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:
parent
e072b49138
commit
f50e67ac9b
3 changed files with 216 additions and 8 deletions
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>`;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Reference in a new issue