Files
ytplayer/frontend/downloads-page.js
2026-10-04 02:48:08 +08:00

297 lines
12 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/* 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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
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 = 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);