308 lines
12 KiB
JavaScript
308 lines
12 KiB
JavaScript
/* Downloads page UI module. Grouped jobs, storage info, confirmed cancellation. */
|
||
(function (root) {
|
||
'use strict';
|
||
const errors = typeof module !== 'undefined' ? require('./download-errors.js') : root.DownloadErrors;
|
||
|
||
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, '&')
|
||
.replace(/</g, '<')
|
||
.replace(/>/g, '>')
|
||
.replace(/"/g, '"')
|
||
.replace(/'/g, ''');
|
||
}
|
||
|
||
function formatJobStatus(job) {
|
||
if (job.status === 'failed') {
|
||
return job.error ? `Failed: ${errors.message(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>
|
||
`;
|
||
|
||
if (isFailed && job.error) errors.appendDetails(row.querySelector('.card-info'), job.error);
|
||
|
||
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);
|