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:
2026-02-25 21:36:12 +03:00
parent ef1935c5cf
commit 03a1b30cd8
5 changed files with 2203 additions and 1731 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -29,7 +29,7 @@
<span id="mini-current-time">0:00</span> <span id="mini-current-time">0:00</span>
<span id="mini-total-time">0:00</span> <span id="mini-total-time">0:00</span>
</div> </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 class="mini-progress-fill" id="mini-progress-fill"></div>
</div> </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"/> <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> </svg>
</button> </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 class="mini-volume-display" id="mini-volume-display">50%</div>
</div> </div>
</div> </div>
@@ -62,7 +62,7 @@
<div class="container"> <div class="container">
<header> <header>
<div style="display: flex; align-items: center; gap: 0.5rem;"> <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> <span class="version-label" id="version-label"></span>
</div> </div>
<div style="display: flex; align-items: center; gap: 0.5rem;"> <div style="display: flex; align-items: center; gap: 0.5rem;">
@@ -88,32 +88,38 @@
</div> </div>
</header> </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 --> <!-- Tab Bar -->
<div class="tab-bar" id="tabBar"> <div class="tab-bar" id="tabBar" role="tablist">
<div class="tab-indicator" id="tabIndicator"></div> <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> <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> <span data-i18n="tab.player">Player</span>
</button> </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> <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> <span data-i18n="tab.browser">Browser</span>
</button> </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> <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> <span data-i18n="tab.quick_actions">Actions</span>
</button> </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> <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> <span data-i18n="tab.scripts">Scripts</span>
</button> </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> <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> <span data-i18n="tab.callbacks">Callbacks</span>
</button> </button>
</div> </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="player-layout">
<div class="album-art-container"> <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"> <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="current-time">0:00</span>
<span id="total-time">0:00</span> <span id="total-time">0:00</span>
</div> </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 class="progress-fill" id="progress-fill"></div>
</div> </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"/> <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> </svg>
</button> </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 class="volume-display" id="volume-display">50%</div>
</div> </div>
@@ -182,7 +188,7 @@
</div> </div>
<!-- Media Browser Section --> <!-- 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 --> <!-- Breadcrumb Navigation -->
<div class="breadcrumb" id="breadcrumb"></div> <div class="breadcrumb" id="breadcrumb"></div>
@@ -249,7 +255,7 @@
</div> </div>
<!-- Scripts Section (Quick Actions) --> <!-- 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-grid" id="scripts-grid">
<div class="scripts-empty empty-state-illustration"> <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> <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> </div>
<!-- Script Management Section --> <!-- 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"> <table class="scripts-table">
<thead> <thead>
<tr> <tr>
@@ -290,7 +296,7 @@
</div> </div>
<!-- Callback Management Section --> <!-- 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"> <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.) Callbacks are scripts triggered automatically by media control events (play, pause, stop, etc.)
</p> </p>
@@ -480,8 +486,17 @@
</form> </form>
</dialog> </dialog>
<!-- Toast Notification --> <!-- Confirm Dialog -->
<div class="toast" id="toast"></div> <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 -->
<footer> <footer>

View File

