Compare commits

...

1 Commits

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

View File

@@ -4,11 +4,18 @@
--bg-tertiary: #282828; --bg-tertiary: #282828;
--text-primary: #ffffff; --text-primary: #ffffff;
--text-secondary: #b3b3b3; --text-secondary: #b3b3b3;
--text-muted: #6a6a6a; --text-muted: #8a8a8a;
--accent: #1db954; --accent: #1db954;
--accent-hover: #1ed760; --accent-hover: #1ed760;
--border: #404040; --border: #404040;
--error: #e74c3c; --error: #e74c3c;
--vinyl-ring: #1a1a1a;
--vinyl-groove: #2a2a2a;
--vinyl-highlight: rgba(255,255,255,0.05);
--vinyl-highlight-dim: rgba(255,255,255,0.03);
--vinyl-edge: #111;
--vinyl-spindle: #0a0a0a;
--shadow-elevation: rgba(0, 0, 0, 0.5);
} }
:root[data-theme="light"] { :root[data-theme="light"] {
@@ -22,12 +29,44 @@
--accent-hover: #1ed760; --accent-hover: #1ed760;
--border: #d0d0d0; --border: #d0d0d0;
--error: #e74c3c; --error: #e74c3c;
--vinyl-ring: #c0c0c0;
--vinyl-groove: #b0b0b0;
--vinyl-highlight: rgba(255,255,255,0.3);
--vinyl-highlight-dim: rgba(255,255,255,0.15);
--vinyl-edge: #999;
--vinyl-spindle: #888;
--shadow-elevation: rgba(0, 0, 0, 0.15);
}
:root[data-theme="light"] .player-container,
:root[data-theme="light"] .browser-container,
:root[data-theme="light"] .scripts-container,
:root[data-theme="light"] .script-management {
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.08);
}
:root[data-theme="light"] .toast {
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.1);
}
:root[data-theme="light"] dialog {
box-shadow: 0 4px 24px rgba(0, 0, 0, 0.15);
}
:root[data-theme="light"] dialog::backdrop {
background: rgba(0, 0, 0, 0.4);
}
:root[data-theme="light"] .mini-player {
box-shadow: 0 -2px 8px rgba(0, 0, 0, 0.08);
} }
* { * {
margin: 0; margin: 0;
padding: 0; padding: 0;
box-sizing: border-box; box-sizing: border-box;
scrollbar-width: thin;
scrollbar-color: var(--border) var(--bg-primary);
} }
html { html {
@@ -35,7 +74,7 @@
} }
body { body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Helvetica', 'Arial', sans-serif; font-family: 'Segoe UI', -apple-system, BlinkMacSystemFont, 'Roboto', 'Helvetica Neue', 'Arial', sans-serif;
background: var(--bg-primary); background: var(--bg-primary);
color: var(--text-primary); color: var(--text-primary);
line-height: 1.6; line-height: 1.6;
@@ -70,11 +109,6 @@
background: var(--text-muted); background: var(--text-muted);
} }
* {
scrollbar-width: thin;
scrollbar-color: var(--border) var(--bg-primary);
}
/* Focus-visible states for keyboard accessibility */ /* Focus-visible states for keyboard accessibility */
:focus-visible { :focus-visible {
outline: 2px solid var(--accent); outline: 2px solid var(--accent);
@@ -212,7 +246,6 @@
gap: 6px; gap: 6px;
z-index: 100; z-index: 100;
box-shadow: 0 4px 12px rgba(0,0,0,0.3); box-shadow: 0 4px 12px rgba(0,0,0,0.3);
display: none;
grid-template-columns: repeat(3, 24px); grid-template-columns: repeat(3, 24px);
} }
@@ -409,21 +442,21 @@
margin: 45px; margin: 45px;
filter: saturate(0.8) brightness(0.92) contrast(1.05); filter: saturate(0.8) brightness(0.92) contrast(1.05);
box-shadow: box-shadow:
0 0 0 3px #2a2a2a, 0 0 0 3px var(--vinyl-groove),
0 0 0 5px #1a1a1a, 0 0 0 5px var(--vinyl-ring),
0 0 0 6px rgba(255,255,255,0.05), 0 0 0 6px var(--vinyl-highlight),
0 0 0 12px #1a1a1a, 0 0 0 12px var(--vinyl-ring),
0 0 0 13px rgba(255,255,255,0.03), 0 0 0 13px var(--vinyl-highlight-dim),
0 0 0 20px #1a1a1a, 0 0 0 20px var(--vinyl-ring),
0 0 0 21px rgba(255,255,255,0.05), 0 0 0 21px var(--vinyl-highlight),
0 0 0 28px #1a1a1a, 0 0 0 28px var(--vinyl-ring),
0 0 0 29px rgba(255,255,255,0.03), 0 0 0 29px var(--vinyl-highlight-dim),
0 0 0 36px #1a1a1a, 0 0 0 36px var(--vinyl-ring),
0 0 0 37px rgba(255,255,255,0.05), 0 0 0 37px var(--vinyl-highlight),
0 0 0 42px #1a1a1a, 0 0 0 42px var(--vinyl-ring),
0 0 0 43px #2a2a2a, 0 0 0 43px var(--vinyl-groove),
0 0 0 45px #111, 0 0 0 45px var(--vinyl-edge),
0 4px 15px 45px rgba(0,0,0,0.4); 0 4px 15px 45px var(--shadow-elevation);
} }
/* Vinyl label vignette overlay */ /* Vinyl label vignette overlay */
@@ -463,9 +496,9 @@
width: 14px; width: 14px;
height: 14px; height: 14px;
border-radius: 50%; border-radius: 50%;
background: #0a0a0a; background: var(--vinyl-spindle);
border: 2px solid #444; border: 2px solid var(--border);
box-shadow: inset 0 1px 3px rgba(0,0,0,0.8); box-shadow: inset 0 1px 3px rgba(0,0,0,0.5);
z-index: 3; z-index: 3;
top: 50%; top: 50%;
left: 50%; left: 50%;
@@ -580,10 +613,19 @@
transition: transform 0.15s ease; transition: transform 0.15s ease;
} }
.progress-bar:hover .progress-fill::after { .progress-bar:hover .progress-fill::after,
.progress-bar.dragging .progress-fill::after {
transform: translateY(-50%) scale(1); transform: translateY(-50%) scale(1);
} }
.progress-bar.dragging {
transform: scaleY(1.4);
}
.progress-bar.dragging .progress-fill {
transition: none;
}
.controls { .controls {
display: flex; display: flex;
gap: 1rem; gap: 1rem;
@@ -593,10 +635,22 @@
} }
button { button {
background: var(--bg-tertiary);
border: none; border: none;
color: var(--text-primary); background: none;
color: inherit;
font: inherit;
cursor: pointer; cursor: pointer;
padding: 0;
}
button:disabled {
opacity: 0.3;
cursor: not-allowed;
}
.controls button {
background: var(--bg-tertiary);
color: var(--text-primary);
border-radius: 50%; border-radius: 50%;
width: 48px; width: 48px;
height: 48px; height: 48px;
@@ -606,24 +660,19 @@
transition: all 0.2s; transition: all 0.2s;
} }
button:hover:not(:disabled) { .controls button:hover:not(:disabled) {
background: var(--accent); background: var(--accent);
color: #fff; color: #fff;
transform: scale(1.05); transform: scale(1.05);
} }
button:disabled { .controls button.primary {
opacity: 0.3;
cursor: not-allowed;
}
button.primary {
width: 56px; width: 56px;
height: 56px; height: 56px;
background: var(--accent); background: var(--accent);
} }
button.primary:hover:not(:disabled) { .controls button.primary:hover:not(:disabled) {
background: var(--accent-hover); background: var(--accent-hover);
color: #fff; color: #fff;
transform: scale(1.1); transform: scale(1.1);
@@ -690,6 +739,19 @@
.mute-btn { .mute-btn {
width: 40px; width: 40px;
height: 40px; height: 40px;
background: var(--bg-tertiary);
border-radius: 50%;
color: var(--text-primary);
display: flex;
align-items: center;
justify-content: center;
transition: all 0.2s;
}
.mute-btn:hover:not(:disabled) {
background: var(--accent);
color: #fff;
transform: scale(1.05);
} }
.source-info { .source-info {
@@ -1039,6 +1101,50 @@
background: rgba(0, 0, 0, 0.8); background: rgba(0, 0, 0, 0.8);
} }
.confirm-dialog {
max-width: 400px;
padding: 1.5rem;
}
.confirm-dialog p {
margin-bottom: 1.25rem;
font-size: 0.95rem;
line-height: 1.5;
color: var(--text-primary);
}
.confirm-dialog-actions {
display: flex;
justify-content: flex-end;
gap: 8px;
}
.confirm-dialog-actions button {
padding: 8px 20px;
border-radius: 6px;
font-size: 0.85rem;
font-weight: 500;
transition: all 0.2s;
}
.btn-cancel {
background: var(--bg-tertiary);
color: var(--text-primary);
}
.btn-cancel:hover {
background: var(--border);
}
.btn-danger {
background: var(--error);
color: #fff;
}
.btn-danger:hover {
filter: brightness(1.15);
}
.dialog-header { .dialog-header {
padding: 1.5rem; padding: 1.5rem;
background: var(--bg-secondary); background: var(--bg-secondary);
@@ -1165,10 +1271,18 @@
margin: 0; margin: 0;
} }
.toast { .toast-container {
position: fixed; position: fixed;
bottom: 2rem; bottom: 2rem;
right: 2rem; right: 2rem;
display: flex;
flex-direction: column-reverse;
gap: 8px;
z-index: 1000;
pointer-events: none;
}
.toast {
background: var(--bg-secondary); background: var(--bg-secondary);
border: 1px solid var(--border); border: 1px solid var(--border);
border-radius: 8px; border-radius: 8px;
@@ -1178,8 +1292,7 @@
opacity: 0; opacity: 0;
transform: translateY(20px); transform: translateY(20px);
transition: all 0.3s; transition: all 0.3s;
pointer-events: none; pointer-events: auto;
z-index: 1000;
border-left: 4px solid var(--border); border-left: 4px solid var(--border);
} }
@@ -1257,7 +1370,9 @@
padding: 0.75rem; padding: 0.75rem;
border-radius: 6px; border-radius: 6px;
background: var(--accent); background: var(--accent);
color: var(--text-primary);
font-weight: 600; font-weight: 600;
transition: background 0.2s;
} }
.btn-connect:hover { .btn-connect:hover {
@@ -2412,6 +2527,45 @@
} }
} }
/* Connection Banner */
.connection-banner {
position: fixed;
top: 0;
left: 0;
right: 0;
z-index: 1000;
display: flex;
align-items: center;
justify-content: center;
gap: 12px;
padding: 10px 16px;
background: var(--error);
color: #fff;
font-size: 0.85rem;
font-weight: 500;
text-align: center;
transition: transform 0.3s ease;
}
.connection-banner.hidden {
transform: translateY(-100%);
pointer-events: none;
}
.connection-banner-btn {
padding: 4px 14px;
background: rgba(255, 255, 255, 0.2);
color: #fff;
border-radius: 4px;
font-size: 0.8rem;
font-weight: 600;
transition: background 0.2s;
}
.connection-banner-btn:hover {
background: rgba(255, 255, 255, 0.35);
}
/* Wide screens - horizontal player layout */ /* Wide screens - horizontal player layout */
@media (min-width: 900px) { @media (min-width: 900px) {
.container { .container {

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
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);
}
reconnectTimeout = setTimeout(() => { reconnectTimeout = setTimeout(() => {
const savedToken = localStorage.getItem('media_server_token'); const savedToken = localStorage.getItem('media_server_token');
if (savedToken) { if (savedToken) {
console.log('Attempting to reconnect...');
connectWebSocket(savedToken); connectWebSocket(savedToken);
} }
}, WS_RECONNECT_MS); }, 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 => {
// Don't revoke URLs managed by the thumbnail cache
if (!cachedUrls.has(img.src)) {
URL.revokeObjectURL(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}`;
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'); const a = document.createElement('a');
a.href = url; a.href = url;
a.download = fileName; a.download = fileName;
document.body.appendChild(a); document.body.appendChild(a);
a.click(); a.click();
document.body.removeChild(a); 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": "Исходный код"
} }