diff --git a/webui/index.html b/webui/index.html index d56533e0..a17a9b9e 100644 --- a/webui/index.html +++ b/webui/index.html @@ -2365,6 +2365,9 @@ +
diff --git a/webui/static/pages-extra.js b/webui/static/pages-extra.js index 6444db46..d0229420 100644 --- a/webui/static/pages-extra.js +++ b/webui/static/pages-extra.js @@ -2193,6 +2193,85 @@ function _getBatchColor(batchId) { return _batchColorMap[batchId]; } +// Per-batch progress samples for a client-side ETA (no backend timing needed +// for Phase A). batch_id -> [{t: ms, done: int}], capped to the recent window. +const _adlRateSamples = {}; +const _ADL_RATE_WINDOW = 8; + +function _adlSampleRate(batchId, done) { + const arr = _adlRateSamples[batchId] || (_adlRateSamples[batchId] = []); + const now = Date.now(); + const last = arr[arr.length - 1]; + if (!last || last.done !== done) arr.push({ t: now, done }); + while (arr.length > _ADL_RATE_WINDOW) arr.shift(); + // tracks/sec over the sampled window + if (arr.length < 2) return 0; + const first = arr[0]; + const dt = (arr[arr.length - 1].t - first.t) / 1000; + const dd = arr[arr.length - 1].done - first.done; + return dt > 0 && dd > 0 ? dd / dt : 0; +} + +function _adlFmtDuration(sec) { + if (!sec || sec < 0 || !isFinite(sec)) return ''; + if (sec < 60) return `${Math.round(sec)}s`; + if (sec < 3600) return `${Math.round(sec / 60)}m`; + return `${Math.floor(sec / 3600)}h ${Math.round((sec % 3600) / 60)}m`; +} + +// ETA string for a batch's stat line. Album bundles use the downloader's own +// speed/size; track batches use the client-side completion rate. +function _adlBatchEta(batch) { + if (batch.phase === 'album_downloading') { + const ab = batch.album_bundle || {}; + const bits = []; + if (ab.speed) bits.push(ab.speed); + if (ab.downloaded && ab.size) bits.push(`${ab.downloaded} / ${ab.size}`); + return bits.join(' · '); + } + if (batch.phase !== 'downloading') return ''; + const total = batch.total || 0; + const done = (batch.completed || 0) + (batch.failed || 0); + const remaining = total - done; + if (remaining <= 0) return ''; + const rate = _adlSampleRate(batch.batch_id, done); // tracks/sec + if (rate <= 0) return ''; + return `~${_adlFmtDuration(remaining / rate)} left`; +} + +// Glanceable aggregate strip atop the panel: batches · downloading · queued · +// speed · ETA. Hidden when nothing is active. +function _adlRenderBatchSummary(activeBatches) { + const el = document.getElementById('adl-batch-summary'); + if (!el) return; + if (!activeBatches.length) { el.style.display = 'none'; return; } + + let downloading = 0, queued = 0, remaining = 0, rate = 0, bundleSpeed = ''; + for (const b of activeBatches) { + downloading += (b.active || 0); + queued += (b.queued || 0); + if (b.phase === 'downloading') { + const done = (b.completed || 0) + (b.failed || 0); + remaining += Math.max(0, (b.total || 0) - done); + rate += _adlSampleRate(b.batch_id, done); + } + if (b.phase === 'album_downloading' && b.album_bundle && b.album_bundle.speed && !bundleSpeed) { + bundleSpeed = b.album_bundle.speed; + } + } + + const parts = [`${activeBatches.length} batch${activeBatches.length === 1 ? '' : 'es'}`]; + if (downloading) parts.push(`${downloading} downloading`); + if (queued) parts.push(`${queued} queued`); + if (bundleSpeed) parts.push(_adlEsc(bundleSpeed)); + const etaStr = (rate > 0 && remaining > 0) ? `~${_adlFmtDuration(remaining / rate)} left` : ''; + + el.style.display = ''; + el.innerHTML = + `${parts.join(' · ')}` + + (etaStr ? `${etaStr}` : ''); +} + function loadActiveDownloadsPage() { _adlFetch(); _adlFetchBatchHistory(); @@ -2553,17 +2632,27 @@ function _adlRenderBatchPanel() { return elapsed < _BATCH_FADE_SECONDS; }); + const activeBatches = visibleBatches.filter(b => b.phase !== 'complete' && b.phase !== 'cancelled' && b.phase !== 'error'); + // Update header with count if (headerTitle) { - const activeCount = visibleBatches.filter(b => b.phase !== 'complete' && b.phase !== 'cancelled' && b.phase !== 'error').length; - headerTitle.textContent = activeCount > 0 ? `Batches (${activeCount})` : 'Batches'; + headerTitle.textContent = activeBatches.length > 0 ? `Batches (${activeBatches.length})` : 'Batches'; } + _adlRenderBatchSummary(activeBatches); + if (visibleBatches.length === 0) { container.innerHTML = `
- -
No active batches
-
Start a download from Search, Sync, or Wishlist
+
+ +
+
Nothing downloading
+
Batches show up here as they run.
+
`; return; } @@ -2687,19 +2776,50 @@ function _adlRenderBatchPanel() { } } - const cardClasses = ['adl-batch-card']; + const cardClasses = ['adl-batch-card', `phase-${batch.phase}`]; if (isExpanded) cardClasses.push('expanded'); if (isActive) cardClasses.push('active-glow'); if (isFiltered) cardClasses.push('filtered'); const playlistId = _adlEsc(batch.playlist_id || ''); + // Segmented progress: done (green) / failed (red) / active (accent) of + // total; the remaining width is the dim bar background (queued). + let segDone = 0, segFail = 0, segActive = 0; + if (batch.phase === 'album_downloading') { + segActive = bundleProgress; // one release downloading + } else { + segDone = Math.max(0, Math.min(100, (batch.completed / total) * 100)); + segFail = Math.max(0, Math.min(100 - segDone, (batch.failed / total) * 100)); + segActive = Math.max(0, Math.min(100 - segDone - segFail, ((batch.active || 0) / total) * 100)); + } + + // "Now downloading" — the live track on active batches. + const nowTrack = isActive + ? (batchTracks.find(t => t.status === 'downloading') || batchTracks.find(t => t.status === 'searching')) + : null; + const nowHtml = (nowTrack && nowTrack.title) + ? `
${_adlEsc(nowTrack.title)}
` + : ''; + + // Stat chips + ETA line. + const chips = []; + if (batch.completed) chips.push(`✓ ${batch.completed}`); + if (batch.failed) chips.push(`✗ ${batch.failed}`); + if (batch.active) chips.push(`↓ ${batch.active}`); + if (batch.queued) chips.push(`${batch.queued} queued`); + const etaText = _adlBatchEta(batch); + const statLine = (chips.length || etaText) + ? `
${chips.join('')}
${etaText ? `${_adlEsc(etaText)}` : ''}
` + : ''; + html += `
${thumbHtml}
${phaseIcon}${phaseText}
+ ${nowHtml}
${sourceBadge}
@@ -2709,11 +2829,17 @@ function _adlRenderBatchPanel() { ${!isTerminal ? `` : ''} +
-
-
+
+
+
+
+ ${statLine}
${tracksHtml}
`; } diff --git a/webui/static/style.css b/webui/static/style.css index 80373e48..51aaaddb 100644 --- a/webui/static/style.css +++ b/webui/static/style.css @@ -59147,6 +59147,85 @@ body.reduce-effects *::after { background: linear-gradient(90deg, rgba(var(--accent-rgb), 0.7), #ef4444); } +/* ── Batches panel: Phase-A upgrade ─────────────────────────────────────── */ + +/* Glanceable aggregate strip */ +.adl-batch-summary { + display: flex; align-items: center; justify-content: space-between; gap: 8px; + padding: 7px 11px; margin: 0 0 8px; + background: rgba(var(--accent-rgb), 0.08); + border: 1px solid rgba(var(--accent-rgb), 0.18); + border-radius: 9px; + font-size: 0.72rem; +} +.adl-batch-summary-main { color: rgba(255, 255, 255, 0.85); font-weight: 600; letter-spacing: 0.01em; } +.adl-batch-summary-eta { color: rgb(var(--accent-rgb)); font-weight: 700; white-space: nowrap; } + +/* Segmented progress bar */ +.adl-batch-segbar { + display: flex; height: 4px; margin-top: 9px; border-radius: 3px; overflow: hidden; + background: rgba(255, 255, 255, 0.07); +} +.adl-batch-seg { height: 100%; transition: width 0.4s ease; } +.adl-batch-seg.seg-done { background: #22c55e; } +.adl-batch-seg.seg-fail { background: #ef4444; } +.adl-batch-seg.seg-active { background: rgba(var(--accent-rgb), 0.85); } +.adl-batch-seg.seg-active.shimmer { + background-image: linear-gradient(90deg, + rgba(var(--accent-rgb), 0.55) 0%, rgba(var(--accent-rgb), 1) 50%, rgba(var(--accent-rgb), 0.55) 100%); + background-size: 200% 100%; + animation: adlSegShimmer 1.4s linear infinite; +} +@keyframes adlSegShimmer { 0% { background-position: 200% 0; } 100% { background-position: -200% 0; } } + +/* Stat chips + ETA */ +.adl-batch-statline { + display: flex; align-items: center; justify-content: space-between; gap: 8px; margin-top: 7px; +} +.adl-batch-chips { display: flex; flex-wrap: wrap; gap: 5px; } +.adl-chip { + font-size: 0.64rem; font-weight: 700; padding: 1px 6px; border-radius: 999px; + border: 1px solid currentColor; line-height: 1.5; white-space: nowrap; +} +.adl-chip-done { color: #22c55e; } +.adl-chip-fail { color: #ef4444; } +.adl-chip-active { color: rgb(var(--accent-rgb)); } +.adl-chip-queued { color: rgba(255, 255, 255, 0.4); } +.adl-batch-eta { font-size: 0.66rem; color: rgba(255, 255, 255, 0.55); white-space: nowrap; } + +/* "Now downloading" line */ +.adl-batch-card-now { + display: flex; align-items: center; gap: 4px; margin-top: 3px; + font-size: 0.68rem; color: rgba(255, 255, 255, 0.55); + white-space: nowrap; overflow: hidden; text-overflow: ellipsis; +} +.adl-batch-now-icon { color: rgb(var(--accent-rgb)); font-weight: 700; } + +/* Expand chevron affordance */ +.adl-batch-card-chevron { + display: inline-flex; align-items: center; color: rgba(255, 255, 255, 0.3); + transition: transform 0.2s ease, color 0.2s ease; +} +.adl-batch-card:hover .adl-batch-card-chevron { color: rgba(255, 255, 255, 0.55); } +.adl-batch-card.expanded .adl-batch-card-chevron { transform: rotate(180deg); } + +/* Phase tints — subtle whole-card cue */ +.adl-batch-card.phase-complete { opacity: 0.82; } +.adl-batch-card.phase-error, +.adl-batch-card.phase-cancelled { opacity: 0.7; } + +/* Polished empty state */ +.adl-batch-empty-icon { color: rgb(var(--accent-rgb)); opacity: 0.4; margin-bottom: 8px; } +.adl-batch-empty-title { font-size: 0.82rem; font-weight: 600; color: rgba(255, 255, 255, 0.7); } +.adl-batch-empty-sub { font-size: 0.7rem; margin-top: 3px; color: rgba(255, 255, 255, 0.4); } +.adl-batch-empty-links { display: flex; gap: 12px; justify-content: center; margin-top: 12px; } +.adl-batch-empty-links a { + font-size: 0.72rem; font-weight: 600; color: rgb(var(--accent-rgb)); text-decoration: none; + padding: 4px 10px; border: 1px solid rgba(var(--accent-rgb), 0.3); border-radius: 8px; + transition: background 0.15s ease; +} +.adl-batch-empty-links a:hover { background: rgba(var(--accent-rgb), 0.12); } + /* Expanded tracks list */ .adl-batch-tracks { display: none;