- Add "Quick Actions" section to display configured scripts - Load scripts from /api/scripts/list on connection - Display scripts in responsive grid layout - Execute scripts with single click via /api/scripts/execute - Show toast notifications for execution feedback - Visual feedback during script execution - Auto-hide section if no scripts configured Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
1014 lines
32 KiB
HTML
1014 lines
32 KiB
HTML
<!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);
|
|
}
|
|
|
|
/* Scripts Section */
|
|
.scripts-container {
|
|
background: var(--bg-secondary);
|
|
border-radius: 12px;
|
|
padding: 2rem;
|
|
margin-top: 2rem;
|
|
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.5);
|
|
}
|
|
|
|
.scripts-container h2 {
|
|
font-size: 1.25rem;
|
|
margin-bottom: 1rem;
|
|
color: var(--text-primary);
|
|
}
|
|
|
|
.scripts-grid {
|
|
display: grid;
|
|
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
|
|
gap: 1rem;
|
|
}
|
|
|
|
.script-btn {
|
|
width: 100%;
|
|
height: auto;
|
|
min-height: 80px;
|
|
padding: 1rem;
|
|
border-radius: 8px;
|
|
background: var(--bg-tertiary);
|
|
border: 1px solid var(--border);
|
|
color: var(--text-primary);
|
|
cursor: pointer;
|
|
transition: all 0.2s;
|
|
display: flex;
|
|
flex-direction: column;
|
|
align-items: center;
|
|
justify-content: center;
|
|
gap: 0.5rem;
|
|
}
|
|
|
|
.script-btn:hover:not(:disabled) {
|
|
background: var(--accent);
|
|
border-color: var(--accent);
|
|
transform: translateY(-2px);
|
|
}
|
|
|
|
.script-btn:disabled {
|
|
opacity: 0.3;
|
|
cursor: not-allowed;
|
|
}
|
|
|
|
.script-btn .script-label {
|
|
font-weight: 600;
|
|
font-size: 0.875rem;
|
|
}
|
|
|
|
.script-btn .script-description {
|
|
font-size: 0.75rem;
|
|
color: var(--text-secondary);
|
|
text-align: center;
|
|
}
|
|
|
|
.script-btn.executing {
|
|
opacity: 0.6;
|
|
pointer-events: none;
|
|
}
|
|
|
|
.scripts-empty {
|
|
text-align: center;
|
|
color: var(--text-muted);
|
|
padding: 2rem;
|
|
font-size: 0.875rem;
|
|
}
|
|
|
|
.toast {
|
|
position: fixed;
|
|
bottom: 2rem;
|
|
right: 2rem;
|
|
background: var(--bg-secondary);
|
|
border: 1px solid var(--border);
|
|
border-radius: 8px;
|
|
padding: 1rem 1.5rem;
|
|
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.5);
|
|
opacity: 0;
|
|
transform: translateY(20px);
|
|
transition: all 0.3s;
|
|
pointer-events: none;
|
|
z-index: 1000;
|
|
}
|
|
|
|
.toast.show {
|
|
opacity: 1;
|
|
transform: translateY(0);
|
|
}
|
|
|
|
.toast.success {
|
|
border-color: var(--accent);
|
|
}
|
|
|
|
.toast.error {
|
|
border-color: var(--error);
|
|
}
|
|
|
|
/* 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>
|
|
|
|
<!-- Scripts Section -->
|
|
<div class="scripts-container" id="scripts-container" style="display: none;">
|
|
<h2>Quick Actions</h2>
|
|
<div class="scripts-grid" id="scripts-grid">
|
|
<div class="scripts-empty">No scripts configured</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Toast Notification -->
|
|
<div class="toast" id="toast"></div>
|
|
|
|
<script>
|
|
let ws = null;
|
|
let reconnectTimeout = null;
|
|
let currentState = 'idle';
|
|
let currentDuration = 0;
|
|
let currentPosition = 0;
|
|
let isUserAdjustingVolume = false;
|
|
let scripts = [];
|
|
|
|
// Initialize on page load
|
|
window.addEventListener('DOMContentLoaded', () => {
|
|
const token = localStorage.getItem('media_server_token');
|
|
if (token) {
|
|
connectWebSocket(token);
|
|
loadScripts();
|
|
} 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();
|
|
loadScripts();
|
|
};
|
|
|
|
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 });
|
|
}
|
|
|
|
// Scripts functionality
|
|
async function loadScripts() {
|
|
const token = localStorage.getItem('media_server_token');
|
|
|
|
try {
|
|
const response = await fetch('/api/scripts/list', {
|
|
headers: {
|
|
'Authorization': `Bearer ${token}`
|
|
}
|
|
});
|
|
|
|
if (response.ok) {
|
|
scripts = await response.json();
|
|
displayScripts();
|
|
}
|
|
} catch (error) {
|
|
console.error('Error loading scripts:', error);
|
|
}
|
|
}
|
|
|
|
function displayScripts() {
|
|
const container = document.getElementById('scripts-container');
|
|
const grid = document.getElementById('scripts-grid');
|
|
|
|
if (scripts.length === 0) {
|
|
container.style.display = 'none';
|
|
return;
|
|
}
|
|
|
|
container.style.display = 'block';
|
|
grid.innerHTML = '';
|
|
|
|
scripts.forEach(script => {
|
|
const button = document.createElement('button');
|
|
button.className = 'script-btn';
|
|
button.onclick = () => executeScript(script.name, button);
|
|
|
|
const label = document.createElement('div');
|
|
label.className = 'script-label';
|
|
label.textContent = script.label || script.name;
|
|
|
|
button.appendChild(label);
|
|
|
|
if (script.description) {
|
|
const description = document.createElement('div');
|
|
description.className = 'script-description';
|
|
description.textContent = script.description;
|
|
button.appendChild(description);
|
|
}
|
|
|
|
grid.appendChild(button);
|
|
});
|
|
}
|
|
|
|
async function executeScript(scriptName, buttonElement) {
|
|
const token = localStorage.getItem('media_server_token');
|
|
|
|
// Add executing state
|
|
buttonElement.classList.add('executing');
|
|
|
|
try {
|
|
const response = await fetch(`/api/scripts/execute/${scriptName}`, {
|
|
method: 'POST',
|
|
headers: {
|
|
'Authorization': `Bearer ${token}`,
|
|
'Content-Type': 'application/json'
|
|
},
|
|
body: JSON.stringify({ args: [] })
|
|
});
|
|
|
|
const result = await response.json();
|
|
|
|
if (response.ok && result.success) {
|
|
showToast(`${scriptName} executed successfully`, 'success');
|
|
} else {
|
|
showToast(`Failed to execute ${scriptName}`, 'error');
|
|
}
|
|
} catch (error) {
|
|
console.error(`Error executing script ${scriptName}:`, error);
|
|
showToast(`Error executing ${scriptName}`, 'error');
|
|
} finally {
|
|
// Remove executing state
|
|
buttonElement.classList.remove('executing');
|
|
}
|
|
}
|
|
|
|
function showToast(message, type = 'success') {
|
|
const toast = document.getElementById('toast');
|
|
toast.textContent = message;
|
|
toast.className = `toast ${type} show`;
|
|
|
|
setTimeout(() => {
|
|
toast.classList.remove('show');
|
|
}, 3000);
|
|
}
|
|
</script>
|
|
</body>
|
|
</html>
|