Comprehensive WebUI improvements: security, UX, accessibility, performance
Security: - Replace inline onclick handlers with data-attribute event delegation (XSS fix) - Remove auth tokens from URL query params; use Authorization header + blob URLs - Defer artwork blob URL revocation to prevent ERR_FILE_NOT_FOUND Reliability: - Merge duplicate DOMContentLoaded listeners - WebSocket exponential backoff reconnect (3s base, 30s max, 20 attempts) - Connection banner with manual reconnect button after failures UX: - Toast notifications now stack (multiple visible simultaneously) - Custom styled confirm dialog replacing native confirm() - Drag-to-seek on progress bars (mouse + touch) - Keyboard shortcuts: Space, arrows, M for media controls - Browser search matches both filename and title - Path separator auto-detection (Unix/Windows) Accessibility: - WAI-ARIA Tabs pattern (tablist, tab, tabpanel roles) - Arrow/Home/End keyboard navigation in tab bar - ARIA slider roles on progress bars with live value updates - aria-label on volume sliders, aria-live on status dot Performance: - Thumbnail cache (Map, max 200 entries, LRU eviction) - Skip revocation of cached blob URLs during grid re-render - Blob URL cleanup on page unload Visual polish: - Vinyl mode uses CSS custom properties (works in light + dark themes) - Light theme shadow overrides for containers, dialogs, toasts - Optimized system font stack Code quality: - Scoped button reset, merged duplicate CSS selectors - WCAG AA contrast fix for --text-muted - Normalized CSS to consistent 4-space indentation Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -29,7 +29,7 @@
|
||||
<span id="mini-current-time">0:00</span>
|
||||
<span id="mini-total-time">0:00</span>
|
||||
</div>
|
||||
<div class="mini-progress-bar" id="mini-progress-bar">
|
||||
<div class="mini-progress-bar" id="mini-progress-bar" role="slider" aria-label="Playback position" aria-valuemin="0" aria-valuemax="0" aria-valuenow="0">
|
||||
<div class="mini-progress-fill" id="mini-progress-fill"></div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -39,7 +39,7 @@
|
||||
<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="mini-volume-slider" class="mini-volume-slider" min="0" max="100" value="50">
|
||||
<input type="range" id="mini-volume-slider" class="mini-volume-slider" min="0" max="100" value="50" aria-label="Volume">
|
||||
<div class="mini-volume-display" id="mini-volume-display">50%</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -62,7 +62,7 @@
|
||||
<div class="container">
|
||||
<header>
|
||||
<div style="display: flex; align-items: center; gap: 0.5rem;">
|
||||
<span class="status-dot" id="status-dot"></span>
|
||||
<span class="status-dot" id="status-dot" aria-live="polite"></span>
|
||||
<span class="version-label" id="version-label"></span>
|
||||
</div>
|
||||
<div style="display: flex; align-items: center; gap: 0.5rem;">
|
||||
@@ -88,32 +88,38 @@
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- Connection Banner -->
|
||||
<div class="connection-banner hidden" id="connectionBanner">
|
||||
<span id="connectionBannerText"></span>
|
||||
<button class="connection-banner-btn" id="connectionBannerBtn" onclick="manualReconnect()" style="display: none;" data-i18n="connection.reconnect">Reconnect</button>
|
||||
</div>
|
||||
|
||||
<!-- Tab Bar -->
|
||||
<div class="tab-bar" id="tabBar">
|
||||
<div class="tab-bar" id="tabBar" role="tablist">
|
||||
<div class="tab-indicator" id="tabIndicator"></div>
|
||||
<button class="tab-btn active" data-tab="player" onclick="switchTab('player')">
|
||||
<button class="tab-btn active" data-tab="player" onclick="switchTab('player')" role="tab" aria-selected="true" aria-controls="panel-player" tabindex="0">
|
||||
<svg viewBox="0 0 24 24" width="16" height="16"><path fill="currentColor" d="M12 3v10.55c-.59-.34-1.27-.55-2-.55-2.21 0-4 1.79-4 4s1.79 4 4 4 4-1.79 4-4V7h4V3h-6z"/></svg>
|
||||
<span data-i18n="tab.player">Player</span>
|
||||
</button>
|
||||
<button class="tab-btn" data-tab="browser" onclick="switchTab('browser')">
|
||||
<button class="tab-btn" data-tab="browser" onclick="switchTab('browser')" role="tab" aria-selected="false" aria-controls="panel-browser" tabindex="-1">
|
||||
<svg viewBox="0 0 24 24" width="16" height="16"><path fill="currentColor" d="M10 4H4c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V8c0-1.1-.9-2-2-2h-8l-2-2z"/></svg>
|
||||
<span data-i18n="tab.browser">Browser</span>
|
||||
</button>
|
||||
<button class="tab-btn" data-tab="quick-actions" onclick="switchTab('quick-actions')">
|
||||
<button class="tab-btn" data-tab="quick-actions" onclick="switchTab('quick-actions')" role="tab" aria-selected="false" aria-controls="panel-quick-actions" tabindex="-1">
|
||||
<svg viewBox="0 0 24 24" width="16" height="16"><path fill="currentColor" d="M7 2v11h3v9l7-12h-4l4-8z"/></svg>
|
||||
<span data-i18n="tab.quick_actions">Actions</span>
|
||||
</button>
|
||||
<button class="tab-btn" data-tab="scripts" onclick="switchTab('scripts')">
|
||||
<button class="tab-btn" data-tab="scripts" onclick="switchTab('scripts')" role="tab" aria-selected="false" aria-controls="panel-scripts" tabindex="-1">
|
||||
<svg viewBox="0 0 24 24" width="16" height="16"><path fill="currentColor" d="M9.4 16.6L4.8 12l4.6-4.6L8 6l-6 6 6 6 1.4-1.4zm5.2 0l4.6-4.6-4.6-4.6L16 6l6 6-6 6-1.4-1.4z"/></svg>
|
||||
<span data-i18n="tab.scripts">Scripts</span>
|
||||
</button>
|
||||
<button class="tab-btn" data-tab="callbacks" onclick="switchTab('callbacks')">
|
||||
<button class="tab-btn" data-tab="callbacks" onclick="switchTab('callbacks')" role="tab" aria-selected="false" aria-controls="panel-callbacks" tabindex="-1">
|
||||
<svg viewBox="0 0 24 24" width="16" height="16"><path fill="currentColor" d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-2 15l-5-5 1.41-1.41L10 14.17l7.59-7.59L19 8l-9 9z"/></svg>
|
||||
<span data-i18n="tab.callbacks">Callbacks</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="player-container" data-tab-content="player">
|
||||
<div class="player-container" data-tab-content="player" role="tabpanel" id="panel-player">
|
||||
<div class="player-layout">
|
||||
<div class="album-art-container">
|
||||
<img id="album-art-glow" class="album-art-glow" 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%3C/svg%3E" alt="" aria-hidden="true">
|
||||
@@ -138,7 +144,7 @@
|
||||
<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-bar" id="progress-bar" data-duration="0" role="slider" aria-label="Playback position" aria-valuemin="0" aria-valuemax="0" aria-valuenow="0">
|
||||
<div class="progress-fill" id="progress-fill"></div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -167,7 +173,7 @@
|
||||
<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">
|
||||
<input type="range" id="volume-slider" min="0" max="100" value="50" aria-label="Volume">
|
||||
<div class="volume-display" id="volume-display">50%</div>
|
||||
</div>
|
||||
|
||||
@@ -182,7 +188,7 @@
|
||||
</div>
|
||||
|
||||
<!-- Media Browser Section -->
|
||||
<div class="browser-container" data-tab-content="browser" >
|
||||
<div class="browser-container" data-tab-content="browser" role="tabpanel" id="panel-browser">
|
||||
<!-- Breadcrumb Navigation -->
|
||||
<div class="breadcrumb" id="breadcrumb"></div>
|
||||
|
||||
@@ -249,7 +255,7 @@
|
||||
</div>
|
||||
|
||||
<!-- Scripts Section (Quick Actions) -->
|
||||
<div class="scripts-container" id="scripts-container" data-tab-content="quick-actions" >
|
||||
<div class="scripts-container" data-tab-content="quick-actions" role="tabpanel" id="panel-quick-actions">
|
||||
<div class="scripts-grid" id="scripts-grid">
|
||||
<div class="scripts-empty empty-state-illustration">
|
||||
<svg viewBox="0 0 64 64"><path d="M20 8l-8 48"/><path d="M44 8l8 48"/><path d="M10 24h44"/><path d="M8 40h44"/></svg>
|
||||
@@ -262,7 +268,7 @@
|
||||
</div>
|
||||
|
||||
<!-- Script Management Section -->
|
||||
<div class="script-management" data-tab-content="scripts" >
|
||||
<div class="script-management" data-tab-content="scripts" role="tabpanel" id="panel-scripts">
|
||||
<table class="scripts-table">
|
||||
<thead>
|
||||
<tr>
|
||||
@@ -290,7 +296,7 @@
|
||||
</div>
|
||||
|
||||
<!-- Callback Management Section -->
|
||||
<div class="script-management" id="callbacksSection" data-tab-content="callbacks" >
|
||||
<div class="script-management" data-tab-content="callbacks" role="tabpanel" id="panel-callbacks">
|
||||
<p style="color: var(--text-secondary); font-size: 0.875rem; margin-bottom: 1rem;" data-i18n="callbacks.description">
|
||||
Callbacks are scripts triggered automatically by media control events (play, pause, stop, etc.)
|
||||
</p>
|
||||
@@ -480,8 +486,17 @@
|
||||
</form>
|
||||
</dialog>
|
||||
|
||||
<!-- Toast Notification -->
|
||||
<div class="toast" id="toast"></div>
|
||||
<!-- Confirm Dialog -->
|
||||
<dialog id="confirmDialog" class="confirm-dialog">
|
||||
<p id="confirmDialogMessage"></p>
|
||||
<div class="confirm-dialog-actions">
|
||||
<button type="button" class="btn-cancel" id="confirmDialogCancel" data-i18n="dialog.cancel">Cancel</button>
|
||||
<button type="button" class="btn-danger" id="confirmDialogConfirm" data-i18n="dialog.confirm">Confirm</button>
|
||||
</div>
|
||||
</dialog>
|
||||
|
||||
<!-- Toast Notifications -->
|
||||
<div class="toast-container" id="toast-container"></div>
|
||||
|
||||
<!-- Footer -->
|
||||
<footer>
|
||||
|
||||
@@ -55,7 +55,9 @@
|
||||
const POSITION_INTERPOLATION_MS = 100;
|
||||
const SEARCH_DEBOUNCE_MS = 200;
|
||||
const TOAST_DURATION_MS = 3000;
|
||||
const WS_RECONNECT_MS = 3000;
|
||||
const WS_BACKOFF_BASE_MS = 3000;
|
||||
const WS_BACKOFF_MAX_MS = 30000;
|
||||
const WS_MAX_RECONNECT_ATTEMPTS = 20;
|
||||
const WS_PING_INTERVAL_MS = 30000;
|
||||
const VOLUME_RELEASE_DELAY_MS = 500;
|
||||
|
||||
@@ -105,11 +107,17 @@
|
||||
target.classList.add('active');
|
||||
}
|
||||
|
||||
// Update tab buttons
|
||||
document.querySelectorAll('.tab-btn').forEach(btn => btn.classList.remove('active'));
|
||||
// Update tab buttons and ARIA state
|
||||
document.querySelectorAll('.tab-btn').forEach(btn => {
|
||||
btn.classList.remove('active');
|
||||
btn.setAttribute('aria-selected', 'false');
|
||||
btn.setAttribute('tabindex', '-1');
|
||||
});
|
||||
const activeBtn = document.querySelector(`.tab-btn[data-tab="${tabName}"]`);
|
||||
if (activeBtn) {
|
||||
activeBtn.classList.add('active');
|
||||
activeBtn.setAttribute('aria-selected', 'true');
|
||||
activeBtn.setAttribute('tabindex', '0');
|
||||
updateTabIndicator(activeBtn);
|
||||
}
|
||||
|
||||
@@ -413,6 +421,7 @@
|
||||
let ws = null;
|
||||
let reconnectTimeout = null;
|
||||
let pingInterval = null;
|
||||
let wsReconnectAttempts = 0;
|
||||
let currentState = 'idle';
|
||||
let currentDuration = 0;
|
||||
let currentPosition = 0;
|
||||
@@ -421,6 +430,7 @@
|
||||
let scripts = [];
|
||||
let lastStatus = null; // Store last status for locale switching
|
||||
let lastArtworkKey = null; // Track artwork identity to skip redundant loads
|
||||
let currentArtworkBlobUrl = null; // Track current blob URL for safe revocation
|
||||
|
||||
// Dialog dirty state tracking
|
||||
let scriptFormDirty = false;
|
||||
@@ -431,6 +441,61 @@
|
||||
let lastPositionValue = 0;
|
||||
let interpolationInterval = null;
|
||||
|
||||
function setupProgressDrag(bar, fill) {
|
||||
let dragging = false;
|
||||
|
||||
function getPercent(clientX) {
|
||||
const rect = bar.getBoundingClientRect();
|
||||
return Math.max(0, Math.min(1, (clientX - rect.left) / rect.width));
|
||||
}
|
||||
|
||||
function updatePreview(percent) {
|
||||
fill.style.width = (percent * 100) + '%';
|
||||
}
|
||||
|
||||
function handleStart(clientX) {
|
||||
if (currentDuration <= 0) return;
|
||||
dragging = true;
|
||||
bar.classList.add('dragging');
|
||||
updatePreview(getPercent(clientX));
|
||||
}
|
||||
|
||||
function handleMove(clientX) {
|
||||
if (!dragging) return;
|
||||
updatePreview(getPercent(clientX));
|
||||
}
|
||||
|
||||
function handleEnd(clientX) {
|
||||
if (!dragging) return;
|
||||
dragging = false;
|
||||
bar.classList.remove('dragging');
|
||||
const percent = getPercent(clientX);
|
||||
seek(percent * currentDuration);
|
||||
}
|
||||
|
||||
// Mouse events
|
||||
bar.addEventListener('mousedown', (e) => { e.preventDefault(); handleStart(e.clientX); });
|
||||
document.addEventListener('mousemove', (e) => { handleMove(e.clientX); });
|
||||
document.addEventListener('mouseup', (e) => { handleEnd(e.clientX); });
|
||||
|
||||
// Touch events
|
||||
bar.addEventListener('touchstart', (e) => { handleStart(e.touches[0].clientX); }, { passive: true });
|
||||
document.addEventListener('touchmove', (e) => { if (dragging) handleMove(e.touches[0].clientX); });
|
||||
document.addEventListener('touchend', (e) => {
|
||||
if (dragging) {
|
||||
const touch = e.changedTouches[0];
|
||||
handleEnd(touch.clientX);
|
||||
}
|
||||
});
|
||||
|
||||
// Simple click (mousedown + mouseup without move)
|
||||
bar.addEventListener('click', (e) => {
|
||||
if (currentDuration > 0) {
|
||||
seek(getPercent(e.clientX) * currentDuration);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Initialize on page load
|
||||
window.addEventListener('DOMContentLoaded', async () => {
|
||||
// Cache DOM references
|
||||
@@ -516,26 +581,15 @@
|
||||
}, { threshold: 0.1 });
|
||||
observer.observe(playerContainer);
|
||||
|
||||
// Mini player progress bar click to seek
|
||||
const miniProgressBar = document.getElementById('mini-progress-bar');
|
||||
miniProgressBar.addEventListener('click', (e) => {
|
||||
const rect = miniProgressBar.getBoundingClientRect();
|
||||
const percent = (e.clientX - rect.left) / rect.width;
|
||||
const position = percent * currentDuration;
|
||||
seek(position);
|
||||
});
|
||||
|
||||
// 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);
|
||||
}
|
||||
});
|
||||
// Drag-to-seek for progress bars
|
||||
setupProgressDrag(
|
||||
document.getElementById('mini-progress-bar'),
|
||||
document.getElementById('mini-progress-fill')
|
||||
);
|
||||
setupProgressDrag(
|
||||
document.getElementById('progress-bar'),
|
||||
document.getElementById('progress-fill')
|
||||
);
|
||||
|
||||
// Enter key in token input
|
||||
document.getElementById('token-input').addEventListener('keypress', (e) => {
|
||||
@@ -579,6 +633,99 @@
|
||||
closeCallbackDialog();
|
||||
}
|
||||
});
|
||||
|
||||
// Delegated click handlers for script table actions (XSS-safe)
|
||||
document.getElementById('scriptsTableBody').addEventListener('click', (e) => {
|
||||
const btn = e.target.closest('[data-action]');
|
||||
if (!btn) return;
|
||||
const action = btn.dataset.action;
|
||||
const name = btn.dataset.scriptName;
|
||||
if (action === 'execute') executeScriptDebug(name);
|
||||
else if (action === 'edit') showEditScriptDialog(name);
|
||||
else if (action === 'delete') deleteScriptConfirm(name);
|
||||
});
|
||||
|
||||
// Delegated click handlers for callback table actions (XSS-safe)
|
||||
document.getElementById('callbacksTableBody').addEventListener('click', (e) => {
|
||||
const btn = e.target.closest('[data-action]');
|
||||
if (!btn) return;
|
||||
const action = btn.dataset.action;
|
||||
const name = btn.dataset.callbackName;
|
||||
if (action === 'execute') executeCallbackDebug(name);
|
||||
else if (action === 'edit') showEditCallbackDialog(name);
|
||||
else if (action === 'delete') deleteCallbackConfirm(name);
|
||||
});
|
||||
|
||||
// Initialize browser toolbar and load folders
|
||||
initBrowserToolbar();
|
||||
if (token) {
|
||||
loadMediaFolders();
|
||||
}
|
||||
|
||||
// Cleanup blob URLs on page unload
|
||||
window.addEventListener('beforeunload', () => {
|
||||
thumbnailCache.forEach(url => URL.revokeObjectURL(url));
|
||||
thumbnailCache.clear();
|
||||
});
|
||||
|
||||
// Tab bar keyboard navigation (WAI-ARIA Tabs pattern)
|
||||
document.getElementById('tabBar').addEventListener('keydown', (e) => {
|
||||
const tabs = Array.from(document.querySelectorAll('.tab-btn'));
|
||||
const currentIdx = tabs.indexOf(document.activeElement);
|
||||
if (currentIdx === -1) return;
|
||||
|
||||
let newIdx;
|
||||
if (e.key === 'ArrowRight') {
|
||||
newIdx = (currentIdx + 1) % tabs.length;
|
||||
} else if (e.key === 'ArrowLeft') {
|
||||
newIdx = (currentIdx - 1 + tabs.length) % tabs.length;
|
||||
} else if (e.key === 'Home') {
|
||||
newIdx = 0;
|
||||
} else if (e.key === 'End') {
|
||||
newIdx = tabs.length - 1;
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
|
||||
e.preventDefault();
|
||||
tabs[newIdx].focus();
|
||||
switchTab(tabs[newIdx].dataset.tab);
|
||||
});
|
||||
|
||||
// Global keyboard shortcuts
|
||||
document.addEventListener('keydown', (e) => {
|
||||
// Skip when typing in inputs, textareas, selects, or when a dialog is open
|
||||
const tag = e.target.tagName;
|
||||
if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') return;
|
||||
if (document.querySelector('dialog[open]')) return;
|
||||
|
||||
switch (e.key) {
|
||||
case ' ':
|
||||
e.preventDefault();
|
||||
togglePlayPause();
|
||||
break;
|
||||
case 'ArrowLeft':
|
||||
e.preventDefault();
|
||||
if (currentDuration > 0) seek(Math.max(0, currentPosition - 5));
|
||||
break;
|
||||
case 'ArrowRight':
|
||||
e.preventDefault();
|
||||
if (currentDuration > 0) seek(Math.min(currentDuration, currentPosition + 5));
|
||||
break;
|
||||
case 'ArrowUp':
|
||||
e.preventDefault();
|
||||
setVolume(Math.min(100, parseInt(dom.volumeSlider.value) + 5));
|
||||
break;
|
||||
case 'ArrowDown':
|
||||
e.preventDefault();
|
||||
setVolume(Math.max(0, parseInt(dom.volumeSlider.value) - 5));
|
||||
break;
|
||||
case 'm':
|
||||
case 'M':
|
||||
toggleMute();
|
||||
break;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
function showAuthForm(errorMessage = '') {
|
||||
@@ -631,7 +778,9 @@
|
||||
|
||||
ws.onopen = () => {
|
||||
console.log('WebSocket connected');
|
||||
wsReconnectAttempts = 0;
|
||||
updateConnectionStatus(true);
|
||||
hideConnectionBanner();
|
||||
hideAuthForm();
|
||||
loadScripts();
|
||||
loadScriptsTable();
|
||||
@@ -667,14 +816,30 @@
|
||||
localStorage.removeItem('media_server_token');
|
||||
showAuthForm(t('auth.invalid'));
|
||||
} 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);
|
||||
// Abnormal closure - attempt reconnect with exponential backoff
|
||||
wsReconnectAttempts++;
|
||||
|
||||
if (wsReconnectAttempts <= WS_MAX_RECONNECT_ATTEMPTS) {
|
||||
const delay = Math.min(
|
||||
WS_BACKOFF_BASE_MS * Math.pow(1.5, wsReconnectAttempts - 1),
|
||||
WS_BACKOFF_MAX_MS
|
||||
);
|
||||
console.log(`Reconnecting in ${Math.round(delay / 1000)}s (attempt ${wsReconnectAttempts}/${WS_MAX_RECONNECT_ATTEMPTS})...`);
|
||||
|
||||
if (wsReconnectAttempts >= 3) {
|
||||
showConnectionBanner(t('connection.reconnecting').replace('{attempt}', wsReconnectAttempts), false);
|
||||
}
|
||||
}, WS_RECONNECT_MS);
|
||||
|
||||
reconnectTimeout = setTimeout(() => {
|
||||
const savedToken = localStorage.getItem('media_server_token');
|
||||
if (savedToken) {
|
||||
connectWebSocket(savedToken);
|
||||
}
|
||||
}, delay);
|
||||
} else {
|
||||
// Exhausted retries - show manual reconnect
|
||||
showConnectionBanner(t('connection.lost'), true);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -694,6 +859,29 @@
|
||||
}
|
||||
}
|
||||
|
||||
function showConnectionBanner(message, showButton) {
|
||||
const banner = document.getElementById('connectionBanner');
|
||||
const text = document.getElementById('connectionBannerText');
|
||||
const btn = document.getElementById('connectionBannerBtn');
|
||||
text.textContent = message;
|
||||
btn.style.display = showButton ? '' : 'none';
|
||||
banner.classList.remove('hidden');
|
||||
}
|
||||
|
||||
function hideConnectionBanner() {
|
||||
const banner = document.getElementById('connectionBanner');
|
||||
banner.classList.add('hidden');
|
||||
}
|
||||
|
||||
function manualReconnect() {
|
||||
const savedToken = localStorage.getItem('media_server_token');
|
||||
if (savedToken) {
|
||||
wsReconnectAttempts = 0;
|
||||
hideConnectionBanner();
|
||||
connectWebSocket(savedToken);
|
||||
}
|
||||
}
|
||||
|
||||
function updateUI(status) {
|
||||
// Store status for locale switching
|
||||
lastStatus = status;
|
||||
@@ -719,15 +907,35 @@
|
||||
|
||||
if (artworkKey !== lastArtworkKey) {
|
||||
lastArtworkKey = artworkKey;
|
||||
const artworkUrl = artworkSource
|
||||
? `/api/media/artwork?token=${encodeURIComponent(localStorage.getItem('media_server_token'))}&_=${Date.now()}`
|
||||
: "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";
|
||||
dom.albumArt.src = artworkUrl;
|
||||
dom.miniAlbumArt.src = artworkUrl;
|
||||
if (dom.albumArtGlow) {
|
||||
dom.albumArtGlow.src = artworkSource
|
||||
? artworkUrl
|
||||
: "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%3C/svg%3E";
|
||||
const placeholderArt = "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";
|
||||
const placeholderGlow = "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%3C/svg%3E";
|
||||
if (artworkSource) {
|
||||
// Fetch artwork with Authorization header (avoid token in URL)
|
||||
const token = localStorage.getItem('media_server_token');
|
||||
fetch(`/api/media/artwork?_=${Date.now()}`, {
|
||||
headers: { 'Authorization': `Bearer ${token}` }
|
||||
})
|
||||
.then(r => r.ok ? r.blob() : null)
|
||||
.then(blob => {
|
||||
if (!blob) return;
|
||||
const oldBlobUrl = currentArtworkBlobUrl;
|
||||
const url = URL.createObjectURL(blob);
|
||||
currentArtworkBlobUrl = url;
|
||||
dom.albumArt.src = url;
|
||||
dom.miniAlbumArt.src = url;
|
||||
if (dom.albumArtGlow) dom.albumArtGlow.src = url;
|
||||
// Revoke old blob URL after a delay to let pending loads finish
|
||||
if (oldBlobUrl) setTimeout(() => URL.revokeObjectURL(oldBlobUrl), 1000);
|
||||
})
|
||||
.catch(err => console.error('Artwork fetch failed:', err));
|
||||
} else {
|
||||
if (currentArtworkBlobUrl) {
|
||||
URL.revokeObjectURL(currentArtworkBlobUrl);
|
||||
currentArtworkBlobUrl = null;
|
||||
}
|
||||
dom.albumArt.src = placeholderArt;
|
||||
dom.miniAlbumArt.src = placeholderArt;
|
||||
if (dom.albumArtGlow) dom.albumArtGlow.src = placeholderGlow;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -807,16 +1015,23 @@
|
||||
const widthStr = `${percent}%`;
|
||||
const currentStr = formatTime(position);
|
||||
const totalStr = formatTime(duration);
|
||||
const posRound = Math.round(position);
|
||||
const durRound = Math.round(duration);
|
||||
|
||||
dom.progressFill.style.width = widthStr;
|
||||
dom.currentTime.textContent = currentStr;
|
||||
dom.totalTime.textContent = totalStr;
|
||||
dom.progressBar.dataset.duration = duration;
|
||||
dom.progressBar.setAttribute('aria-valuenow', posRound);
|
||||
dom.progressBar.setAttribute('aria-valuemax', durRound);
|
||||
|
||||
dom.miniProgressFill.style.width = widthStr;
|
||||
dom.miniCurrentTime.textContent = currentStr;
|
||||
dom.miniTotalTime.textContent = totalStr;
|
||||
if (dom.miniPlayer) dom.miniPlayer.style.setProperty('--mini-progress', widthStr);
|
||||
const miniBar = document.getElementById('mini-progress-bar');
|
||||
miniBar.setAttribute('aria-valuenow', posRound);
|
||||
miniBar.setAttribute('aria-valuemax', durRound);
|
||||
}
|
||||
|
||||
function startPositionInterpolation() {
|
||||
@@ -941,7 +1156,7 @@
|
||||
}
|
||||
|
||||
function displayScripts() {
|
||||
const container = document.getElementById('scripts-container');
|
||||
const container = document.getElementById('panel-quick-actions');
|
||||
const grid = document.getElementById('scripts-grid');
|
||||
|
||||
grid.innerHTML = '';
|
||||
@@ -1012,15 +1227,53 @@
|
||||
}
|
||||
|
||||
function showToast(message, type = 'success') {
|
||||
const toast = document.getElementById('toast');
|
||||
const container = document.getElementById('toast-container');
|
||||
const toast = document.createElement('div');
|
||||
toast.className = `toast ${type}`;
|
||||
toast.textContent = message;
|
||||
toast.className = `toast ${type} show`;
|
||||
container.appendChild(toast);
|
||||
|
||||
// Trigger reflow then show
|
||||
requestAnimationFrame(() => {
|
||||
toast.classList.add('show');
|
||||
});
|
||||
|
||||
setTimeout(() => {
|
||||
toast.classList.remove('show');
|
||||
toast.addEventListener('transitionend', () => toast.remove(), { once: true });
|
||||
// Fallback removal if transitionend doesn't fire
|
||||
setTimeout(() => { if (toast.parentNode) toast.remove(); }, 500);
|
||||
}, TOAST_DURATION_MS);
|
||||
}
|
||||
|
||||
function showConfirm(message) {
|
||||
return new Promise((resolve) => {
|
||||
const dialog = document.getElementById('confirmDialog');
|
||||
const msg = document.getElementById('confirmDialogMessage');
|
||||
const btnCancel = document.getElementById('confirmDialogCancel');
|
||||
const btnConfirm = document.getElementById('confirmDialogConfirm');
|
||||
|
||||
msg.textContent = message;
|
||||
|
||||
function cleanup() {
|
||||
btnCancel.removeEventListener('click', onCancel);
|
||||
btnConfirm.removeEventListener('click', onConfirm);
|
||||
dialog.removeEventListener('close', onClose);
|
||||
dialog.close();
|
||||
}
|
||||
|
||||
function onCancel() { cleanup(); resolve(false); }
|
||||
function onConfirm() { cleanup(); resolve(true); }
|
||||
function onClose() { cleanup(); resolve(false); }
|
||||
|
||||
btnCancel.addEventListener('click', onCancel);
|
||||
btnConfirm.addEventListener('click', onConfirm);
|
||||
dialog.addEventListener('close', onClose);
|
||||
|
||||
dialog.showModal();
|
||||
});
|
||||
}
|
||||
|
||||
// Script Management Functions
|
||||
|
||||
let _loadScriptsPromise = null;
|
||||
@@ -1053,20 +1306,20 @@
|
||||
|
||||
tbody.innerHTML = scriptsList.map(script => `
|
||||
<tr>
|
||||
<td><code>${script.name}</code></td>
|
||||
<td>${script.label || script.name}</td>
|
||||
<td><code>${escapeHtml(script.name)}</code></td>
|
||||
<td>${escapeHtml(script.label || script.name)}</td>
|
||||
<td style="max-width: 200px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;"
|
||||
title="${escapeHtml(script.command || 'N/A')}">${escapeHtml(script.command || 'N/A')}</td>
|
||||
<td>${script.timeout}s</td>
|
||||
<td>
|
||||
<div class="action-buttons">
|
||||
<button class="action-btn execute" onclick="executeScriptDebug('${script.name}')" title="Execute script">
|
||||
<button class="action-btn execute" data-action="execute" data-script-name="${escapeHtml(script.name)}" title="Execute script">
|
||||
<svg viewBox="0 0 24 24"><path d="M8 5v14l11-7z"/></svg>
|
||||
</button>
|
||||
<button class="action-btn" onclick="showEditScriptDialog('${script.name}')" title="Edit script">
|
||||
<button class="action-btn" data-action="edit" data-script-name="${escapeHtml(script.name)}" title="Edit script">
|
||||
<svg viewBox="0 0 24 24"><path d="M3 17.25V21h3.75L17.81 9.94l-3.75-3.75L3 17.25zM20.71 7.04c.39-.39.39-1.02 0-1.41l-2.34-2.34c-.39-.39-1.02-.39-1.41 0l-1.83 1.83 3.75 3.75 1.83-1.83z"/></svg>
|
||||
</button>
|
||||
<button class="action-btn delete" onclick="deleteScriptConfirm('${script.name}')" title="Delete script">
|
||||
<button class="action-btn delete" data-action="delete" data-script-name="${escapeHtml(script.name)}" title="Delete script">
|
||||
<svg viewBox="0 0 24 24"><path d="M6 19c0 1.1.9 2 2 2h8c1.1 0 2-.9 2-2V7H6v12zM19 4h-3.5l-1-1h-5l-1 1H5v2h14V4z"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
@@ -1151,10 +1404,10 @@
|
||||
}
|
||||
}
|
||||
|
||||
function closeScriptDialog() {
|
||||
async function closeScriptDialog() {
|
||||
// Check if form has unsaved changes
|
||||
if (scriptFormDirty) {
|
||||
if (!confirm(t('scripts.confirm.unsaved'))) {
|
||||
if (!await showConfirm(t('scripts.confirm.unsaved'))) {
|
||||
return; // User cancelled, don't close
|
||||
}
|
||||
}
|
||||
@@ -1220,7 +1473,7 @@
|
||||
}
|
||||
|
||||
async function deleteScriptConfirm(scriptName) {
|
||||
if (!confirm(`Are you sure you want to delete the script "${scriptName}"?`)) {
|
||||
if (!await showConfirm(t('scripts.confirm.delete').replace('{name}', scriptName))) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1280,19 +1533,19 @@
|
||||
|
||||
tbody.innerHTML = callbacksList.map(callback => `
|
||||
<tr>
|
||||
<td><code>${callback.name}</code></td>
|
||||
<td><code>${escapeHtml(callback.name)}</code></td>
|
||||
<td style="max-width: 200px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;"
|
||||
title="${escapeHtml(callback.command)}">${escapeHtml(callback.command)}</td>
|
||||
<td>${callback.timeout}s</td>
|
||||
<td>
|
||||
<div class="action-buttons">
|
||||
<button class="action-btn execute" onclick="executeCallbackDebug('${callback.name}')" title="Execute callback">
|
||||
<button class="action-btn execute" data-action="execute" data-callback-name="${escapeHtml(callback.name)}" title="Execute callback">
|
||||
<svg viewBox="0 0 24 24"><path d="M8 5v14l11-7z"/></svg>
|
||||
</button>
|
||||
<button class="action-btn" onclick="showEditCallbackDialog('${callback.name}')" title="Edit callback">
|
||||
<button class="action-btn" data-action="edit" data-callback-name="${escapeHtml(callback.name)}" title="Edit callback">
|
||||
<svg viewBox="0 0 24 24"><path d="M3 17.25V21h3.75L17.81 9.94l-3.75-3.75L3 17.25zM20.71 7.04c.39-.39.39-1.02 0-1.41l-2.34-2.34c-.39-.39-1.02-.39-1.41 0l-1.83 1.83 3.75 3.75 1.83-1.83z"/></svg>
|
||||
</button>
|
||||
<button class="action-btn delete" onclick="deleteCallbackConfirm('${callback.name}')" title="Delete callback">
|
||||
<button class="action-btn delete" data-action="delete" data-callback-name="${escapeHtml(callback.name)}" title="Delete callback">
|
||||
<svg viewBox="0 0 24 24"><path d="M6 19c0 1.1.9 2 2 2h8c1.1 0 2-.9 2-2V7H6v12zM19 4h-3.5l-1-1h-5l-1 1H5v2h14V4z"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
@@ -1367,10 +1620,10 @@
|
||||
}
|
||||
}
|
||||
|
||||
function closeCallbackDialog() {
|
||||
async function closeCallbackDialog() {
|
||||
// Check if form has unsaved changes
|
||||
if (callbackFormDirty) {
|
||||
if (!confirm(t('callbacks.confirm.unsaved'))) {
|
||||
if (!await showConfirm(t('callbacks.confirm.unsaved'))) {
|
||||
return; // User cancelled, don't close
|
||||
}
|
||||
}
|
||||
@@ -1433,7 +1686,7 @@
|
||||
}
|
||||
|
||||
async function deleteCallbackConfirm(callbackName) {
|
||||
if (!confirm(`Are you sure you want to delete the callback "${callbackName}"?`)) {
|
||||
if (!await showConfirm(t('callbacks.confirm.delete').replace('{name}', callbackName))) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1650,6 +1903,8 @@ let viewMode = localStorage.getItem('mediaBrowser.viewMode') || 'grid';
|
||||
let cachedItems = null;
|
||||
let browserSearchTerm = '';
|
||||
let browserSearchTimer = null;
|
||||
const thumbnailCache = new Map();
|
||||
const THUMBNAIL_CACHE_MAX = 200;
|
||||
|
||||
// Load media folders on page load
|
||||
async function loadMediaFolders() {
|
||||
@@ -1852,8 +2107,12 @@ function renderBreadcrumbs(currentPath, parentPath) {
|
||||
}
|
||||
|
||||
function revokeBlobUrls(container) {
|
||||
const cachedUrls = new Set(thumbnailCache.values());
|
||||
container.querySelectorAll('img[src^="blob:"]').forEach(img => {
|
||||
URL.revokeObjectURL(img.src);
|
||||
// Don't revoke URLs managed by the thumbnail cache
|
||||
if (!cachedUrls.has(img.src)) {
|
||||
URL.revokeObjectURL(img.src);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -2117,12 +2376,20 @@ async function loadThumbnail(imgElement, fileName) {
|
||||
return;
|
||||
}
|
||||
|
||||
const fullPath = currentPath === '/'
|
||||
? '/' + fileName
|
||||
: currentPath + '/' + fileName;
|
||||
const encodedPath = encodeURIComponent(
|
||||
mediaFolders[currentFolderId].path + fullPath.replace(/\//g, '\\')
|
||||
);
|
||||
const absolutePath = buildAbsolutePath(currentFolderId, currentPath, fileName);
|
||||
|
||||
// Check cache first
|
||||
if (thumbnailCache.has(absolutePath)) {
|
||||
const cachedUrl = thumbnailCache.get(absolutePath);
|
||||
imgElement.onload = () => {
|
||||
imgElement.classList.remove('loading');
|
||||
imgElement.classList.add('loaded');
|
||||
};
|
||||
imgElement.src = cachedUrl;
|
||||
return;
|
||||
}
|
||||
|
||||
const encodedPath = encodeURIComponent(absolutePath);
|
||||
|
||||
const response = await fetch(
|
||||
`/api/browser/thumbnail?path=${encodedPath}&size=medium`,
|
||||
@@ -2132,6 +2399,14 @@ async function loadThumbnail(imgElement, fileName) {
|
||||
if (response.status === 200) {
|
||||
const blob = await response.blob();
|
||||
const url = URL.createObjectURL(blob);
|
||||
thumbnailCache.set(absolutePath, url);
|
||||
|
||||
// Evict oldest entries when cache exceeds limit
|
||||
if (thumbnailCache.size > THUMBNAIL_CACHE_MAX) {
|
||||
const oldest = thumbnailCache.keys().next().value;
|
||||
URL.revokeObjectURL(thumbnailCache.get(oldest));
|
||||
thumbnailCache.delete(oldest);
|
||||
}
|
||||
|
||||
// Wait for image to actually load before showing it
|
||||
imgElement.onload = () => {
|
||||
@@ -2139,9 +2414,14 @@ async function loadThumbnail(imgElement, fileName) {
|
||||
imgElement.classList.add('loaded');
|
||||
};
|
||||
|
||||
// Revoke previous blob URL if any
|
||||
// Revoke previous blob URL if not managed by cache
|
||||
// (Cache is keyed by path, so check values)
|
||||
if (imgElement.src && imgElement.src.startsWith('blob:')) {
|
||||
URL.revokeObjectURL(imgElement.src);
|
||||
let isCached = false;
|
||||
for (const url of thumbnailCache.values()) {
|
||||
if (url === imgElement.src) { isCached = true; break; }
|
||||
}
|
||||
if (!isCached) URL.revokeObjectURL(imgElement.src);
|
||||
}
|
||||
imgElement.src = url;
|
||||
} else {
|
||||
@@ -2164,6 +2444,16 @@ async function loadThumbnail(imgElement, fileName) {
|
||||
}
|
||||
}
|
||||
|
||||
function buildAbsolutePath(folderId, relativePath, fileName) {
|
||||
const folderPath = mediaFolders[folderId].path;
|
||||
// Detect separator from folder path
|
||||
const sep = folderPath.includes('/') ? '/' : '\\';
|
||||
const fullRelative = relativePath === '/'
|
||||
? sep + fileName
|
||||
: relativePath.replace(/[/\\]/g, sep) + sep + fileName;
|
||||
return folderPath + fullRelative;
|
||||
}
|
||||
|
||||
let playInProgress = false;
|
||||
|
||||
async function playMediaFile(fileName) {
|
||||
@@ -2176,10 +2466,7 @@ async function playMediaFile(fileName) {
|
||||
return;
|
||||
}
|
||||
|
||||
const fullPath = currentPath === '/'
|
||||
? '/' + fileName
|
||||
: currentPath + '/' + fileName;
|
||||
const absolutePath = mediaFolders[currentFolderId].path + fullPath.replace(/\//g, '\\');
|
||||
const absolutePath = buildAbsolutePath(currentFolderId, currentPath, fileName);
|
||||
|
||||
const response = await fetch('/api/browser/play', {
|
||||
method: 'POST',
|
||||
@@ -2235,7 +2522,7 @@ async function playAllFolder() {
|
||||
}
|
||||
}
|
||||
|
||||
function downloadFile(fileName, event) {
|
||||
async function downloadFile(fileName, event) {
|
||||
if (event) event.stopPropagation();
|
||||
const token = localStorage.getItem('media_server_token');
|
||||
if (!token) return;
|
||||
@@ -2244,14 +2531,27 @@ function downloadFile(fileName, event) {
|
||||
? '/' + fileName
|
||||
: currentPath + '/' + fileName;
|
||||
const encodedPath = encodeURIComponent(fullPath);
|
||||
const url = `/api/browser/download?folder_id=${currentFolderId}&path=${encodedPath}&token=${token}`;
|
||||
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = fileName;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
try {
|
||||
const response = await fetch(
|
||||
`/api/browser/download?folder_id=${currentFolderId}&path=${encodedPath}`,
|
||||
{ headers: { 'Authorization': `Bearer ${token}` } }
|
||||
);
|
||||
if (!response.ok) throw new Error('Download failed');
|
||||
|
||||
const blob = await response.blob();
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = fileName;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
} catch (error) {
|
||||
console.error('Download error:', error);
|
||||
showToast(t('browser.download_error'), 'error');
|
||||
}
|
||||
}
|
||||
|
||||
function createDownloadBtn(fileName, cssClass) {
|
||||
@@ -2341,7 +2641,8 @@ function applyBrowserSearch() {
|
||||
}
|
||||
|
||||
const filtered = cachedItems.filter(item =>
|
||||
item.name.toLowerCase().includes(browserSearchTerm)
|
||||
item.name.toLowerCase().includes(browserSearchTerm) ||
|
||||
(item.title && item.title.toLowerCase().includes(browserSearchTerm))
|
||||
);
|
||||
renderBrowserItems(filtered);
|
||||
}
|
||||
@@ -2468,13 +2769,3 @@ async function saveFolder(event) {
|
||||
closeFolderDialog();
|
||||
}
|
||||
|
||||
// Initialize browser on page load
|
||||
window.addEventListener('DOMContentLoaded', () => {
|
||||
initBrowserToolbar();
|
||||
|
||||
// Load media folders after authentication
|
||||
const token = localStorage.getItem('media_server_token');
|
||||
if (token) {
|
||||
loadMediaFolders();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -154,6 +154,12 @@
|
||||
"browser.folder_dialog.enabled": "Enabled",
|
||||
"browser.folder_dialog.cancel": "Cancel",
|
||||
"browser.folder_dialog.save": "Save",
|
||||
"browser.download_error": "Failed to download file",
|
||||
"connection.reconnecting": "Connection lost. Reconnecting (attempt {attempt})...",
|
||||
"connection.lost": "Connection lost. Server may be unavailable.",
|
||||
"connection.reconnect": "Reconnect",
|
||||
"dialog.cancel": "Cancel",
|
||||
"dialog.confirm": "Confirm",
|
||||
"footer.created_by": "Created by",
|
||||
"footer.source_code": "Source Code"
|
||||
}
|
||||
|
||||
@@ -154,6 +154,12 @@
|
||||
"browser.folder_dialog.enabled": "Включено",
|
||||
"browser.folder_dialog.cancel": "Отмена",
|
||||
"browser.folder_dialog.save": "Сохранить",
|
||||
"browser.download_error": "Не удалось скачать файл",
|
||||
"connection.reconnecting": "Соединение потеряно. Переподключение (попытка {attempt})...",
|
||||
"connection.lost": "Соединение потеряно. Сервер может быть недоступен.",
|
||||
"connection.reconnect": "Переподключиться",
|
||||
"dialog.cancel": "Отмена",
|
||||
"dialog.confirm": "Подтвердить",
|
||||
"footer.created_by": "Создано",
|
||||
"footer.source_code": "Исходный код"
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user