@@ -55,7 +55,9 @@
const POSITION_INTERPOLATION_MS = 100; const POSITION_INTERPOLATION_MS = 100;
const SEARCH_DEBOUNCE_MS = 200; const SEARCH_DEBOUNCE_MS = 200;
const TOAST_DURATION_MS = 3000; 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 WS_PING_INTERVAL_MS = 30000;
const VOLUME_RELEASE_DELAY_MS = 500; const VOLUME_RELEASE_DELAY_MS = 500;
@@ -105,11 +107,17 @@
target.classList.add('active'); target.classList.add('active');
} }
// Update tab buttons // Update tab buttons and ARIA state
document.querySelectorAll('.tab-btn').forEach(btn => btn.classList.remove('active')); 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}"]`); const activeBtn = document.querySelector(`.tab-btn[data-tab="${tabName}"]`);
if (activeBtn) { if (activeBtn) {
activeBtn.classList.add('active'); activeBtn.classList.add('active');
activeBtn.setAttribute('aria-selected', 'true');
activeBtn.setAttribute('tabindex', '0');
updateTabIndicator(activeBtn); updateTabIndicator(activeBtn);
} }
@@ -413,6 +421,7 @@
let ws = null; let ws = null;
let reconnectTimeout = null; let reconnectTimeout = null;
let pingInterval = null; let pingInterval = null;
let wsReconnectAttempts = 0;
let currentState = 'idle'; let currentState = 'idle';
let currentDuration = 0; let currentDuration = 0;
let currentPosition = 0; let currentPosition = 0;
@@ -421,6 +430,7 @@
let scripts = []; let scripts = [];
let lastStatus = null; // Store last status for locale switching let lastStatus = null; // Store last status for locale switching
let lastArtworkKey = null; // Track artwork identity to skip redundant loads let lastArtworkKey = null; // Track artwork identity to skip redundant loads
let currentArtworkBlobUrl = null; // Track current blob URL for safe revocation
// Dialog dirty state tracking // Dialog dirty state tracking
let scriptFormDirty = false; let scriptFormDirty = false;
@@ -431,6 +441,61 @@
let lastPositionValue = 0; let lastPositionValue = 0;
let interpolationInterval = null; 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 // Initialize on page load
window.addEventListener('DOMContentLoaded', async () => { window.addEventListener('DOMContentLoaded', async () => {
// Cache DOM references // Cache DOM references
@@ -516,26 +581,15 @@
}, { threshold: 0.1 }); }, { threshold: 0.1 });
observer.observe(playerContainer); observer.observe(playerContainer);
// Mini player progress bar click to seek // Drag-to-seek for progress bars
const miniProgressBar = document.getElementById('mini-progress-bar'); setupProgressDrag(
miniProgressBar.addEventListener('click', (e) => { document.getElementById('mini-progress-bar'),
const rect = miniProgressBar.getBoundingClientRect(); document.getElementById('mini-progress-fill')
const percent = (e.clientX - rect.left) / rect.width; );
const position = percent * currentDuration; setupProgressDrag(
seek(position); document.getElementById('progress-bar'),
}); document.getElementById('progress-fill')
);
// 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 // Enter key in token input
document.getElementById('token-input').addEventListener('keypress', (e) => { document.getElementById('token-input').addEventListener('keypress', (e) => {
@@ -579,6 +633,99 @@
closeCallbackDialog(); 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 = '') { function showAuthForm(errorMessage = '') {
@@ -631,7 +778,9 @@
ws.onopen = () => { ws.onopen = () => {
console.log('WebSocket connected'); console.log('WebSocket connected');
wsReconnectAttempts = 0;
updateConnectionStatus(true); updateConnectionStatus(true);
hideConnectionBanner();
hideAuthForm(); hideAuthForm();
loadScripts(); loadScripts();
loadScriptsTable(); loadScriptsTable();
@@ -667,14 +816,30 @@
localStorage.removeItem('media_server_token'); localStorage.removeItem('media_server_token');
showAuthForm(t('auth.invalid')); showAuthForm(t('auth.invalid'));
} else if (event.code !== 1000) { } else if (event.code !== 1000) {
// Abnormal closure - attempt reconnect // Abnormal closure - attempt reconnect with exponential backoff
reconnectTimeout = setTimeout(() => { wsReconnectAttempts++;
const savedToken = localStorage.getItem('media_server_token');
if (savedToken) { if (wsReconnectAttempts <= WS_MAX_RECONNECT_ATTEMPTS) {
console.log('Attempting to reconnect...'); const delay = Math.min(
connectWebSocket(savedToken); 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) { function updateUI(status) {
// Store status for locale switching // Store status for locale switching
lastStatus = status; lastStatus = status;
@@ -719,15 +907,35 @@
if (artworkKey !== lastArtworkKey) { if (artworkKey !== lastArtworkKey) {
lastArtworkKey = artworkKey; lastArtworkKey = artworkKey;
const artworkUrl = artworkSource 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";
? `/api/media/artwork?token=${encodeURIComponent(localStorage.getItem('media_server_token'))}&_=${Date.now()}` 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";
: "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"; if (artworkSource) {
dom.albumArt.src = artworkUrl; // Fetch artwork with Authorization header (avoid token in URL)
dom.miniAlbumArt.src = artworkUrl; const token = localStorage.getItem('media_server_token');
if (dom.albumArtGlow) { fetch(`/api/media/artwork?_=${Date.now()}`, {
dom.albumArtGlow.src = artworkSource headers: { 'Authorization': `Bearer ${token}` }
? 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"; .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 widthStr = `${percent}%`;
const currentStr = formatTime(position); const currentStr = formatTime(position);
const totalStr = formatTime(duration); const totalStr = formatTime(duration);
const posRound = Math.round(position);
const durRound = Math.round(duration);
dom.progressFill.style.width = widthStr; dom.progressFill.style.width = widthStr;
dom.currentTime.textContent = currentStr; dom.currentTime.textContent = currentStr;
dom.totalTime.textContent = totalStr; dom.totalTime.textContent = totalStr;
dom.progressBar.dataset.duration = duration; dom.progressBar.dataset.duration = duration;
dom.progressBar.setAttribute('aria-valuenow', posRound);
dom.progressBar.setAttribute('aria-valuemax', durRound);
dom.miniProgressFill.style.width = widthStr; dom.miniProgressFill.style.width = widthStr;
dom.miniCurrentTime.textContent = currentStr; dom.miniCurrentTime.textContent = currentStr;
dom.miniTotalTime.textContent = totalStr; dom.miniTotalTime.textContent = totalStr;
if (dom.miniPlayer) dom.miniPlayer.style.setProperty('--mini-progress', widthStr); 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() { function startPositionInterpolation() {
@@ -941,7 +1156,7 @@
} }
function displayScripts() { function displayScripts() {
const container = document.getElementById('scripts-container'); const container = document.getElementById('panel-quick-actions');
const grid = document.getElementById('scripts-grid'); const grid = document.getElementById('scripts-grid');
grid.innerHTML = ''; grid.innerHTML = '';
@@ -1012,15 +1227,53 @@
} }
function showToast(message, type = 'success') { 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.textContent = message;
toast.className = `toast ${type} show`; container.appendChild(toast);
// Trigger reflow then show
requestAnimationFrame(() => {
toast.classList.add('show');
});
setTimeout(() => { setTimeout(() => {
toast.classList.remove('show'); 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); }, 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 // Script Management Functions
let _loadScriptsPromise = null; let _loadScriptsPromise = null;
@@ -1053,20 +1306,20 @@
tbody.innerHTML = scriptsList.map(script => ` tbody.innerHTML = scriptsList.map(script => `
<tr> <tr>
<td><code>${script.name}</code></td> <td><code>${escapeHtml(script.name)}</code></td>
<td>${script.label || script.name}</td> <td>${escapeHtml(script.label || script.name)}</td>
<td style="max-width: 200px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;" <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> title="${escapeHtml(script.command || 'N/A')}">${escapeHtml(script.command || 'N/A')}</td>
<td>${script.timeout}s</td> <td>${script.timeout}s</td>
<td> <td>
<div class="action-buttons"> <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> <svg viewBox="0 0 24 24"><path d="M8 5v14l11-7z"/></svg>
</button> </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> <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>
<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> <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> </button>
</div> </div>
@@ -1151,10 +1404,10 @@
} }
} }
function closeScriptDialog() { async function closeScriptDialog() {
// Check if form has unsaved changes // Check if form has unsaved changes
if (scriptFormDirty) { if (scriptFormDirty) {
if (!confirm(t('scripts.confirm.unsaved'))) { if (!await showConfirm(t('scripts.confirm.unsaved'))) {
return; // User cancelled, don't close return; // User cancelled, don't close
} }
} }
@@ -1220,7 +1473,7 @@
} }
async function deleteScriptConfirm(scriptName) { 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; return;
} }
@@ -1280,19 +1533,19 @@
tbody.innerHTML = callbacksList.map(callback => ` tbody.innerHTML = callbacksList.map(callback => `
<tr> <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;" <td style="max-width: 200px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;"
title="${escapeHtml(callback.command)}">${escapeHtml(callback.command)}</td> title="${escapeHtml(callback.command)}">${escapeHtml(callback.command)}</td>
<td>${callback.timeout}s</td> <td>${callback.timeout}s</td>
<td> <td>
<div class="action-buttons"> <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> <svg viewBox="0 0 24 24"><path d="M8 5v14l11-7z"/></svg>
</button> </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> <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>
<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> <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> </button>
</div> </div>
@@ -1367,10 +1620,10 @@
} }
} }
function closeCallbackDialog() { async function closeCallbackDialog() {
// Check if form has unsaved changes // Check if form has unsaved changes
if (callbackFormDirty) { if (callbackFormDirty) {
if (!confirm(t('callbacks.confirm.unsaved'))) { if (!await showConfirm(t('callbacks.confirm.unsaved'))) {
return; // User cancelled, don't close return; // User cancelled, don't close
} }
} }
@@ -1433,7 +1686,7 @@
} }
async function deleteCallbackConfirm(callbackName) { 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; return;
} }
@@ -1650,6 +1903,8 @@ let viewMode = localStorage.getItem('mediaBrowser.viewMode') || 'grid';
let cachedItems = null; let cachedItems = null;
let browserSearchTerm = ''; let browserSearchTerm = '';
let browserSearchTimer = null; let browserSearchTimer = null;
const thumbnailCache = new Map();
const THUMBNAIL_CACHE_MAX = 200;
// Load media folders on page load // Load media folders on page load
async function loadMediaFolders() { async function loadMediaFolders() {
@@ -1852,8 +2107,12 @@ function renderBreadcrumbs(currentPath, parentPath) {
} }
function revokeBlobUrls(container) { function revokeBlobUrls(container) {
const cachedUrls = new Set(thumbnailCache.values());
container.querySelectorAll('img[src^="blob:"]').forEach(img => { 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; return;
} }
const fullPath = currentPath === '/' const absolutePath = buildAbsolutePath(currentFolderId, currentPath, fileName);
? '/' + fileName
: currentPath + '/' + fileName; // Check cache first
const encodedPath = encodeURIComponent( if (thumbnailCache.has(absolutePath)) {
mediaFolders[currentFolderId].path + fullPath.replace(/\//g, '\\') 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( const response = await fetch(
`/api/browser/thumbnail?path=${encodedPath}&size=medium`, `/api/browser/thumbnail?path=${encodedPath}&size=medium`,
@@ -2132,6 +2399,14 @@ async function loadThumbnail(imgElement, fileName) {
if (response.status === 200) { if (response.status === 200) {
const blob = await response.blob(); const blob = await response.blob();
const url = URL.createObjectURL(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 // Wait for image to actually load before showing it
imgElement.onload = () => { imgElement.onload = () => {
@@ -2139,9 +2414,14 @@ async function loadThumbnail(imgElement, fileName) {
imgElement.classList.add('loaded'); 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:')) { 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; imgElement.src = url;
} else { } 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; let playInProgress = false;
async function playMediaFile(fileName) { async function playMediaFile(fileName) {
@@ -2176,10 +2466,7 @@ async function playMediaFile(fileName) {
return; return;
} }
const fullPath = currentPath === '/' const absolutePath = buildAbsolutePath(currentFolderId, currentPath, fileName);
? '/' + fileName
: currentPath + '/' + fileName;
const absolutePath = mediaFolders[currentFolderId].path + fullPath.replace(/\//g, '\\');
const response = await fetch('/api/browser/play', { const response = await fetch('/api/browser/play', {
method: 'POST', method: 'POST',
@@ -2235,7 +2522,7 @@ async function playAllFolder() {
} }
} }
function downloadFile(fileName, event) { async function downloadFile(fileName, event) {
if (event) event.stopPropagation(); if (event) event.stopPropagation();
const token = localStorage.getItem('media_server_token'); const token = localStorage.getItem('media_server_token');
if (!token) return; if (!token) return;
@@ -2244,14 +2531,27 @@ function downloadFile(fileName, event) {
? '/' + fileName ? '/' + fileName
: currentPath + '/' + fileName; : currentPath + '/' + fileName;
const encodedPath = encodeURIComponent(fullPath); const encodedPath = encodeURIComponent(fullPath);
const url = `/api/browser/download?folder_id=${currentFolderId}&path=${encodedPath}&token=${token}`;
const a = document.createElement('a'); try {
a.href = url; const response = await fetch(
a.download = fileName; `/api/browser/download?folder_id=${currentFolderId}&path=${encodedPath}`,
document.body.appendChild(a); { headers: { 'Authorization': `Bearer ${token}` } }
a.click(); );
document.body.removeChild(a); 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) { function createDownloadBtn(fileName, cssClass) {
@@ -2341,7 +2641,8 @@ function applyBrowserSearch() {
} }
const filtered = cachedItems.filter(item => const filtered = cachedItems.filter(item =>
item.name.toLowerCase().includes(browserSearchTerm) item.name.toLowerCase().includes(browserSearchTerm) ||
(item.title && item.title.toLowerCase().includes(browserSearchTerm))
); );
renderBrowserItems(filtered); renderBrowserItems(filtered);
} }
@@ -2468,13 +2769,3 @@ async function saveFolder(event) {
closeFolderDialog(); 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();
}
});

View File

@@ -154,6 +154,12 @@
"browser.folder_dialog.enabled": "Enabled", "browser.folder_dialog.enabled": "Enabled",
"browser.folder_dialog.cancel": "Cancel", "browser.folder_dialog.cancel": "Cancel",
"browser.folder_dialog.save": "Save", "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.created_by": "Created by",
"footer.source_code": "Source Code" "footer.source_code": "Source Code"
} }

View File

@@ -154,6 +154,12 @@
"browser.folder_dialog.enabled": "Включено", "browser.folder_dialog.enabled": "Включено",
"browser.folder_dialog.cancel": "Отмена", "browser.folder_dialog.cancel": "Отмена",
"browser.folder_dialog.save": "Сохранить", "browser.folder_dialog.save": "Сохранить",
"browser.download_error": "Не удалось скачать файл",
"connection.reconnecting": "Соединение потеряно. Переподключение (попытка {attempt})...",
"connection.lost": "Соединение потеряно. Сервер может быть недоступен.",
"connection.reconnect": "Переподключиться",
"dialog.cancel": "Отмена",
"dialog.confirm": "Подтвердить",
"footer.created_by": "Создано", "footer.created_by": "Создано",
"footer.source_code": "Исходный код" "footer.source_code": "Исходный код"
} }