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:
2026-02-06 03:25:08 +03:00
parent 1a1cfbaafb
commit a0d138bb93
3 changed files with 867 additions and 5 deletions

View File

@@ -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
```

View File

@@ -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

View 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>