Add built-in Web UI for media control and monitoring
- Add static file serving to FastAPI application - Create responsive web interface with real-time updates - Features: - Real-time status updates via WebSocket - Album artwork display with automatic updates - Playback controls (play, pause, next, previous) - Volume control with mute toggle - Seekable progress bar - Token authentication with localStorage persistence - Dark theme and responsive design - Auto-reconnect WebSocket support - Update README with Web UI documentation - Zero dependencies (vanilla HTML/CSS/JavaScript) Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
58
README.md
58
README.md
@@ -4,14 +4,58 @@ 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
|
||||
- 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
|
||||
|
||||
## Web UI
|
||||
|
||||
The media server includes a built-in web interface for controlling and monitoring media playback.
|
||||
|
||||
### Features
|
||||
|
||||
- **Real-time status updates** via WebSocket connection
|
||||
- **Album artwork display** with automatic updates
|
||||
- **Playback controls** - Play, pause, next, previous
|
||||
- **Volume control** with mute toggle
|
||||
- **Seekable progress bar** - Click to jump to any position
|
||||
- **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
|
||||
|
||||
### Accessing the Web UI
|
||||
|
||||
1. Start the media server:
|
||||
```bash
|
||||
python -m media_server.main
|
||||
```
|
||||
|
||||
2. Open your browser and navigate to:
|
||||
```
|
||||
http://localhost:8765/
|
||||
```
|
||||
|
||||
3. Enter your API token when prompted (get it with `media-server --show-token`)
|
||||
|
||||
4. Start playing media in any supported player and watch the UI update in real-time!
|
||||
|
||||
### Remote Access
|
||||
|
||||
To access the Web UI from other devices on your network:
|
||||
|
||||
1. Find your computer's IP address (e.g., `192.168.1.100`)
|
||||
2. Navigate to `http://192.168.1.100:8765/` from any device on the same network
|
||||
3. Enter your API token
|
||||
|
||||
**Security Note:** For remote access over the internet, use a reverse proxy with HTTPS (nginx, Caddy) to encrypt traffic.
|
||||
|
||||
## Requirements
|
||||
|
||||
- Python 3.10+
|
||||
@@ -71,13 +115,17 @@ Requires Termux and Termux:API apps from F-Droid.
|
||||
python -m media_server.main
|
||||
```
|
||||
|
||||
4. Test the connection:
|
||||
```bash
|
||||
curl http://localhost:8765/api/health
|
||||
```
|
||||
4. **Open the Web UI** (recommended):
|
||||
- Navigate to `http://localhost:8765/` in your browser
|
||||
- Enter your API token from step 2
|
||||
- Start playing media and control it from the web interface!
|
||||
|
||||
5. Test with authentication:
|
||||
5. Or test via API:
|
||||
```bash
|
||||
# Health check (no auth required)
|
||||
curl http://localhost:8765/api/health
|
||||
|
||||
# Get media status
|
||||
curl -H "Authorization: Bearer YOUR_TOKEN" http://localhost:8765/api/media/status
|
||||
```
|
||||
|
||||
|
||||
@@ -4,10 +4,13 @@ import argparse
|
||||
import logging
|
||||
import sys
|
||||
from contextlib import asynccontextmanager
|
||||
from pathlib import Path
|
||||
|
||||
import uvicorn
|
||||
from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.responses import FileResponse
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
|
||||
from . import __version__
|
||||
from .config import settings, generate_default_config, get_config_dir
|
||||
@@ -69,6 +72,16 @@ def create_app() -> FastAPI:
|
||||
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.mount("/static", StaticFiles(directory=str(static_dir)), name="static")
|
||||
|
||||
@app.get("/", include_in_schema=False)
|
||||
async def serve_ui():
|
||||
"""Serve the Web UI."""
|
||||
return FileResponse(static_dir / "index.html")
|
||||
|
||||
return app
|
||||
|
||||
|
||||
|
||||
801
media_server/static/index.html
Normal file
801
media_server/static/index.html
Normal file
@@ -0,0 +1,801 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Media Server</title>
|
||||
<style>
|
||||
:root {
|
||||
--bg-primary: #121212;
|
||||
--bg-secondary: #1e1e1e;
|
||||
--bg-tertiary: #282828;
|
||||
--text-primary: #ffffff;
|
||||
--text-secondary: #b3b3b3;
|
||||
--text-muted: #6a6a6a;
|
||||
--accent: #1db954;
|
||||
--accent-hover: #1ed760;
|
||||
--border: #404040;
|
||||
--error: #e74c3c;
|
||||
}
|
||||
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Helvetica', 'Arial', sans-serif;
|
||||
background: var(--bg-primary);
|
||||
color: var(--text-primary);
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.container {
|
||||
max-width: 800px;
|
||||
margin: 0 auto;
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 2rem;
|
||||
padding-bottom: 1rem;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.status-indicator {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
font-size: 0.875rem;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.status-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: var(--error);
|
||||
transition: background 0.3s;
|
||||
}
|
||||
|
||||
.status-dot.connected {
|
||||
background: var(--accent);
|
||||
}
|
||||
|
||||
.player-container {
|
||||
background: var(--bg-secondary);
|
||||
border-radius: 12px;
|
||||
padding: 2rem;
|
||||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
|
||||
.album-art-container {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
#album-art {
|
||||
width: 300px;
|
||||
height: 300px;
|
||||
object-fit: cover;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.5);
|
||||
background: var(--bg-tertiary);
|
||||
}
|
||||
|
||||
.track-info {
|
||||
text-align: center;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
#track-title {
|
||||
font-size: 1.75rem;
|
||||
font-weight: 600;
|
||||
margin-bottom: 0.5rem;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
#artist {
|
||||
font-size: 1.125rem;
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
#album {
|
||||
font-size: 0.875rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.playback-state {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
font-size: 0.875rem;
|
||||
color: var(--text-secondary);
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.state-icon {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
.progress-container {
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.time-display {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.progress-bar {
|
||||
width: 100%;
|
||||
height: 6px;
|
||||
background: var(--bg-tertiary);
|
||||
border-radius: 3px;
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.progress-fill {
|
||||
height: 100%;
|
||||
background: var(--accent);
|
||||
border-radius: 3px;
|
||||
width: 0;
|
||||
transition: width 0.1s linear;
|
||||
}
|
||||
|
||||
.controls {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
button {
|
||||
background: var(--bg-tertiary);
|
||||
border: none;
|
||||
color: var(--text-primary);
|
||||
cursor: pointer;
|
||||
border-radius: 50%;
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
button:hover:not(:disabled) {
|
||||
background: var(--accent);
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
button:disabled {
|
||||
opacity: 0.3;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
button.primary {
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
background: var(--accent);
|
||||
}
|
||||
|
||||
button.primary:hover:not(:disabled) {
|
||||
background: var(--accent-hover);
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
.volume-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
padding: 1rem;
|
||||
background: var(--bg-tertiary);
|
||||
border-radius: 8px;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
#volume-slider {
|
||||
flex: 1;
|
||||
height: 6px;
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
background: var(--bg-primary);
|
||||
border-radius: 3px;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
#volume-slider::-webkit-slider-thumb {
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
background: var(--accent);
|
||||
border-radius: 50%;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
#volume-slider::-moz-range-thumb {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
background: var(--accent);
|
||||
border-radius: 50%;
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.volume-display {
|
||||
font-size: 0.875rem;
|
||||
color: var(--text-secondary);
|
||||
min-width: 40px;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.mute-btn {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
}
|
||||
|
||||
.source-info {
|
||||
text-align: center;
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-muted);
|
||||
padding-top: 1rem;
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
|
||||
/* Auth Modal */
|
||||
#auth-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.9);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
#auth-overlay.hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.auth-modal {
|
||||
background: var(--bg-secondary);
|
||||
padding: 2rem;
|
||||
border-radius: 12px;
|
||||
max-width: 400px;
|
||||
width: 90%;
|
||||
}
|
||||
|
||||
.auth-modal h2 {
|
||||
margin-bottom: 1rem;
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
.auth-modal p {
|
||||
margin-bottom: 1rem;
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
#token-input {
|
||||
width: 100%;
|
||||
padding: 0.75rem;
|
||||
background: var(--bg-tertiary);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
color: var(--text-primary);
|
||||
font-size: 0.875rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
#token-input:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.btn-connect {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
padding: 0.75rem;
|
||||
border-radius: 6px;
|
||||
background: var(--accent);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.btn-connect:hover {
|
||||
background: var(--accent-hover);
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.help-text {
|
||||
background: var(--bg-tertiary);
|
||||
padding: 0.75rem;
|
||||
border-radius: 6px;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.help-text code {
|
||||
background: var(--bg-primary);
|
||||
padding: 0.25rem 0.5rem;
|
||||
border-radius: 3px;
|
||||
font-family: monospace;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.error-message {
|
||||
color: var(--error);
|
||||
font-size: 0.875rem;
|
||||
margin-top: 0.5rem;
|
||||
display: none;
|
||||
}
|
||||
|
||||
.error-message.visible {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.clear-token-btn {
|
||||
position: fixed;
|
||||
top: 1rem;
|
||||
right: 1rem;
|
||||
width: auto;
|
||||
height: auto;
|
||||
padding: 0.5rem 1rem;
|
||||
border-radius: 6px;
|
||||
font-size: 0.75rem;
|
||||
background: var(--bg-tertiary);
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.clear-token-btn:hover {
|
||||
opacity: 1;
|
||||
background: var(--error);
|
||||
}
|
||||
|
||||
/* SVG Icons */
|
||||
svg {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
fill: currentColor;
|
||||
}
|
||||
|
||||
button.primary svg {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.container {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
#album-art {
|
||||
width: 250px;
|
||||
height: 250px;
|
||||
}
|
||||
|
||||
#track-title {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<!-- Clear Token Button -->
|
||||
<button class="clear-token-btn" onclick="clearToken()" title="Clear saved token">Logout</button>
|
||||
|
||||
<!-- Auth Modal -->
|
||||
<div id="auth-overlay">
|
||||
<div class="auth-modal">
|
||||
<h2>Media Server</h2>
|
||||
<p>Enter your API token to connect to the media server.</p>
|
||||
<input type="text" id="token-input" placeholder="Enter API Token" autocomplete="off">
|
||||
<button class="btn-connect" onclick="authenticate()">Connect</button>
|
||||
<div class="help-text">
|
||||
<p>To get your token, run:</p>
|
||||
<code>media-server --show-token</code>
|
||||
</div>
|
||||
<div class="error-message" id="auth-error"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="container">
|
||||
<header>
|
||||
<h1>Media Server</h1>
|
||||
<div class="status-indicator">
|
||||
<span class="status-dot" id="status-dot"></span>
|
||||
<span id="status-text">Disconnected</span>
|
||||
</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>
|
||||
|
||||
<div class="track-info">
|
||||
<div id="track-title">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">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">
|
||||
<div class="progress-fill" id="progress-fill"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="controls">
|
||||
<button onclick="previousTrack()" 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()" 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()" 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()" 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">
|
||||
Source: <span id="source">Unknown</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
let ws = null;
|
||||
let reconnectTimeout = null;
|
||||
let currentState = 'idle';
|
||||
let currentDuration = 0;
|
||||
let currentPosition = 0;
|
||||
let isUserAdjustingVolume = false;
|
||||
|
||||
// Initialize on page load
|
||||
window.addEventListener('DOMContentLoaded', () => {
|
||||
const token = localStorage.getItem('media_server_token');
|
||||
if (token) {
|
||||
connectWebSocket(token);
|
||||
} else {
|
||||
showAuthForm();
|
||||
}
|
||||
|
||||
// Volume slider event
|
||||
const volumeSlider = document.getElementById('volume-slider');
|
||||
volumeSlider.addEventListener('input', (e) => {
|
||||
isUserAdjustingVolume = true;
|
||||
const volume = parseInt(e.target.value);
|
||||
document.getElementById('volume-display').textContent = `${volume}%`;
|
||||
});
|
||||
|
||||
volumeSlider.addEventListener('change', (e) => {
|
||||
const volume = parseInt(e.target.value);
|
||||
setVolume(volume);
|
||||
setTimeout(() => { isUserAdjustingVolume = false; }, 500);
|
||||
});
|
||||
|
||||
// Progress bar click to seek
|
||||
const progressBar = document.getElementById('progress-bar');
|
||||
progressBar.addEventListener('click', (e) => {
|
||||
if (currentDuration > 0) {
|
||||
const rect = progressBar.getBoundingClientRect();
|
||||
const x = e.clientX - rect.left;
|
||||
const percent = x / rect.width;
|
||||
const seekPos = percent * currentDuration;
|
||||
seek(seekPos);
|
||||
}
|
||||
});
|
||||
|
||||
// Enter key in token input
|
||||
document.getElementById('token-input').addEventListener('keypress', (e) => {
|
||||
if (e.key === 'Enter') {
|
||||
authenticate();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
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('Please enter a token');
|
||||
return;
|
||||
}
|
||||
|
||||
localStorage.setItem('media_server_token', token);
|
||||
connectWebSocket(token);
|
||||
}
|
||||
|
||||
function clearToken() {
|
||||
localStorage.removeItem('media_server_token');
|
||||
if (ws) {
|
||||
ws.close();
|
||||
}
|
||||
showAuthForm('Token cleared. Please enter a new token.');
|
||||
}
|
||||
|
||||
function connectWebSocket(token) {
|
||||
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
const wsUrl = `${protocol}//${window.location.host}/api/media/ws?token=${encodeURIComponent(token)}`;
|
||||
|
||||
ws = new WebSocket(wsUrl);
|
||||
|
||||
ws.onopen = () => {
|
||||
console.log('WebSocket connected');
|
||||
updateConnectionStatus(true);
|
||||
hideAuthForm();
|
||||
};
|
||||
|
||||
ws.onmessage = (event) => {
|
||||
const msg = JSON.parse(event.data);
|
||||
|
||||
if (msg.type === 'status' || msg.type === 'status_update') {
|
||||
updateUI(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);
|
||||
|
||||
if (event.code === 4001) {
|
||||
// Invalid token
|
||||
localStorage.removeItem('media_server_token');
|
||||
showAuthForm('Invalid token. Please try again.');
|
||||
} else if (event.code !== 1000) {
|
||||
// Abnormal closure - attempt reconnect
|
||||
reconnectTimeout = setTimeout(() => {
|
||||
const savedToken = localStorage.getItem('media_server_token');
|
||||
if (savedToken) {
|
||||
console.log('Attempting to reconnect...');
|
||||
connectWebSocket(savedToken);
|
||||
}
|
||||
}, 3000);
|
||||
}
|
||||
};
|
||||
|
||||
// Send keepalive ping every 30 seconds
|
||||
setInterval(() => {
|
||||
if (ws && ws.readyState === WebSocket.OPEN) {
|
||||
ws.send(JSON.stringify({ type: 'ping' }));
|
||||
}
|
||||
}, 30000);
|
||||
}
|
||||
|
||||
function updateConnectionStatus(connected) {
|
||||
const dot = document.getElementById('status-dot');
|
||||
const text = document.getElementById('status-text');
|
||||
|
||||
if (connected) {
|
||||
dot.classList.add('connected');
|
||||
text.textContent = 'Connected';
|
||||
} else {
|
||||
dot.classList.remove('connected');
|
||||
text.textContent = 'Disconnected';
|
||||
}
|
||||
}
|
||||
|
||||
function updateUI(status) {
|
||||
// Update track info
|
||||
document.getElementById('track-title').textContent = status.title || 'No media playing';
|
||||
document.getElementById('artist').textContent = status.artist || '';
|
||||
document.getElementById('album').textContent = status.album || '';
|
||||
|
||||
// Update state
|
||||
currentState = status.state;
|
||||
updatePlaybackState(status.state);
|
||||
|
||||
// Update album art
|
||||
const artImg = document.getElementById('album-art');
|
||||
if (status.album_art_url) {
|
||||
const token = localStorage.getItem('media_server_token');
|
||||
artImg.src = `/api/media/artwork?token=${encodeURIComponent(token)}&_=${Date.now()}`;
|
||||
} else {
|
||||
artImg.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";
|
||||
}
|
||||
|
||||
// Update progress
|
||||
if (status.duration && status.position !== null) {
|
||||
currentDuration = status.duration;
|
||||
currentPosition = status.position;
|
||||
updateProgress(status.position, status.duration);
|
||||
}
|
||||
|
||||
// Update volume
|
||||
if (!isUserAdjustingVolume) {
|
||||
document.getElementById('volume-slider').value = status.volume;
|
||||
document.getElementById('volume-display').textContent = `${status.volume}%`;
|
||||
}
|
||||
|
||||
// Update mute state
|
||||
updateMuteIcon(status.muted);
|
||||
|
||||
// Update source
|
||||
document.getElementById('source').textContent = status.source || 'Unknown';
|
||||
|
||||
// Enable/disable controls based on state
|
||||
const hasMedia = status.state !== 'idle';
|
||||
document.getElementById('btn-play-pause').disabled = !hasMedia;
|
||||
document.getElementById('btn-next').disabled = !hasMedia;
|
||||
document.getElementById('btn-previous').disabled = !hasMedia;
|
||||
}
|
||||
|
||||
function updatePlaybackState(state) {
|
||||
const stateText = document.getElementById('playback-state');
|
||||
const stateIcon = document.getElementById('state-icon');
|
||||
const playPauseIcon = document.getElementById('play-pause-icon');
|
||||
|
||||
switch(state) {
|
||||
case 'playing':
|
||||
stateText.textContent = 'Playing';
|
||||
stateIcon.innerHTML = '<path d="M8 5v14l11-7z"/>';
|
||||
playPauseIcon.innerHTML = '<path d="M6 19h4V5H6v14zm8-14v14h4V5h-4z"/>';
|
||||
break;
|
||||
case 'paused':
|
||||
stateText.textContent = 'Paused';
|
||||
stateIcon.innerHTML = '<path d="M6 19h4V5H6v14zm8-14v14h4V5h-4z"/>';
|
||||
playPauseIcon.innerHTML = '<path d="M8 5v14l11-7z"/>';
|
||||
break;
|
||||
case 'stopped':
|
||||
stateText.textContent = 'Stopped';
|
||||
stateIcon.innerHTML = '<path d="M6 6h12v12H6z"/>';
|
||||
playPauseIcon.innerHTML = '<path d="M8 5v14l11-7z"/>';
|
||||
break;
|
||||
default:
|
||||
stateText.textContent = 'Idle';
|
||||
stateIcon.innerHTML = '<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"/>';
|
||||
playPauseIcon.innerHTML = '<path d="M8 5v14l11-7z"/>';
|
||||
}
|
||||
}
|
||||
|
||||
function updateProgress(position, duration) {
|
||||
const percent = (position / duration) * 100;
|
||||
document.getElementById('progress-fill').style.width = `${percent}%`;
|
||||
document.getElementById('current-time').textContent = formatTime(position);
|
||||
document.getElementById('total-time').textContent = formatTime(duration);
|
||||
document.getElementById('progress-bar').dataset.duration = duration;
|
||||
}
|
||||
|
||||
function updateMuteIcon(muted) {
|
||||
const muteIcon = document.getElementById('mute-icon');
|
||||
if (muted) {
|
||||
muteIcon.innerHTML = '<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"/>';
|
||||
} else {
|
||||
muteIcon.innerHTML = '<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"/>';
|
||||
}
|
||||
}
|
||||
|
||||
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')}`;
|
||||
}
|
||||
|
||||
// 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) {
|
||||
console.error(`Command ${endpoint} failed:`, response.status);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Error sending command ${endpoint}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
function togglePlayPause() {
|
||||
if (currentState === 'playing') {
|
||||
sendCommand('pause');
|
||||
} else {
|
||||
sendCommand('play');
|
||||
}
|
||||
}
|
||||
|
||||
function nextTrack() {
|
||||
sendCommand('next');
|
||||
}
|
||||
|
||||
function previousTrack() {
|
||||
sendCommand('previous');
|
||||
}
|
||||
|
||||
function setVolume(volume) {
|
||||
sendCommand('volume', { volume: volume });
|
||||
}
|
||||
|
||||
function toggleMute() {
|
||||
sendCommand('mute');
|
||||
}
|
||||
|
||||
function seek(position) {
|
||||
sendCommand('seek', { position: position });
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user