Batches panel: Phase A visual upgrade (summary, segmented progress, ETA, live track)

Takes the Active Downloads batch panel from flat cards to a glanceable,
information-rich view:
- Sticky aggregate summary strip: 'N batches · X downloading · Y queued · speed · ~ETA'.
- Segmented progress bar per batch — proportional done (green) / failed (red) /
  active (accent, animated shimmer) / remaining, so the state reads at a glance
  instead of one dim fill.
- Colored stat chips (✓ done · ✗ failed · ↓ active · queued) + a per-batch ETA
  from a client-side completion-rate sampler (album bundles use the downloader's
  own speed/size). No backend changes — Phase A is frontend-only.
- 'Now downloading' line showing the live track on active batches.
- Expand chevron affordance (rotates when open); subtle phase tinting.
- Polished empty state with quick-start links (Search / Sync / Wishlist).

Card actions (filter / cancel / open-modal / expand) and the fade/history
behavior are unchanged. ETA/speed for non-bundle batches and a retry-failed
action are Phases B/C (backend).
This commit is contained in:
BoulderBadgeDad 2026-05-31 22:28:43 -07:00
parent e072b49138
commit f50e67ac9b
3 changed files with 216 additions and 8 deletions

View file

@ -2365,6 +2365,9 @@
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="9 18 15 12 9 6"/></svg>
</button>
</div>
<div class="adl-batch-summary" id="adl-batch-summary" style="display:none">
<!-- Live aggregate (batches · downloading · speed · ETA) rendered by JS -->
</div>
<div class="adl-batch-active" id="adl-batch-active">
<!-- Active batch cards rendered by JS -->
</div>

View file

@ -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 =
`<span class="adl-batch-summary-main">${parts.join(' · ')}</span>` +
(etaStr ? `<span class="adl-batch-summary-eta">${etaStr}</span>` : '');
}
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 = `<div class="adl-batch-empty">
<svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.2" style="opacity:0.25;margin-bottom:6px"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>
<div>No active batches</div>
<div style="font-size:0.7rem;margin-top:2px;opacity:0.5">Start a download from Search, Sync, or Wishlist</div>
<div class="adl-batch-empty-icon">
<svg width="34" height="34" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.3"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>
</div>
<div class="adl-batch-empty-title">Nothing downloading</div>
<div class="adl-batch-empty-sub">Batches show up here as they run.</div>
<div class="adl-batch-empty-links">
<a href="#" onclick="event.preventDefault(); navigateToPage('search')">Search</a>
<a href="#" onclick="event.preventDefault(); navigateToPage('sync')">Sync</a>
<a href="#" onclick="event.preventDefault(); navigateToPage('wishlist')">Wishlist</a>
</div>
</div>`;
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)
? `<div class="adl-batch-card-now"><span class="adl-batch-now-icon">↓</span> ${_adlEsc(nowTrack.title)}</div>`
: '';
// Stat chips + ETA line.
const chips = [];
if (batch.completed) chips.push(`<span class="adl-chip adl-chip-done">✓ ${batch.completed}</span>`);
if (batch.failed) chips.push(`<span class="adl-chip adl-chip-fail">✗ ${batch.failed}</span>`);
if (batch.active) chips.push(`<span class="adl-chip adl-chip-active">↓ ${batch.active}</span>`);
if (batch.queued) chips.push(`<span class="adl-chip adl-chip-queued">${batch.queued} queued</span>`);
const etaText = _adlBatchEta(batch);
const statLine = (chips.length || etaText)
? `<div class="adl-batch-statline"><div class="adl-batch-chips">${chips.join('')}</div>${etaText ? `<span class="adl-batch-eta">${_adlEsc(etaText)}</span>` : ''}</div>`
: '';
html += `<div class="${cardClasses.join(' ')}" style="${colorStyle}${fadeStyle}" data-batch-id="${batch.batch_id}" onclick="_adlToggleBatch('${batch.batch_id}')">
<div class="adl-batch-card-top">
${thumbHtml}
<div class="adl-batch-card-info">
<div class="adl-batch-card-name adl-batch-card-link" onclick="event.stopPropagation(); _adlOpenBatchModal('${batch.batch_id}', '${playlistId}', '${_adlEsc(batch.batch_name || 'Download')}')" title="Open download modal">${_adlEsc(batch.batch_name || 'Download')}</div>
<div class="adl-batch-card-meta">${phaseIcon}${phaseText}</div>
${nowHtml}
</div>
${sourceBadge}
<div class="adl-batch-card-actions">
@ -2709,11 +2829,17 @@ function _adlRenderBatchPanel() {
${!isTerminal ? `<button class="adl-batch-card-cancel" onclick="event.stopPropagation(); _adlCancelBatch('${batch.batch_id}')" title="Cancel batch">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
</button>` : ''}
<span class="adl-batch-card-chevron" aria-hidden="true">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="6 9 12 15 18 9"/></svg>
</span>
</div>
</div>
<div class="adl-batch-progress">
<div class="adl-batch-progress-fill${hasFailed ? ' has-failed' : ''}" style="width:${pct}%"></div>
<div class="adl-batch-segbar">
<div class="adl-batch-seg seg-done" style="width:${segDone}%"></div>
<div class="adl-batch-seg seg-fail" style="width:${segFail}%"></div>
<div class="adl-batch-seg seg-active${isActive ? ' shimmer' : ''}" style="width:${segActive}%"></div>
</div>
${statLine}
<div class="adl-batch-tracks">${tracksHtml}</div>
</div>`;
}

View file

@ -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;