Add callback management API/UI and theme support

- Add callback CRUD endpoints (create, update, delete, list)
- Add callback management UI with all 11 callback events support
- Add light/dark theme switcher with localStorage persistence
- Improve button styling (wider buttons, simplified text)
- Extend ConfigManager with callback operations

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-02-06 04:11:57 +03:00
parent d7c5994e56
commit a0af855846
5 changed files with 664 additions and 10 deletions

View File

@@ -18,6 +18,19 @@
--error: #e74c3c;
}
:root[data-theme="light"] {
--bg-primary: #ffffff;
--bg-secondary: #f5f5f5;
--bg-tertiary: #e8e8e8;
--text-primary: #1a1a1a;
--text-secondary: #4a4a4a;
--text-muted: #888888;
--accent: #1db954;
--accent-hover: #1ed760;
--border: #d0d0d0;
--error: #e74c3c;
}
* {
margin: 0;
padding: 0;
@@ -71,6 +84,30 @@
background: var(--accent);
}
.theme-toggle {
background: var(--bg-tertiary);
border: 1px solid var(--border);
border-radius: 8px;
padding: 0.5rem;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.3s;
width: 40px;
height: 40px;
}
.theme-toggle:hover {
background: var(--border);
}
.theme-toggle svg {
width: 20px;
height: 20px;
fill: var(--text-primary);
}
.player-container {
background: var(--bg-secondary);
border-radius: 12px;
@@ -352,7 +389,7 @@
}
.add-script-btn {
padding: 0.5rem 1rem;
padding: 0.5rem 1.5rem;
border-radius: 6px;
background: var(--accent);
border: none;
@@ -361,6 +398,7 @@
font-size: 0.875rem;
font-weight: 600;
transition: background 0.2s;
min-width: 140px;
}
.add-script-btn:hover {
@@ -459,7 +497,8 @@
}
.dialog-body input,
.dialog-body textarea {
.dialog-body textarea,
.dialog-body select {
display: block;
width: 100%;
padding: 0.5rem;
@@ -478,7 +517,8 @@
}
.dialog-body input:focus,
.dialog-body textarea:focus {
.dialog-body textarea:focus,
.dialog-body select:focus {
outline: none;
border-color: var(--accent);
}
@@ -723,9 +763,19 @@
<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 style="display: flex; align-items: center; gap: 1rem;">
<button class="theme-toggle" onclick="toggleTheme()" title="Toggle theme" id="theme-toggle">
<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"/>
</svg>
<svg id="theme-icon-moon" viewBox="0 0 24 24">
<path d="M9 2c-1.05 0-2.05.16-3 .46 4.06 1.27 7 5.06 7 9.54 0 4.48-2.94 8.27-7 9.54.95.3 1.95.46 3 .46 5.52 0 10-4.48 10-10S14.52 2 9 2z"/>
</svg>
</button>
<div class="status-indicator">
<span class="status-dot" id="status-dot"></span>
<span id="status-text">Disconnected</span>
</div>
</div>
</header>
@@ -801,7 +851,7 @@
<div class="script-management">
<div class="script-management-header">
<h2>Script Management</h2>
<button class="add-script-btn" onclick="showAddScriptDialog()">+ Add Script</button>
<button class="add-script-btn" onclick="showAddScriptDialog()">+ Add</button>
</div>
<table class="scripts-table">
<thead>
@@ -820,6 +870,32 @@
</tbody>
</table>
</div>
<!-- Callback Management Section -->
<div class="script-management">
<div class="script-management-header">
<h2>Callback Management</h2>
<button class="add-script-btn" onclick="showAddCallbackDialog()">+ Add</button>
</div>
<p style="color: var(--text-secondary); font-size: 0.875rem; margin-bottom: 1rem;">
Callbacks are scripts triggered automatically by media control events (play, pause, volume, etc.)
</p>
<table class="scripts-table">
<thead>
<tr>
<th>Event</th>
<th>Command</th>
<th>Timeout</th>
<th>Actions</th>
</tr>
</thead>
<tbody id="callbacksTableBody">
<tr>
<td colspan="4" class="empty-state">No callbacks configured. Click "Add Callback" to create one.</td>
</tr>
</tbody>
</table>
</div>
</div>
<!-- Add/Edit Script Dialog -->
@@ -870,10 +946,87 @@
</form>
</dialog>
<!-- Add/Edit Callback Dialog -->
<dialog id="callbackDialog">
<div class="dialog-header">
<h3 id="callbackDialogTitle">Add Callback</h3>
</div>
<form id="callbackForm" onsubmit="saveCallback(event)">
<div class="dialog-body">
<input type="hidden" id="callbackIsEdit">
<label>
Event *
<select id="callbackName" required>
<option value="">Select event...</option>
<option value="on_play">on_play - After play succeeds</option>
<option value="on_pause">on_pause - After pause succeeds</option>
<option value="on_stop">on_stop - After stop succeeds</option>
<option value="on_next">on_next - After next track succeeds</option>
<option value="on_previous">on_previous - After previous track succeeds</option>
<option value="on_volume">on_volume - After volume change</option>
<option value="on_mute">on_mute - After mute toggle</option>
<option value="on_seek">on_seek - After seek succeeds</option>
<option value="on_turn_on">on_turn_on - Callback-only action</option>
<option value="on_turn_off">on_turn_off - Callback-only action</option>
<option value="on_toggle">on_toggle - Callback-only action</option>
</select>
</label>
<label>
Command *
<input type="text" id="callbackCommand" required placeholder="e.g., shutdown /s /t 0">
</label>
<label>
Timeout (seconds)
<input type="number" id="callbackTimeout" value="30" min="1" max="300">
</label>
<label>
Working Directory
<input type="text" id="callbackWorkingDir" placeholder="Optional">
</label>
</div>
<div class="dialog-footer">
<button type="button" class="btn-secondary" onclick="closeCallbackDialog()">Cancel</button>
<button type="submit" class="btn-primary">Save</button>
</div>
</form>
</dialog>
<!-- Toast Notification -->
<div class="toast" id="toast"></div>
<script>
// Theme management
function initTheme() {
const savedTheme = localStorage.getItem('theme') || 'dark';
setTheme(savedTheme);
}
function setTheme(theme) {
document.documentElement.setAttribute('data-theme', theme);
localStorage.setItem('theme', theme);
const sunIcon = document.getElementById('theme-icon-sun');
const moonIcon = document.getElementById('theme-icon-moon');
if (theme === 'light') {
sunIcon.style.display = 'none';
moonIcon.style.display = 'block';
} else {
sunIcon.style.display = 'block';
moonIcon.style.display = 'none';
}
}
function toggleTheme() {
const currentTheme = document.documentElement.getAttribute('data-theme') || 'dark';
const newTheme = currentTheme === 'dark' ? 'light' : 'dark';
setTheme(newTheme);
}
let ws = null;
let reconnectTimeout = null;
let currentState = 'idle';
@@ -889,11 +1042,15 @@
// Initialize on page load
window.addEventListener('DOMContentLoaded', () => {
// Initialize theme
initTheme();
const token = localStorage.getItem('media_server_token');
if (token) {
connectWebSocket(token);
loadScripts();
loadScriptsTable();
loadCallbacksTable();
} else {
showAuthForm();
}
@@ -980,6 +1137,7 @@
hideAuthForm();
loadScripts();
loadScriptsTable();
loadCallbacksTable();
};
ws.onmessage = (event) => {
@@ -1515,6 +1673,178 @@
showToast('Error deleting script', 'error');
}
}
// Callback Management Functions
async function loadCallbacksTable() {
const token = localStorage.getItem('media_server_token');
const tbody = document.getElementById('callbacksTableBody');
try {
const response = await fetch('/api/callbacks/list', {
headers: { 'Authorization': `Bearer ${token}` }
});
if (!response.ok) {
throw new Error('Failed to fetch callbacks');
}
const callbacksList = await response.json();
if (callbacksList.length === 0) {
tbody.innerHTML = '<tr><td colspan="4" class="empty-state">No callbacks configured. Click "Add Callback" to create one.</td></tr>';
return;
}
tbody.innerHTML = callbacksList.map(callback => `
<tr>
<td><code>${callback.name}</code></td>
<td style="max-width: 400px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;"
title="${escapeHtml(callback.command)}">${escapeHtml(callback.command)}</td>
<td>${callback.timeout}s</td>
<td>
<button class="action-btn" onclick="showEditCallbackDialog('${callback.name}')">Edit</button>
<button class="action-btn delete" onclick="deleteCallbackConfirm('${callback.name}')">Delete</button>
</td>
</tr>
`).join('');
} catch (error) {
console.error('Error loading callbacks:', error);
tbody.innerHTML = '<tr><td colspan="4" class="empty-state" style="color: var(--error);">Failed to load callbacks</td></tr>';
}
}
function showAddCallbackDialog() {
const dialog = document.getElementById('callbackDialog');
const form = document.getElementById('callbackForm');
const title = document.getElementById('callbackDialogTitle');
// Reset form
form.reset();
document.getElementById('callbackIsEdit').value = 'false';
document.getElementById('callbackName').disabled = false;
title.textContent = 'Add Callback';
dialog.showModal();
}
async function showEditCallbackDialog(callbackName) {
const token = localStorage.getItem('media_server_token');
const dialog = document.getElementById('callbackDialog');
const title = document.getElementById('callbackDialogTitle');
try {
// Fetch current callback details
const response = await fetch('/api/callbacks/list', {
headers: { 'Authorization': `Bearer ${token}` }
});
if (!response.ok) {
throw new Error('Failed to fetch callback details');
}
const callbacksList = await response.json();
const callback = callbacksList.find(c => c.name === callbackName);
if (!callback) {
showToast('Callback not found', 'error');
return;
}
// Populate form
document.getElementById('callbackIsEdit').value = 'true';
document.getElementById('callbackName').value = callbackName;
document.getElementById('callbackName').disabled = true; // Can't change event name
document.getElementById('callbackCommand').value = callback.command;
document.getElementById('callbackTimeout').value = callback.timeout;
document.getElementById('callbackWorkingDir').value = callback.working_dir || '';
title.textContent = 'Edit Callback';
dialog.showModal();
} catch (error) {
console.error('Error loading callback for edit:', error);
showToast('Failed to load callback details', 'error');
}
}
function closeCallbackDialog() {
const dialog = document.getElementById('callbackDialog');
dialog.close();
}
async function saveCallback(event) {
event.preventDefault();
const token = localStorage.getItem('media_server_token');
const isEdit = document.getElementById('callbackIsEdit').value === 'true';
const callbackName = document.getElementById('callbackName').value;
const data = {
command: document.getElementById('callbackCommand').value,
timeout: parseInt(document.getElementById('callbackTimeout').value) || 30,
working_dir: document.getElementById('callbackWorkingDir').value || null,
shell: true
};
const endpoint = isEdit ?
`/api/callbacks/update/${callbackName}` :
`/api/callbacks/create/${callbackName}`;
const method = isEdit ? 'PUT' : 'POST';
try {
const response = await fetch(endpoint, {
method,
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json'
},
body: JSON.stringify(data)
});
const result = await response.json();
if (response.ok && result.success) {
showToast(`Callback ${isEdit ? 'updated' : 'created'} successfully`, 'success');
closeCallbackDialog();
loadCallbacksTable();
} else {
showToast(result.detail || `Failed to ${isEdit ? 'update' : 'create'} callback`, 'error');
}
} catch (error) {
console.error('Error saving callback:', error);
showToast(`Error ${isEdit ? 'updating' : 'creating'} callback`, 'error');
}
}
async function deleteCallbackConfirm(callbackName) {
if (!confirm(`Are you sure you want to delete the callback "${callbackName}"?`)) {
return;
}
const token = localStorage.getItem('media_server_token');
try {
const response = await fetch(`/api/callbacks/delete/${callbackName}`, {
method: 'DELETE',
headers: {
'Authorization': `Bearer ${token}`
}
});
const result = await response.json();
if (response.ok && result.success) {
showToast('Callback deleted successfully', 'success');
loadCallbacksTable();
} else {
showToast(result.detail || 'Failed to delete callback', 'error');
}
} catch (error) {
console.error('Error deleting callback:', error);
showToast('Error deleting callback', 'error');
}
}
</script>
</body>
</html>