Clarify download states and make the empty page useful

This commit is contained in:
Jonathan Sykes
2026-10-03 16:05:42 +08:00
parent 0374e19396
commit cf9b6f4133
9 changed files with 458 additions and 92 deletions

304
frontend/downloads-page.js Normal file
View File

@@ -0,0 +1,304 @@
/* Downloads page UI module. Grouped jobs, storage info, confirmed cancellation. */
(function (root) {
'use strict';
const bytes = n => {
if (typeof n !== 'number' || isNaN(n) || n <= 0) return '0 MB';
return n >= 1073741824 ? `${(n / 1073741824).toFixed(1)} GB` : `${(n / 1048576).toFixed(1)} MB`;
};
function escapeHtml(str) {
return String(str || '')
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
function formatJobStatus(job) {
if (job.status === 'failed') {
return job.error ? `Failed: ${job.error}` : 'Save failed';
}
if (job.status === 'paused') {
return 'Paused — resume when you’re ready';
}
if (job.status === 'queued') {
return 'Waiting for a free slot…';
}
const p = job.progress;
if (p) {
if (p.phase === 'waiting') return 'Waiting for a free slot…';
if (p.phase === 'preparing') return `Preparing on server… ${Math.round((p.elapsed || 0) / 1000)} s`;
if (p.phase === 'verifying') return 'Verifying…';
if (p.total) {
return `${(p.received / 1048576).toFixed(1)} / ${(p.total / 1048576).toFixed(1)} MB`;
}
}
return 'Saving for offline…';
}
function groupJobs(jobs) {
const groups = {
active: [],
queued: [],
paused: [],
failed: []
};
for (const job of jobs || []) {
const st = job.status || 'paused';
if (groups[st]) {
groups[st].push(job);
} else {
groups.paused.push(job);
}
}
return groups;
}
function confirmCancel(job, options) {
const title = job.title || job.id;
const message = `Cancel saving “${title}”?`;
const subtext = 'The part already downloaded will be deleted.';
if (options.showModal && typeof options.showModal === 'function') {
const body = options.doc ? options.doc.createTextNode(subtext) : subtext;
options.showModal(message, body, [
{ label: 'Keep', onClick: options.closeModal || (() => {}) },
{
label: 'Cancel save',
danger: true,
onClick: () => {
options.onAction(job.id, 'cancel');
if (options.closeModal) options.closeModal();
if (options.refresh) options.refresh();
}
}
]);
} else if (typeof root.confirm === 'function') {
if (root.confirm(`${message}\n${subtext}`)) {
options.onAction(job.id, 'cancel');
if (options.refresh) options.refresh();
}
} else {
options.onAction(job.id, 'cancel');
if (options.refresh) options.refresh();
}
}
let watcher;
function render(container, options = {}) {
clearInterval(watcher);
if (!container) return;
const doc = container.ownerDocument || document;
const downloadsApi = options.downloadsApi || root.Downloads;
const jobs = options.jobs || (downloadsApi && downloadsApi.getJobs ? downloadsApi.getJobs() : []);
const onAction = options.onAction || (downloadsApi && downloadsApi.action ? downloadsApi.action.bind(downloadsApi) : () => {});
const onNavigateSaved = options.onNavigateSaved || (() => {});
container.replaceChildren();
if (options.isActive && options.refresh) {
const state = items => JSON.stringify(items.map(j => [j.id, j.status, j.error]));
const signature = state(jobs);
watcher = setInterval(() => {
if (!options.isActive()) { clearInterval(watcher); return; }
if (signature !== state(downloadsApi.getJobs())) options.refresh();
}, 1000);
}
const query = (options.filter || '').toLowerCase();
const visibleJobs = jobs.filter(job => `${job.title || job.id} ${job.channel || ''}`.toLowerCase().includes(query));
if (jobs.length && !visibleJobs.length) {
const message = doc.createElement('p'); message.className = 'status'; message.textContent = 'No downloads match your filter.'; container.append(message); return;
}
if (!jobs.length) {
// Calm SVG Empty State
const empty = doc.createElement('div');
empty.className = 'empty-state dl-empty-state';
empty.innerHTML = `
<div class="empty-icon dl-empty-icon-wrapper" aria-hidden="true">
<svg class="dl-empty-svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path>
<polyline points="7 10 12 15 17 10"></polyline>
<line x1="12" y1="15" x2="12" y2="3"></line>
</svg>
</div>
<h3 class="empty-title">No active downloads</h3>
<p class="empty-desc">Saves in progress appear here with live status. Videos you save land in <strong>Saved</strong>.</p>
<p class="dl-how-to-save">Save videos for offline listening using the Save button on any track or playlist.</p>
<div class="dl-storage-note">Calculating storage…</div>
`;
const cta = doc.createElement('button');
cta.type = 'button';
cta.className = 'empty-cta dl-saved-cta';
cta.textContent = 'View saved videos';
cta.addEventListener('click', onNavigateSaved);
empty.appendChild(cta);
container.appendChild(empty);
if (downloadsApi && downloadsApi.getFiles) {
Promise.resolve(downloadsApi.getFiles()).then(files => {
const storageNote = empty.querySelector('.dl-storage-note');
if (storageNote && files) {
const playlists = downloadsApi.getPlaylists ? downloadsApi.getPlaylists() : [];
const stats = downloadsApi.breakdown ? downloadsApi.breakdown(files, playlists) : null;
const totalStr = stats ? bytes(stats.total) : bytes(files.reduce((a, b) => a + (b.size || 0), 0));
storageNote.textContent = `Device storage used by saved media: ${totalStr}`;
}
}).catch(() => {
const storageNote = empty.querySelector('.dl-storage-note');
if (storageNote) storageNote.textContent = '';
});
}
return;
}
// Summary Header
const summary = doc.createElement('div');
summary.className = 'saved-summary dl-summary';
const totalCount = jobs.length;
summary.textContent = `${totalCount} download${totalCount === 1 ? '' : 's'}`;
const savedLinkBtn = doc.createElement('button');
savedLinkBtn.type = 'button';
savedLinkBtn.className = 'dl-saved-link';
savedLinkBtn.textContent = 'View saved';
savedLinkBtn.addEventListener('click', onNavigateSaved);
summary.appendChild(savedLinkBtn);
container.appendChild(summary);
// Grouped Consistent Rows
const groups = groupJobs(visibleJobs);
const sections = [
{ key: 'active', title: 'Active downloads' },
{ key: 'queued', title: 'Queued' },
{ key: 'paused', title: 'Paused' },
{ key: 'failed', title: 'Failed' }
];
for (const sec of sections) {
const secJobs = groups[sec.key];
if (!secJobs || !secJobs.length) continue;
const groupDiv = doc.createElement('div');
groupDiv.className = `dl-group dl-group-${sec.key}`;
const groupHeading = doc.createElement('h4');
groupHeading.className = 'dl-group-title';
groupHeading.textContent = `${sec.title} (${secJobs.length})`;
groupDiv.appendChild(groupHeading);
for (const job of secJobs) {
const isPaused = job.status === 'paused';
const isFailed = job.status === 'failed';
const isActive = job.status === 'active' || job.status === 'queued';
const row = doc.createElement('div');
row.className = `card downloading dl-row ${isPaused ? 'dl-paused' : ''} ${isFailed ? 'dl-failed' : ''} ${isActive ? 'dl-active' : ''}`;
row.dataset.id = job.id;
const thumbUrl = job.thumbnail || (options.thumbUrlFor ? options.thumbUrlFor(job.id, job) : '');
const p = job.progress;
const pct = p && p.total ? Math.max(1, Math.min(100, (p.received / p.total) * 100)) : null;
row.innerHTML = `
<div class="thumb">
<img loading="lazy" decoding="async" src="${escapeHtml(thumbUrl)}" alt="" />
<div class="dl-progress"><div class="dl-bar ${pct !== null ? 'determinate' : ''}" style="${pct !== null ? 'width:' + pct + '%' : ''}"></div></div>
</div>
<div class="card-info">
<div class="card-title">${escapeHtml(job.title || job.id)}</div>
<div class="card-channel">${escapeHtml(formatJobStatus(job))}</div>
</div>
`;
const acts = doc.createElement('div');
acts.className = 'dl-actions';
if (isActive) {
const pauseBtn = doc.createElement('button');
pauseBtn.type = 'button';
pauseBtn.className = 'dl-act';
pauseBtn.textContent = 'Pause';
pauseBtn.setAttribute('aria-label', `Pause ${job.title || job.id}`);
pauseBtn.onclick = (e) => {
e.stopPropagation();
onAction(job.id, 'pause');
if (options.refresh) options.refresh();
};
acts.appendChild(pauseBtn);
} else if (isPaused) {
const resumeBtn = doc.createElement('button');
resumeBtn.type = 'button';
resumeBtn.className = 'dl-act';
resumeBtn.textContent = 'Resume';
resumeBtn.setAttribute('aria-label', `Resume ${job.title || job.id}`);
resumeBtn.onclick = (e) => {
e.stopPropagation();
onAction(job.id, 'resume');
if (options.refresh) options.refresh();
};
acts.appendChild(resumeBtn);
} else if (isFailed) {
const retryBtn = doc.createElement('button');
retryBtn.type = 'button';
retryBtn.className = 'dl-act';
retryBtn.textContent = 'Retry';
retryBtn.setAttribute('aria-label', `Retry ${job.title || job.id}`);
retryBtn.onclick = (e) => {
e.stopPropagation();
onAction(job.id, 'retry');
if (options.refresh) options.refresh();
};
acts.appendChild(retryBtn);
}
const cancelBtn = doc.createElement('button');
cancelBtn.type = 'button';
cancelBtn.className = 'dl-act danger';
cancelBtn.textContent = 'Cancel';
cancelBtn.setAttribute('aria-label', `Cancel ${job.title || job.id}`);
cancelBtn.onclick = (e) => {
e.stopPropagation();
confirmCancel(job, {
showModal: options.showModal,
closeModal: options.closeModal,
doc,
onAction,
refresh: options.refresh
});
};
acts.appendChild(cancelBtn);
row.querySelector('.card-info').appendChild(acts);
groupDiv.appendChild(row);
}
container.appendChild(groupDiv);
}
if (groups.paused.length && root.OPFS && root.OPFS.listPartials) {
root.OPFS.listPartials().then(parts => {
for (const p of parts) {
const row = container.querySelector(`.card.dl-paused[data-id="${CSS.escape(p.id)}"]`);
if (!row || !p.size) continue;
const bar = row.querySelector('.dl-bar');
if (bar) {
bar.classList.add('determinate');
bar.style.width = Math.max(1, Math.min(100, (p.received / p.size) * 100)) + '%';
}
const ch = row.querySelector('.card-channel');
if (ch) {
ch.textContent = `Paused at ${(p.received / 1048576).toFixed(1)} / ${(p.size / 1048576).toFixed(1)} MB — resume when you’re ready`;
}
}
}).catch(() => {});
}
}
const api = { render, groupJobs, formatJobStatus, escapeHtml, bytes };
if (typeof module !== 'undefined') module.exports = api; else root.DownloadsPage = api;
})(typeof window !== 'undefined' ? window : globalThis);