/* Downloads page UI module. Grouped jobs, storage info, confirmed cancellation. */ (function (root) { 'use strict'; const actionButtons = typeof module !== 'undefined' ? require('./download-actions.js') : root.DownloadActions; 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, '''); } 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 = `

No active downloads

Saves in progress appear here with live status. Videos you save land in Saved.

Save videos for offline listening using the Save button on any track or playlist.

Calculating storage…
`; 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 = `
${escapeHtml(job.title || job.id)}
${escapeHtml(formatJobStatus(job))}
`; 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 = actionButtons.create(doc, 'pause', job.title || job.id); 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 = actionButtons.create(doc, 'resume', job.title || job.id); 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 = actionButtons.create(doc, 'retry', job.title || job.id); 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 = actionButtons.create(doc, 'cancel', job.title || job.id); 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);