diff --git a/webui/static/script.js b/webui/static/script.js
index 86cadaac..a954a9e6 100644
--- a/webui/static/script.js
+++ b/webui/static/script.js
@@ -386,6 +386,9 @@ function handleServiceStatusUpdate(data) {
updateSidebarServiceStatus('media-server', data.media_server);
updateSidebarServiceStatus('soulseek', data.soulseek);
+ // Update downloads nav badge from status push
+ if (data.active_downloads !== undefined) _updateDlNavBadge(data.active_downloads);
+
// Update enrichment service cards
if (data.enrichment) renderEnrichmentCards(data.enrichment);
@@ -2935,6 +2938,9 @@ async function loadPageData(pageId) {
restoreArtistsPageState();
}
break;
+ case 'active-downloads':
+ loadActiveDownloadsPage();
+ break;
case 'library':
// Check if we should return to artist detail view instead of list
if (artistDetailPageState.currentArtistId && artistDetailPageState.currentArtistName) {
@@ -38727,6 +38733,9 @@ async function fetchAndUpdateServiceStatus() {
updateSidebarServiceStatus('media-server', data.media_server);
updateSidebarServiceStatus('soulseek', data.soulseek);
+ // Update downloads nav badge
+ if (data.active_downloads !== undefined) _updateDlNavBadge(data.active_downloads);
+
// Update enrichment service cards
if (data.enrichment) renderEnrichmentCards(data.enrichment);
@@ -72715,3 +72724,216 @@ function _syncDetailFilter(btn, filter) {
}
});
}
+
+
+// ============================================
+// ACTIVE DOWNLOADS PAGE — Centralized Live View
+// ============================================
+
+let _adlPoller = null;
+let _adlFilter = 'all';
+let _adlData = [];
+
+function loadActiveDownloadsPage() {
+ _adlFetch();
+ // Poll every 2 seconds while on this page
+ if (_adlPoller) clearInterval(_adlPoller);
+ _adlPoller = setInterval(() => {
+ if (currentPage === 'active-downloads') _adlFetch();
+ else { clearInterval(_adlPoller); _adlPoller = null; }
+ }, 2000);
+}
+
+function adlSetFilter(filter) {
+ _adlFilter = filter;
+ document.querySelectorAll('#adl-filter-pills .adl-pill').forEach(p => p.classList.toggle('active', p.dataset.filter === filter));
+ _adlRender();
+}
+
+async function _adlFetch() {
+ try {
+ const resp = await fetch('/api/downloads/all?limit=300');
+ const data = await resp.json();
+ if (data.success) {
+ _adlData = data.downloads || [];
+ _adlRender();
+ _adlUpdateBadge();
+ }
+ } catch (e) {
+ console.error('Downloads page fetch error:', e);
+ }
+}
+
+function _adlUpdateBadge() {
+ const activeCount = _adlData.filter(d => ['downloading', 'searching', 'queued', 'pending', 'post_processing'].includes(d.status)).length;
+ _updateDlNavBadge(activeCount);
+}
+
+function _updateDlNavBadge(count) {
+ const badge = document.getElementById('dl-nav-badge');
+ if (badge) {
+ if (count > 0) {
+ badge.textContent = count;
+ badge.classList.remove('hidden');
+ } else {
+ badge.classList.add('hidden');
+ }
+ }
+}
+
+function _adlRender() {
+ const list = document.getElementById('adl-list');
+ const empty = document.getElementById('adl-empty');
+ const countEl = document.getElementById('adl-count');
+ if (!list) return;
+
+ // Apply filter
+ const activeStatuses = ['downloading', 'searching', 'post_processing'];
+ const queuedStatuses = ['queued'];
+ const completedStatuses = ['completed', 'skipped', 'already_owned'];
+ const failedStatuses = ['failed', 'not_found', 'cancelled'];
+
+ let filtered = _adlData;
+ if (_adlFilter === 'active') filtered = _adlData.filter(d => activeStatuses.includes(d.status));
+ else if (_adlFilter === 'queued') filtered = _adlData.filter(d => queuedStatuses.includes(d.status));
+ else if (_adlFilter === 'completed') filtered = _adlData.filter(d => completedStatuses.includes(d.status));
+ else if (_adlFilter === 'failed') filtered = _adlData.filter(d => failedStatuses.includes(d.status));
+
+ const completedN = _adlData.filter(d => [...completedStatuses, ...failedStatuses].includes(d.status)).length;
+
+ if (countEl) {
+ const activeN = _adlData.filter(d => activeStatuses.includes(d.status)).length;
+ const queuedN = _adlData.filter(d => queuedStatuses.includes(d.status)).length;
+ const total = _adlData.length;
+ const parts = [];
+ if (activeN > 0) parts.push(`${activeN} active`);
+ if (queuedN > 0) parts.push(`${queuedN} queued`);
+ parts.push(`${total} total`);
+ countEl.textContent = parts.join(' / ');
+ }
+
+ // Show/hide clear button
+ const clearBtn = document.getElementById('adl-clear-btn');
+ if (clearBtn) clearBtn.style.display = completedN > 0 ? '' : 'none';
+
+ if (filtered.length === 0) {
+ if (empty) empty.style.display = '';
+ // Clear any existing rows but keep the empty message
+ list.querySelectorAll('.adl-row').forEach(r => r.remove());
+ return;
+ }
+
+ if (empty) empty.style.display = 'none';
+
+ // Group by status category for section headers
+ const groups = { active: [], queued: [], completed: [], failed: [] };
+ for (const dl of filtered) {
+ const cls = _adlStatusClass(dl.status);
+ if (cls === 'active') groups.active.push(dl);
+ else if (cls === 'queued') groups.queued.push(dl);
+ else if (cls === 'completed') groups.completed.push(dl);
+ else groups.failed.push(dl);
+ }
+
+ let html = '';
+ const sections = [
+ { key: 'active', label: 'Active', items: groups.active },
+ { key: 'queued', label: 'Queued', items: groups.queued },
+ { key: 'completed', label: 'Completed', items: groups.completed },
+ { key: 'failed', label: 'Failed', items: groups.failed },
+ ];
+
+ for (const section of sections) {
+ if (section.items.length === 0) continue;
+ // Only show section headers in "all" filter mode
+ if (_adlFilter === 'all') {
+ html += ``;
+ }
+ for (const dl of section.items) {
+ const statusClass = _adlStatusClass(dl.status);
+ const statusLabel = _adlStatusLabel(dl.status);
+ const title = _adlEsc(dl.title || 'Unknown Track');
+ const artist = _adlEsc(dl.artist || '');
+ const album = _adlEsc(dl.album || '');
+ const batchName = _adlEsc(dl.batch_name || '');
+ const error = dl.error ? _adlEsc(dl.error) : '';
+
+ const meta = [artist, album].filter(Boolean).join(' \u00B7 ');
+ const artHtml = dl.artwork
+ ? `
})
`
+ : '
';
+
+ // Track position: "3 of 19"
+ const posText = dl.batch_total > 1 ? `${(dl.track_index || 0) + 1} of ${dl.batch_total}` : '';
+
+ html += `
+ ${artHtml}
+
+
${title}
+ ${meta ? `
${meta}
` : ''}
+ ${batchName ? `
${batchName}${posText ? ' · Track ' + posText : ''}
` : ''}
+ ${error ? `
${error}
` : ''}
+
+
+
+ ${statusLabel}
+
+
`;
+ }
+ }
+
+ // Preserve empty element, inject rows
+ const emptyEl = document.getElementById('adl-empty');
+ const emptyHtml = emptyEl ? emptyEl.outerHTML : '';
+ list.innerHTML = emptyHtml + html;
+ const newEmpty = document.getElementById('adl-empty');
+ if (newEmpty) newEmpty.style.display = filtered.length > 0 ? 'none' : '';
+}
+
+function _adlStatusClass(status) {
+ switch (status) {
+ case 'downloading': case 'searching': case 'post_processing': return 'active';
+ case 'queued': case 'pending': return 'queued';
+ case 'completed': case 'skipped': case 'already_owned': return 'completed';
+ case 'failed': case 'not_found': return 'failed';
+ case 'cancelled': return 'cancelled';
+ default: return 'queued';
+ }
+}
+
+function _adlStatusLabel(status) {
+ switch (status) {
+ case 'downloading': return '
Downloading';
+ case 'searching': return '
Searching';
+ case 'post_processing': return '
Processing';
+ case 'queued': case 'pending': return 'Queued';
+ case 'completed': return 'Completed';
+ case 'skipped': return 'Skipped';
+ case 'already_owned': return 'Owned';
+ case 'failed': return 'Failed';
+ case 'not_found': return 'Not Found';
+ case 'cancelled': return 'Cancelled';
+ default: return status;
+ }
+}
+
+function _adlEsc(str) {
+ if (!str) return '';
+ return String(str).replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"');
+}
+
+async function adlClearCompleted() {
+ try {
+ const resp = await fetch('/api/downloads/clear-completed', { method: 'POST' });
+ const data = await resp.json();
+ if (data.success) {
+ if (typeof showToast === 'function') showToast(`Cleared ${data.cleared} downloads`, 'success');
+ _adlFetch();
+ }
+ } catch (e) {
+ console.error('Error clearing completed downloads:', e);
+ }
+}
+
+window.adlSetFilter = adlSetFilter;
+window.adlClearCompleted = adlClearCompleted;
diff --git a/webui/static/style.css b/webui/static/style.css
index 14d45f8d..c5e0d300 100644
--- a/webui/static/style.css
+++ b/webui/static/style.css
@@ -55178,3 +55178,339 @@ body.reduce-effects *::after {
-webkit-backdrop-filter: none !important;
box-shadow: none !important;
}
+
+/* ============================================
+ ACTIVE DOWNLOADS PAGE — Premium Glassmorphic
+ ============================================ */
+
+.adl-container {
+ padding: 28px 32px;
+ max-width: 960px;
+}
+
+.adl-header {
+ margin-bottom: 24px;
+}
+
+.adl-title {
+ font-size: 1.5rem;
+ font-weight: 700;
+ color: #fff;
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ margin-bottom: 16px;
+ letter-spacing: -0.02em;
+}
+
+.adl-title svg {
+ color: rgb(var(--accent-rgb));
+ filter: drop-shadow(0 0 6px rgba(var(--accent-rgb), 0.3));
+}
+
+.adl-controls {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 12px;
+}
+
+.adl-filter-pills {
+ display: flex;
+ gap: 4px;
+ background: rgba(255, 255, 255, 0.03);
+ border: 1px solid rgba(255, 255, 255, 0.06);
+ border-radius: 10px;
+ padding: 3px;
+}
+
+.adl-pill {
+ padding: 6px 16px;
+ border-radius: 8px;
+ border: none;
+ background: transparent;
+ color: rgba(255, 255, 255, 0.45);
+ font-size: 0.78rem;
+ font-weight: 500;
+ cursor: pointer;
+ transition: all 0.2s ease;
+}
+
+.adl-pill:hover {
+ background: rgba(255, 255, 255, 0.05);
+ color: rgba(255, 255, 255, 0.7);
+}
+
+.adl-pill.active {
+ background: rgba(var(--accent-rgb), 0.15);
+ color: rgb(var(--accent-light-rgb));
+ box-shadow: 0 2px 8px rgba(var(--accent-rgb), 0.15);
+}
+
+.adl-count {
+ font-size: 0.78rem;
+ color: rgba(255, 255, 255, 0.3);
+ white-space: nowrap;
+ font-variant-numeric: tabular-nums;
+}
+
+.adl-clear-btn {
+ padding: 5px 12px;
+ border-radius: 7px;
+ border: 1px solid rgba(255, 255, 255, 0.08);
+ background: rgba(255, 255, 255, 0.03);
+ color: rgba(255, 255, 255, 0.4);
+ font-size: 0.72rem;
+ font-weight: 500;
+ cursor: pointer;
+ transition: all 0.2s ease;
+ white-space: nowrap;
+}
+
+.adl-clear-btn:hover {
+ background: rgba(255, 255, 255, 0.06);
+ color: rgba(255, 255, 255, 0.7);
+ border-color: rgba(255, 255, 255, 0.12);
+}
+
+.adl-list {
+ display: flex;
+ flex-direction: column;
+ gap: 3px;
+}
+
+.adl-empty {
+ text-align: center;
+ padding: 64px 20px;
+ color: rgba(255, 255, 255, 0.2);
+ font-size: 0.9rem;
+}
+
+/* ---- Section Headers ---- */
+
+.adl-section-header {
+ font-size: 0.7rem;
+ font-weight: 600;
+ text-transform: uppercase;
+ letter-spacing: 0.08em;
+ color: rgba(255, 255, 255, 0.2);
+ padding: 12px 14px 4px;
+}
+
+/* ---- Download Row ---- */
+
+.adl-row {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+ padding: 10px 14px;
+ background: rgba(255, 255, 255, 0.018);
+ border: 1px solid rgba(255, 255, 255, 0.035);
+ border-radius: 10px;
+ transition: all 0.2s ease;
+}
+
+.adl-row:hover {
+ background: rgba(255, 255, 255, 0.04);
+ border-color: rgba(255, 255, 255, 0.07);
+}
+
+/* Active row accent glow */
+.adl-row.adl-row-active {
+ border-color: rgba(var(--accent-rgb), 0.18);
+ background: linear-gradient(135deg, rgba(var(--accent-rgb), 0.04) 0%, rgba(var(--accent-rgb), 0.01) 100%);
+ box-shadow: 0 0 12px rgba(var(--accent-rgb), 0.06);
+}
+
+/* Completed row subtle green */
+.adl-row.adl-row-completed {
+ border-color: rgba(34, 197, 94, 0.1);
+}
+
+/* Failed row subtle red */
+.adl-row.adl-row-failed {
+ border-color: rgba(239, 68, 68, 0.1);
+}
+
+.adl-row-art {
+ width: 44px;
+ height: 44px;
+ border-radius: 8px;
+ object-fit: cover;
+ flex-shrink: 0;
+ background: rgba(255, 255, 255, 0.05);
+ box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
+}
+
+.adl-row-art-empty {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ background: linear-gradient(135deg, rgba(255, 255, 255, 0.04) 0%, rgba(255, 255, 255, 0.02) 100%);
+}
+
+.adl-row-info {
+ flex: 1;
+ min-width: 0;
+}
+
+.adl-row-title {
+ font-size: 0.88rem;
+ font-weight: 600;
+ color: #fff;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ line-height: 1.3;
+}
+
+.adl-row-meta {
+ font-size: 0.78rem;
+ color: rgba(255, 255, 255, 0.4);
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ line-height: 1.3;
+}
+
+.adl-row-batch {
+ font-size: 0.7rem;
+ color: rgba(var(--accent-rgb), 0.55);
+ margin-top: 1px;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+
+.adl-row-error {
+ font-size: 0.7rem;
+ color: rgba(239, 68, 68, 0.65);
+ margin-top: 1px;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+
+/* ---- Status Badges ---- */
+
+.adl-row-status {
+ font-size: 0.76rem;
+ font-weight: 500;
+ white-space: nowrap;
+ min-width: 95px;
+ text-align: right;
+ flex-shrink: 0;
+ display: flex;
+ align-items: center;
+ justify-content: flex-end;
+ gap: 4px;
+}
+
+.adl-row-status.active {
+ color: rgb(var(--accent-light-rgb));
+}
+
+.adl-row-status.queued {
+ color: rgba(255, 255, 255, 0.3);
+}
+
+.adl-row-status.completed {
+ color: #22c55e;
+}
+
+.adl-row-status.failed {
+ color: #ef4444;
+}
+
+.adl-row-status.cancelled {
+ color: rgba(255, 255, 255, 0.2);
+ text-decoration: line-through;
+ text-decoration-color: rgba(255, 255, 255, 0.1);
+}
+
+/* Status dot indicator */
+.adl-status-dot {
+ width: 7px;
+ height: 7px;
+ border-radius: 50%;
+ flex-shrink: 0;
+}
+
+.adl-status-dot.active {
+ background: rgb(var(--accent-rgb));
+ box-shadow: 0 0 8px rgba(var(--accent-rgb), 0.5);
+ animation: adlPulse 1.5s ease infinite;
+}
+
+.adl-status-dot.queued { background: rgba(255, 255, 255, 0.2); }
+.adl-status-dot.completed { background: #22c55e; box-shadow: 0 0 6px rgba(34, 197, 94, 0.3); }
+.adl-status-dot.failed { background: #ef4444; box-shadow: 0 0 6px rgba(239, 68, 68, 0.3); }
+.adl-status-dot.cancelled { background: rgba(255, 255, 255, 0.15); }
+
+@keyframes adlPulse {
+ 0%, 100% { opacity: 1; }
+ 50% { opacity: 0.5; }
+}
+
+/* ---- Spinner ---- */
+
+.adl-spinner {
+ display: inline-block;
+ width: 12px;
+ height: 12px;
+ border: 1.5px solid rgba(var(--accent-rgb), 0.2);
+ border-top-color: rgb(var(--accent-rgb));
+ border-radius: 50%;
+ animation: adlSpin 0.6s linear infinite;
+ vertical-align: middle;
+}
+
+@keyframes adlSpin {
+ to { transform: rotate(360deg); }
+}
+
+/* ---- Nav Badge ---- */
+
+.dl-nav-badge {
+ position: absolute;
+ top: 4px;
+ right: 8px;
+ background: rgb(var(--accent-rgb));
+ color: #fff;
+ font-size: 0.6rem;
+ font-weight: 700;
+ min-width: 16px;
+ height: 16px;
+ line-height: 16px;
+ border-radius: 8px;
+ text-align: center;
+ padding: 0 4px;
+ box-shadow: 0 2px 6px rgba(var(--accent-rgb), 0.3);
+}
+
+/* ---- Responsive ---- */
+
+@media (max-width: 600px) {
+ .adl-container {
+ padding: 16px 12px;
+ }
+
+ .adl-controls {
+ flex-direction: column;
+ align-items: flex-start;
+ }
+
+ .adl-filter-pills {
+ flex-wrap: wrap;
+ }
+
+ .adl-row-art {
+ width: 36px;
+ height: 36px;
+ border-radius: 6px;
+ }
+
+ .adl-row-status {
+ min-width: 70px;
+ }
+}