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:
@@ -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 {
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|||||||
@@ -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"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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": "Исходный код"
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user