Compare commits
4 Commits
652f10fc4c
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| be48318212 | |||
| 0eca8292cb | |||
| 3cfc437599 | |||
| a20812ec29 |
37
README.md
37
README.md
@@ -5,7 +5,10 @@ A REST API server for controlling system media playback on Windows, Linux, macOS
|
|||||||
## Features
|
## Features
|
||||||
|
|
||||||
- **Built-in Web UI** for real-time media control and monitoring
|
- **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
|
- **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
|
- **Quick Actions & Scripts** - Execute custom scripts with one click
|
||||||
- **Callbacks** - Trigger commands on media events (play, pause, volume, etc.)
|
- **Callbacks** - Trigger commands on media events (play, pause, volume, etc.)
|
||||||
- Control any media player via system-wide media transport controls
|
- 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
|
- **Mini player** - Sticky compact player that appears when scrolling away from the main player
|
||||||
- **Connection status indicator** - Know when you're connected
|
- **Connection status indicator** - Know when you're connected
|
||||||
- **Token authentication** - Saved in browser localStorage
|
- **Token authentication** - Saved in browser localStorage
|
||||||
- **Responsive design** - Works on desktop and mobile
|
- **Audio spectrum visualizer** - Real-time frequency bars with beat-reactive album art scaling and glow (on-demand WASAPI loopback capture)
|
||||||
- **Dark and light themes** - Toggle between dark and light modes
|
- **Display control** - Monitor brightness adjustment and power on/off
|
||||||
- **Accent color picker** - Choose from 9 preset accent colors (green, blue, purple, pink, orange, red, teal, cyan, yellow)
|
- **Installable PWA** - Add to home screen on mobile/desktop for standalone app experience with safe area support for notched phones
|
||||||
- **Tab-based navigation** - Player, Browser, Quick Actions, Scripts, and Callbacks tabs
|
- **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
|
- **Multi-language support** - English and Russian locales with automatic detection
|
||||||
|
|
||||||
### Accessing the Web UI
|
### 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!
|
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
|
### Remote Access
|
||||||
|
|
||||||
To access the Web UI from other devices on your network:
|
To access the Web UI from other devices on your network:
|
||||||
|
|||||||
@@ -154,6 +154,15 @@ def create_app() -> FastAPI:
|
|||||||
# Mount static files and serve UI at root
|
# Mount static files and serve UI at root
|
||||||
static_dir = Path(__file__).parent / "static"
|
static_dir = Path(__file__).parent / "static"
|
||||||
if static_dir.exists():
|
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.mount("/static", StaticFiles(directory=str(static_dir)), name="static")
|
||||||
|
|
||||||
@app.get("/", include_in_schema=False)
|
@app.get("/", include_in_schema=False)
|
||||||
|
|||||||
@@ -63,6 +63,27 @@
|
|||||||
box-shadow: 0 -2px 8px rgba(0, 0, 0, 0.08);
|
box-shadow: 0 -2px 8px rgba(0, 0, 0, 0.08);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Dynamic Background Canvas */
|
||||||
|
.bg-shader-canvas {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100vw;
|
||||||
|
height: 100vh;
|
||||||
|
z-index: -1;
|
||||||
|
pointer-events: none;
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.6s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bg-shader-canvas.visible {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.dynamic-bg-active {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
* {
|
* {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
@@ -218,6 +239,10 @@ h1 {
|
|||||||
background: var(--bg-tertiary);
|
background: var(--bg-tertiary);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.header-btn.active {
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
.header-btn svg {
|
.header-btn svg {
|
||||||
width: 16px;
|
width: 16px;
|
||||||
height: 16px;
|
height: 16px;
|
||||||
@@ -479,10 +504,17 @@ h1 {
|
|||||||
|
|
||||||
[data-tab-content] {
|
[data-tab-content] {
|
||||||
display: none;
|
display: none;
|
||||||
|
opacity: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-tab-content].active {
|
[data-tab-content].active {
|
||||||
display: block;
|
display: block;
|
||||||
|
animation: tabFadeIn 0.25s ease-out forwards;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes tabFadeIn {
|
||||||
|
from { opacity: 0; transform: translateY(6px); }
|
||||||
|
to { opacity: 1; transform: translateY(0); }
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 600px) {
|
@media (max-width: 600px) {
|
||||||
@@ -1162,7 +1194,7 @@ button:disabled {
|
|||||||
border-right: 2px solid var(--text-muted);
|
border-right: 2px solid var(--text-muted);
|
||||||
border-bottom: 2px solid var(--text-muted);
|
border-bottom: 2px solid var(--text-muted);
|
||||||
transform: rotate(-45deg);
|
transform: rotate(-45deg);
|
||||||
transition: transform 0.2s;
|
transition: transform 0.3s ease;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1177,6 +1209,12 @@ button:disabled {
|
|||||||
.settings-section-content {
|
.settings-section-content {
|
||||||
padding: 0 1rem 1rem;
|
padding: 0 1rem 1rem;
|
||||||
overflow-x: auto;
|
overflow-x: auto;
|
||||||
|
animation: settingsExpand 0.3s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes settingsExpand {
|
||||||
|
from { opacity: 0; transform: translateY(-8px); }
|
||||||
|
to { opacity: 1; transform: translateY(0); }
|
||||||
}
|
}
|
||||||
|
|
||||||
.settings-section-description {
|
.settings-section-description {
|
||||||
@@ -1660,6 +1698,11 @@ dialog {
|
|||||||
width: 90%;
|
width: 90%;
|
||||||
margin: auto;
|
margin: auto;
|
||||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.8);
|
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.8);
|
||||||
|
animation: dialogIn 0.25s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
dialog.dialog-closing {
|
||||||
|
animation: dialogOut 0.2s ease-in forwards;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Ensure dialogs are hidden until explicitly opened */
|
/* Ensure dialogs are hidden until explicitly opened */
|
||||||
@@ -1669,6 +1712,31 @@ dialog:not([open]) {
|
|||||||
|
|
||||||
dialog::backdrop {
|
dialog::backdrop {
|
||||||
background: rgba(0, 0, 0, 0.8);
|
background: rgba(0, 0, 0, 0.8);
|
||||||
|
animation: backdropIn 0.25s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
dialog.dialog-closing::backdrop {
|
||||||
|
animation: backdropOut 0.2s ease-in forwards;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes dialogIn {
|
||||||
|
from { opacity: 0; transform: scale(0.9) translateY(10px); }
|
||||||
|
to { opacity: 1; transform: scale(1) translateY(0); }
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes dialogOut {
|
||||||
|
from { opacity: 1; transform: scale(1) translateY(0); }
|
||||||
|
to { opacity: 0; transform: scale(0.9) translateY(10px); }
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes backdropIn {
|
||||||
|
from { opacity: 0; }
|
||||||
|
to { opacity: 1; }
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes backdropOut {
|
||||||
|
from { opacity: 1; }
|
||||||
|
to { opacity: 0; }
|
||||||
}
|
}
|
||||||
|
|
||||||
.confirm-dialog {
|
.confirm-dialog {
|
||||||
@@ -2582,6 +2650,8 @@ footer .separator {
|
|||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: all 0.15s;
|
transition: all 0.15s;
|
||||||
|
animation: itemFadeIn 0.3s ease-out backwards;
|
||||||
|
animation-delay: calc(var(--item-index, 0) * 20ms);
|
||||||
}
|
}
|
||||||
|
|
||||||
.browser-list-item:hover {
|
.browser-list-item:hover {
|
||||||
@@ -2709,6 +2779,13 @@ footer .separator {
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
position: relative;
|
position: relative;
|
||||||
|
animation: itemFadeIn 0.3s ease-out backwards;
|
||||||
|
animation-delay: calc(var(--item-index, 0) * 30ms);
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes itemFadeIn {
|
||||||
|
from { opacity: 0; transform: translateY(8px) scale(0.97); }
|
||||||
|
to { opacity: 1; transform: translateY(0) scale(1); }
|
||||||
}
|
}
|
||||||
|
|
||||||
.browser-item:hover {
|
.browser-item:hover {
|
||||||
@@ -3067,6 +3144,10 @@ footer .separator {
|
|||||||
padding-top: calc(0.5rem + 2px);
|
padding-top: calc(0.5rem + 2px);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.mini-nav-btn {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
.mini-player-info {
|
.mini-player-info {
|
||||||
min-width: 120px;
|
min-width: 120px;
|
||||||
}
|
}
|
||||||
@@ -3111,11 +3192,25 @@ footer .separator {
|
|||||||
transition: transform 0.3s ease;
|
transition: transform 0.3s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.connection-banner:not(.hidden) {
|
||||||
|
animation: bannerSlideIn 0.4s ease-out, bannerPulse 2s ease-in-out 0.4s 2;
|
||||||
|
}
|
||||||
|
|
||||||
.connection-banner.hidden {
|
.connection-banner.hidden {
|
||||||
transform: translateY(-100%);
|
transform: translateY(-100%);
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@keyframes bannerSlideIn {
|
||||||
|
from { transform: translateY(-100%); }
|
||||||
|
to { transform: translateY(0); }
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes bannerPulse {
|
||||||
|
0%, 100% { opacity: 1; }
|
||||||
|
50% { opacity: 0.7; }
|
||||||
|
}
|
||||||
|
|
||||||
.connection-banner-btn {
|
.connection-banner-btn {
|
||||||
padding: 4px 14px;
|
padding: 4px 14px;
|
||||||
background: rgba(255, 255, 255, 0.2);
|
background: rgba(255, 255, 255, 0.2);
|
||||||
@@ -3164,3 +3259,81 @@ footer .separator {
|
|||||||
justify-content: flex-start;
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Accessibility: reduce motion for users who prefer it */
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
*, *::before, *::after {
|
||||||
|
animation-duration: 0.01ms !important;
|
||||||
|
animation-iteration-count: 1 !important;
|
||||||
|
transition-duration: 0.01ms !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
10
media_server/static/icons/icon.svg
Normal file
10
media_server/static/icons/icon.svg
Normal 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 |
@@ -2,9 +2,16 @@
|
|||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<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>
|
<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">
|
<link rel="stylesheet" href="/static/css/styles.css">
|
||||||
</head>
|
</head>
|
||||||
<body class="loading-translations">
|
<body class="loading-translations">
|
||||||
@@ -50,6 +57,9 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Dynamic Background -->
|
||||||
|
<canvas id="bg-shader-canvas" class="bg-shader-canvas"></canvas>
|
||||||
|
|
||||||
<!-- Auth Modal -->
|
<!-- Auth Modal -->
|
||||||
<div id="auth-overlay" class="hidden">
|
<div id="auth-overlay" class="hidden">
|
||||||
<div class="auth-modal">
|
<div class="auth-modal">
|
||||||
@@ -79,6 +89,9 @@
|
|||||||
</button>
|
</button>
|
||||||
<div class="accent-picker-dropdown" id="accentDropdown"></div>
|
<div class="accent-picker-dropdown" id="accentDropdown"></div>
|
||||||
</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">
|
<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;">
|
<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"/>
|
<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"/>
|
||||||
@@ -645,6 +658,7 @@
|
|||||||
<script src="/static/js/callbacks.js"></script>
|
<script src="/static/js/callbacks.js"></script>
|
||||||
<script src="/static/js/browser.js"></script>
|
<script src="/static/js/browser.js"></script>
|
||||||
<script src="/static/js/links.js"></script>
|
<script src="/static/js/links.js"></script>
|
||||||
|
<script src="/static/js/background.js"></script>
|
||||||
<script src="/static/js/main.js"></script>
|
<script src="/static/js/main.js"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
314
media_server/static/js/background.js
Normal file
314
media_server/static/js/background.js
Normal 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');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -250,9 +250,10 @@ function renderBrowserList(items, container) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
items.forEach(item => {
|
items.forEach((item, idx) => {
|
||||||
const row = document.createElement('div');
|
const row = document.createElement('div');
|
||||||
row.className = 'browser-list-item';
|
row.className = 'browser-list-item';
|
||||||
|
row.style.setProperty('--item-index', Math.min(idx, 20));
|
||||||
row.dataset.name = item.name;
|
row.dataset.name = item.name;
|
||||||
row.dataset.type = item.type;
|
row.dataset.type = item.type;
|
||||||
|
|
||||||
@@ -343,9 +344,10 @@ function renderBrowserGrid(items, container) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
items.forEach(item => {
|
items.forEach((item, idx) => {
|
||||||
const div = document.createElement('div');
|
const div = document.createElement('div');
|
||||||
div.className = 'browser-item';
|
div.className = 'browser-item';
|
||||||
|
div.style.setProperty('--item-index', Math.min(idx, 20));
|
||||||
div.dataset.name = item.name;
|
div.dataset.name = item.name;
|
||||||
div.dataset.type = item.type;
|
div.dataset.type = item.type;
|
||||||
|
|
||||||
@@ -870,7 +872,7 @@ function showManageFoldersDialog() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function closeFolderDialog() {
|
function closeFolderDialog() {
|
||||||
document.getElementById('folderDialog').close();
|
closeDialog(document.getElementById('folderDialog'));
|
||||||
}
|
}
|
||||||
|
|
||||||
async function saveFolder(event) {
|
async function saveFolder(event) {
|
||||||
|
|||||||
@@ -124,7 +124,7 @@ async function closeCallbackDialog() {
|
|||||||
|
|
||||||
const dialog = document.getElementById('callbackDialog');
|
const dialog = document.getElementById('callbackDialog');
|
||||||
callbackFormDirty = false;
|
callbackFormDirty = false;
|
||||||
dialog.close();
|
closeDialog(dialog);
|
||||||
document.body.classList.remove('dialog-open');
|
document.body.classList.remove('dialog-open');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -331,6 +331,14 @@ function showToast(message, type = 'success') {
|
|||||||
}, TOAST_DURATION_MS);
|
}, 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) {
|
function showConfirm(message) {
|
||||||
return new Promise((resolve) => {
|
return new Promise((resolve) => {
|
||||||
const dialog = document.getElementById('confirmDialog');
|
const dialog = document.getElementById('confirmDialog');
|
||||||
@@ -344,7 +352,7 @@ function showConfirm(message) {
|
|||||||
btnCancel.removeEventListener('click', onCancel);
|
btnCancel.removeEventListener('click', onCancel);
|
||||||
btnConfirm.removeEventListener('click', onConfirm);
|
btnConfirm.removeEventListener('click', onConfirm);
|
||||||
dialog.removeEventListener('close', onClose);
|
dialog.removeEventListener('close', onClose);
|
||||||
dialog.close();
|
closeDialog(dialog);
|
||||||
}
|
}
|
||||||
|
|
||||||
function onCancel() { cleanup(); resolve(false); }
|
function onCancel() { cleanup(); resolve(false); }
|
||||||
|
|||||||
@@ -329,7 +329,7 @@ async function closeLinkDialog() {
|
|||||||
|
|
||||||
const dialog = document.getElementById('linkDialog');
|
const dialog = document.getElementById('linkDialog');
|
||||||
linkFormDirty = false;
|
linkFormDirty = false;
|
||||||
dialog.close();
|
closeDialog(dialog);
|
||||||
document.body.classList.remove('dialog-open');
|
document.body.classList.remove('dialog-open');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,11 @@ window.addEventListener('DOMContentLoaded', async () => {
|
|||||||
initTheme();
|
initTheme();
|
||||||
initAccentColor();
|
initAccentColor();
|
||||||
|
|
||||||
|
// Register service worker for PWA installability
|
||||||
|
if ('serviceWorker' in navigator) {
|
||||||
|
navigator.serviceWorker.register('/sw.js').catch(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
// Initialize vinyl mode
|
// Initialize vinyl mode
|
||||||
applyVinylMode();
|
applyVinylMode();
|
||||||
|
|
||||||
@@ -20,6 +25,9 @@ window.addEventListener('DOMContentLoaded', async () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Initialize dynamic background
|
||||||
|
applyDynamicBackground();
|
||||||
|
|
||||||
// Initialize locale (async - loads JSON file)
|
// Initialize locale (async - loads JSON file)
|
||||||
await initLocale();
|
await initLocale();
|
||||||
|
|
||||||
|
|||||||
@@ -94,6 +94,13 @@ function setTheme(theme) {
|
|||||||
sunIcon.style.display = 'block';
|
sunIcon.style.display = 'block';
|
||||||
moonIcon.style.display = 'none';
|
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() {
|
function toggleTheme() {
|
||||||
@@ -142,6 +149,7 @@ function applyAccentColor(color, hover) {
|
|||||||
localStorage.setItem('accentColor', color);
|
localStorage.setItem('accentColor', color);
|
||||||
const dot = document.getElementById('accentDot');
|
const dot = document.getElementById('accentDot');
|
||||||
if (dot) dot.style.background = color;
|
if (dot) dot.style.background = color;
|
||||||
|
if (typeof updateBackgroundColors === 'function') updateBackgroundColors();
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderAccentSwatches() {
|
function renderAccentSwatches() {
|
||||||
@@ -489,7 +497,7 @@ async function onAudioDeviceChanged() {
|
|||||||
|
|
||||||
if (resp.ok) {
|
if (resp.ok) {
|
||||||
const result = await resp.json();
|
const result = await resp.json();
|
||||||
updateAudioDeviceStatus(result);
|
updateAudioDeviceStatus({ available: result.success, ...result });
|
||||||
await checkVisualizerAvailability();
|
await checkVisualizerAvailability();
|
||||||
if (visualizerEnabled) applyVisualizerMode();
|
if (visualizerEnabled) applyVisualizerMode();
|
||||||
showToast(t('settings.audio.device_changed'), 'success');
|
showToast(t('settings.audio.device_changed'), 'success');
|
||||||
|
|||||||
@@ -283,7 +283,7 @@ async function closeScriptDialog() {
|
|||||||
|
|
||||||
const dialog = document.getElementById('scriptDialog');
|
const dialog = document.getElementById('scriptDialog');
|
||||||
scriptFormDirty = false;
|
scriptFormDirty = false;
|
||||||
dialog.close();
|
closeDialog(dialog);
|
||||||
document.body.classList.remove('dialog-open');
|
document.body.classList.remove('dialog-open');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -375,7 +375,7 @@ async function deleteScriptConfirm(scriptName) {
|
|||||||
|
|
||||||
function closeExecutionDialog() {
|
function closeExecutionDialog() {
|
||||||
const dialog = document.getElementById('executionDialog');
|
const dialog = document.getElementById('executionDialog');
|
||||||
dialog.close();
|
closeDialog(dialog);
|
||||||
document.body.classList.remove('dialog-open');
|
document.body.classList.remove('dialog-open');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -24,6 +24,7 @@
|
|||||||
"player.unknown_source": "Unknown",
|
"player.unknown_source": "Unknown",
|
||||||
"player.vinyl": "Vinyl mode",
|
"player.vinyl": "Vinyl mode",
|
||||||
"player.visualizer": "Audio visualizer",
|
"player.visualizer": "Audio visualizer",
|
||||||
|
"player.background": "Dynamic background",
|
||||||
"state.playing": "Playing",
|
"state.playing": "Playing",
|
||||||
"state.paused": "Paused",
|
"state.paused": "Paused",
|
||||||
"state.stopped": "Stopped",
|
"state.stopped": "Stopped",
|
||||||
|
|||||||
@@ -24,6 +24,7 @@
|
|||||||
"player.unknown_source": "Неизвестно",
|
"player.unknown_source": "Неизвестно",
|
||||||
"player.vinyl": "Режим винила",
|
"player.vinyl": "Режим винила",
|
||||||
"player.visualizer": "Аудио визуализатор",
|
"player.visualizer": "Аудио визуализатор",
|
||||||
|
"player.background": "Динамический фон",
|
||||||
"state.playing": "Воспроизведение",
|
"state.playing": "Воспроизведение",
|
||||||
"state.paused": "Пауза",
|
"state.paused": "Пауза",
|
||||||
"state.stopped": "Остановлено",
|
"state.stopped": "Остановлено",
|
||||||
|
|||||||
23
media_server/static/manifest.json
Normal file
23
media_server/static/manifest.json
Normal 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
15
media_server/static/sw.js
Normal 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));
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user