Clarify download states and make the empty page useful
This commit is contained in:
119
frontend/app.js
119
frontend/app.js
@@ -337,13 +337,13 @@ const SaveQueue = (() => {
|
|||||||
const get = (id) => prog.get(id) || null;
|
const get = (id) => prog.get(id) || null;
|
||||||
|
|
||||||
function label(p) {
|
function label(p) {
|
||||||
if (!p) return '⏳ Saving for offline…';
|
if (!p) return 'Saving for offline…';
|
||||||
if (p.phase === 'waiting') return '⏳ Waiting for a free slot…';
|
if (p.phase === 'waiting') return 'Waiting for a free slot…';
|
||||||
const mb = (n) => (n / 1048576).toFixed(n >= 1048576 * 100 ? 0 : 1);
|
const mb = (n) => (n / 1048576).toFixed(n >= 1048576 * 100 ? 0 : 1);
|
||||||
if (p.phase === 'preparing') return `⏳ Preparing on server… ${Math.round((p.elapsed || 0) / 1000)} s`;
|
if (p.phase === 'preparing') return `Preparing on server… ${Math.round((p.elapsed || 0) / 1000)} s`;
|
||||||
if (p.phase === 'verifying') return '✓ Verifying…';
|
if (p.phase === 'verifying') return 'Verifying…';
|
||||||
if (p.total) return `⬇ ${mb(p.received)} / ${mb(p.total)} MB`;
|
if (p.total) return `${mb(p.received)} / ${mb(p.total)} MB`;
|
||||||
return '⏳ Saving for offline…';
|
return 'Saving for offline…';
|
||||||
}
|
}
|
||||||
function paint(id) {
|
function paint(id) {
|
||||||
// Only the Downloads view's own rows: search/playlist cards also carry
|
// Only the Downloads view's own rows: search/playlist cards also carry
|
||||||
@@ -3769,7 +3769,11 @@ const Notes = (() => {
|
|||||||
try {
|
try {
|
||||||
const res = await fetch(`/api/notes/${encodeURIComponent(id)}/lyrics/web`, {
|
const res = await fetch(`/api/notes/${encodeURIComponent(id)}/lyrics/web`, {
|
||||||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ profile: profileName(), overwrite }),
|
body: JSON.stringify((() => {
|
||||||
|
const m = current && current.meta && current.meta.id === id ? current.meta : {};
|
||||||
|
return { profile: profileName(), overwrite, title: m.title || undefined,
|
||||||
|
artist: m.artist || m.channel || m.uploader || undefined, duration: m.duration || undefined };
|
||||||
|
})()),
|
||||||
});
|
});
|
||||||
const j = await res.json().catch(() => null);
|
const j = await res.json().catch(() => null);
|
||||||
if (!j || !j.ok) { toast('⚠ ' + ((j && j.error) || `HTTP ${res.status}`), { duration: 5000 }); setStatus(''); return; }
|
if (!j || !j.ok) { toast('⚠ ' + ((j && j.error) || `HTTP ${res.status}`), { duration: 5000 }); setStatus(''); return; }
|
||||||
@@ -9291,94 +9295,29 @@ function renderDownloads() {
|
|||||||
els.status.classList.add('hidden');
|
els.status.classList.add('hidden');
|
||||||
const c = els.cards;
|
const c = els.cards;
|
||||||
c.innerHTML = '';
|
c.innerHTML = '';
|
||||||
|
$('listFilterBar').classList.toggle('hidden', !Downloads.getJobs().length);
|
||||||
|
$('batchBar').classList.add('hidden');
|
||||||
|
c.classList.remove('select-mode');
|
||||||
|
|
||||||
const active = [...downloadMeta.values()];
|
if (window.DownloadsPage && window.DownloadsPage.render) {
|
||||||
const paused = SaveQueue.paused();
|
window.DownloadsPage.render(c, {
|
||||||
if (!active.length && !paused.length) {
|
downloadsApi: window.Downloads,
|
||||||
const empty = document.createElement('div');
|
filter: listFilter,
|
||||||
empty.className = 'empty-state';
|
isActive: () => view.type === 'downloads',
|
||||||
empty.innerHTML = `
|
showModal: typeof showModal === 'function' ? showModal : null,
|
||||||
<div class="empty-icon">⬇</div>
|
closeModal: typeof closeModal === 'function' ? closeModal : null,
|
||||||
<h3 class="empty-title">No active downloads</h3>
|
onNavigateSaved: () => { view = { type: 'saved' }; render(); },
|
||||||
<p class="empty-desc">Saves in progress show here with live status. Finished videos land in <strong>Saved</strong>.</p>`;
|
thumbUrlFor: typeof thumbUrlFor === 'function' ? thumbUrlFor : null,
|
||||||
const cta = document.createElement('button');
|
refresh: renderList
|
||||||
cta.className = 'empty-cta';
|
});
|
||||||
cta.textContent = '💾 View saved videos';
|
|
||||||
cta.addEventListener('click', () => { view = { type: 'saved' }; render(); });
|
|
||||||
empty.appendChild(cta);
|
|
||||||
c.appendChild(empty);
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const note = document.createElement('div');
|
const jobs = window.Downloads && window.Downloads.getJobs ? window.Downloads.getJobs() : [];
|
||||||
note.className = 'saved-summary';
|
jobs.filter(j => j.status === 'active' || j.status === 'queued').forEach(v => {
|
||||||
const n = active.length + paused.length;
|
if (typeof SaveQueue !== 'undefined' && SaveQueue.progress) {
|
||||||
note.innerHTML = `<span class="saved-total">${n}</span><span class="saved-sub">download${n === 1 ? '' : 's'}${paused.length ? ` · ${paused.length} paused` : ' in progress'}</span>`;
|
|
||||||
c.appendChild(note);
|
|
||||||
|
|
||||||
const rowFor = (v, isPaused) => {
|
|
||||||
const row = document.createElement('div');
|
|
||||||
row.className = 'card downloading dl-row' + (isPaused ? ' dl-paused' : '');
|
|
||||||
row.dataset.id = v.id;
|
|
||||||
row.innerHTML = `
|
|
||||||
<div class="thumb">
|
|
||||||
<img loading="lazy" decoding="async" src="${escapeHtml(v.thumbnail || thumbUrlFor(v.id, v) || '')}" alt="" />
|
|
||||||
<div class="dl-progress"><div class="dl-bar"></div></div>
|
|
||||||
</div>
|
|
||||||
<div class="card-info">
|
|
||||||
<div class="card-title"></div>
|
|
||||||
<div class="card-channel"></div>
|
|
||||||
</div>`;
|
|
||||||
row.querySelector('.card-title').textContent = v.title || v.id;
|
|
||||||
return row;
|
|
||||||
};
|
|
||||||
|
|
||||||
active.forEach((v) => {
|
|
||||||
c.appendChild(rowFor(v, false));
|
|
||||||
// paint the live label/bar now (the worker keeps updating it)
|
|
||||||
SaveQueue.progress(v.id, SaveQueue.get(v.id));
|
SaveQueue.progress(v.id, SaveQueue.get(v.id));
|
||||||
});
|
|
||||||
|
|
||||||
paused.forEach((v) => {
|
|
||||||
const row = rowFor(v, true);
|
|
||||||
row.querySelector('.card-channel').textContent = '⏸ Paused — continues when the connection is back';
|
|
||||||
const acts = document.createElement('div');
|
|
||||||
acts.className = 'dl-actions';
|
|
||||||
const resume = document.createElement('button');
|
|
||||||
resume.type = 'button';
|
|
||||||
resume.className = 'dl-act';
|
|
||||||
resume.textContent = '▶ Resume';
|
|
||||||
resume.onclick = (e) => { e.stopPropagation(); preload(v, { quiet: true }); renderList(); };
|
|
||||||
const cancel = document.createElement('button');
|
|
||||||
cancel.type = 'button';
|
|
||||||
cancel.className = 'dl-act danger';
|
|
||||||
cancel.textContent = 'Cancel';
|
|
||||||
cancel.onclick = (e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
showModal(`Cancel saving “${v.title || v.id}”?`, document.createTextNode('The part already downloaded will be deleted.'), [
|
|
||||||
{ label: 'Keep', onClick: closeModal },
|
|
||||||
{ label: 'Cancel save', danger: true, onClick: () => { SaveQueue.cancel(v.id); closeModal(); renderList(); } },
|
|
||||||
]);
|
|
||||||
};
|
|
||||||
acts.append(resume, cancel);
|
|
||||||
row.querySelector('.card-info').appendChild(acts);
|
|
||||||
c.appendChild(row);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Fill in how far each paused save got (read from the partial on disk).
|
|
||||||
if (paused.length && window.OPFS && window.OPFS.listPartials) {
|
|
||||||
window.OPFS.listPartials().then((parts) => {
|
|
||||||
for (const p of parts) {
|
|
||||||
const row = c.querySelector(`.card.dl-paused[data-id="${CSS.escape(p.id)}"]`);
|
|
||||||
if (!row || !p.size) continue;
|
|
||||||
const bar = row.querySelector('.dl-bar');
|
|
||||||
bar.classList.add('determinate');
|
|
||||||
bar.style.width = Math.max(1, Math.min(100, (p.received / p.size) * 100)) + '%';
|
|
||||||
row.querySelector('.card-channel').textContent =
|
|
||||||
`⏸ Paused at ${(p.received / 1048576).toFixed(0)} / ${(p.size / 1048576).toFixed(0)} MB — continues when the connection is back`;
|
|
||||||
}
|
|
||||||
}).catch(() => {});
|
|
||||||
}
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|||||||
304
frontend/downloads-page.js
Normal file
304
frontend/downloads-page.js
Normal 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, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
.replace(/'/g, ''');
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
50
frontend/downloads-page.test.js
Normal file
50
frontend/downloads-page.test.js
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
const { test } = require('node:test');
|
||||||
|
const assert = require('node:assert/strict');
|
||||||
|
const DownloadsPage = require('./downloads-page.js');
|
||||||
|
|
||||||
|
test('groupJobs categorizes active, queued, paused, and failed jobs correctly', () => {
|
||||||
|
const jobs = [
|
||||||
|
{ id: '1', title: 'Job 1', status: 'active' },
|
||||||
|
{ id: '2', title: 'Job 2', status: 'queued' },
|
||||||
|
{ id: '3', title: 'Job 3', status: 'paused' },
|
||||||
|
{ id: '4', title: 'Job 4', status: 'failed', error: 'Network error' }
|
||||||
|
];
|
||||||
|
const groups = DownloadsPage.groupJobs(jobs);
|
||||||
|
assert.equal(groups.active.length, 1);
|
||||||
|
assert.equal(groups.queued.length, 1);
|
||||||
|
assert.equal(groups.paused.length, 1);
|
||||||
|
assert.equal(groups.failed.length, 1);
|
||||||
|
assert.equal(groups.active[0].id, '1');
|
||||||
|
assert.equal(groups.failed[0].error, 'Network error');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('formatJobStatus produces plain text labels without emojis for all job states', () => {
|
||||||
|
assert.equal(DownloadsPage.formatJobStatus({ status: 'failed', error: 'Connection lost' }), 'Failed: Connection lost');
|
||||||
|
assert.equal(DownloadsPage.formatJobStatus({ status: 'failed' }), 'Save failed');
|
||||||
|
assert.equal(DownloadsPage.formatJobStatus({ status: 'paused' }), 'Paused — resume when you’re ready');
|
||||||
|
assert.equal(DownloadsPage.formatJobStatus({ status: 'queued' }), 'Waiting for a free slot…');
|
||||||
|
assert.equal(
|
||||||
|
DownloadsPage.formatJobStatus({ status: 'active', progress: { received: 10485760, total: 52428800 } }),
|
||||||
|
'10.0 / 50.0 MB'
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
DownloadsPage.formatJobStatus({ status: 'active', progress: { phase: 'preparing', elapsed: 3000 } }),
|
||||||
|
'Preparing on server… 3 s'
|
||||||
|
);
|
||||||
|
assert.equal(DownloadsPage.formatJobStatus({ status: 'active', progress: { phase: 'verifying' } }), 'Verifying…');
|
||||||
|
assert.equal(DownloadsPage.formatJobStatus({ status: 'active' }), 'Saving for offline…');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('bytes formatting handles bytes, MB, GB and invalid input safely', () => {
|
||||||
|
assert.equal(DownloadsPage.bytes(0), '0 MB');
|
||||||
|
assert.equal(DownloadsPage.bytes(10485760), '10.0 MB');
|
||||||
|
assert.equal(DownloadsPage.bytes(1073741824), '1.0 GB');
|
||||||
|
assert.equal(DownloadsPage.bytes(-100), '0 MB');
|
||||||
|
assert.equal(DownloadsPage.bytes(null), '0 MB');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('escapeHtml escapes dangerous HTML characters', () => {
|
||||||
|
assert.equal(DownloadsPage.escapeHtml('<script>alert("xss")</script>'), '<script>alert("xss")</script>');
|
||||||
|
assert.equal(DownloadsPage.escapeHtml('Tom & Jerry'), 'Tom & Jerry');
|
||||||
|
});
|
||||||
|
|
||||||
@@ -45,5 +45,11 @@
|
|||||||
return () => { alive = false; clearInterval(timer); clearInterval(diskTimer); };
|
return () => { alive = false; clearInterval(timer); clearInterval(diskTimer); };
|
||||||
} });
|
} });
|
||||||
}
|
}
|
||||||
const api = { configure, breakdown, bytes }; if (typeof module !== 'undefined') module.exports = api; else root.Downloads = api;
|
function getJobs() { return adapter && typeof adapter.jobs === 'function' ? adapter.jobs() : []; }
|
||||||
|
function action(id, act) { if (adapter && typeof adapter.action === 'function') return adapter.action(id, act); }
|
||||||
|
function getFiles() { return adapter && typeof adapter.files === 'function' ? adapter.files() : Promise.resolve([]); }
|
||||||
|
function getPlaylists() { return adapter && typeof adapter.playlists === 'function' ? adapter.playlists() : []; }
|
||||||
|
function getSlots() { return adapter && typeof adapter.slots === 'function' ? adapter.slots() : 4; }
|
||||||
|
function snapshot() { return { jobs: getJobs(), slots: getSlots(), playlists: getPlaylists() }; }
|
||||||
|
const api = { configure, breakdown, bytes, getJobs, action, getFiles, getPlaylists, getSlots, snapshot }; if (typeof module !== 'undefined') module.exports = api; else root.Downloads = api;
|
||||||
})(typeof window !== 'undefined' ? window : globalThis);
|
})(typeof window !== 'undefined' ? window : globalThis);
|
||||||
|
|||||||
@@ -30,6 +30,7 @@
|
|||||||
<link rel="stylesheet" href="settings-sections.css" />
|
<link rel="stylesheet" href="settings-sections.css" />
|
||||||
<link rel="stylesheet" href="lower-third.css" />
|
<link rel="stylesheet" href="lower-third.css" />
|
||||||
<link rel="stylesheet" href="car-mode.css" />
|
<link rel="stylesheet" href="car-mode.css" />
|
||||||
|
<link rel="stylesheet" href="offline-pages.css" />
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div class="app">
|
<div class="app">
|
||||||
@@ -658,6 +659,7 @@
|
|||||||
<script src="sw-update.js"></script>
|
<script src="sw-update.js"></script>
|
||||||
<script src="settings-sections.js"></script>
|
<script src="settings-sections.js"></script>
|
||||||
<script src="downloads.js"></script>
|
<script src="downloads.js"></script>
|
||||||
|
<script src="downloads-page.js"></script>
|
||||||
<script src="party-dj.js"></script>
|
<script src="party-dj.js"></script>
|
||||||
<script src="lyrics-window.js"></script>
|
<script src="lyrics-window.js"></script>
|
||||||
<script src="midi.js"></script>
|
<script src="midi.js"></script>
|
||||||
|
|||||||
23
frontend/offline-pages.css
Normal file
23
frontend/offline-pages.css
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
/* Downloads uses the same quiet row rhythm as the surrounding library. */
|
||||||
|
.dl-empty-state { max-width:440px; margin:24px auto; padding:24px 16px; }
|
||||||
|
.dl-empty-state .dl-empty-icon-wrapper { width:auto; height:auto; border:0; border-radius:0; background:none; color:var(--text-2); margin-bottom:16px; box-shadow:none; }
|
||||||
|
.dl-empty-svg { width:32px; height:32px; }
|
||||||
|
.dl-empty-state .empty-title { font-size:20px; color:var(--text); }
|
||||||
|
.dl-empty-state .empty-desc, .dl-how-to-save, .dl-storage-note { color:var(--text-2); font-size:14px; line-height:1.5; }
|
||||||
|
.dl-how-to-save { margin:8px 0 16px; }
|
||||||
|
.dl-storage-note { margin-bottom:16px; font-variant-numeric:tabular-nums; }
|
||||||
|
.dl-saved-cta, .dl-saved-link, .dl-row .dl-act { min-height:44px; min-width:44px; padding:0 14px; border:1px solid var(--line); border-radius:var(--radius-sm,8px); background:var(--bg-2); color:var(--text); font:inherit; cursor:pointer; }
|
||||||
|
.dl-saved-cta:hover, .dl-saved-link:hover, .dl-row .dl-act:hover { background:var(--bg-3); }
|
||||||
|
.dl-saved-cta:focus-visible, .dl-saved-link:focus-visible, .dl-row .dl-act:focus-visible { outline:2px solid var(--accent); outline-offset:3px; }
|
||||||
|
.dl-summary { display:flex; align-items:center; justify-content:space-between; gap:12px; padding:8px 0 16px; font-size:14px; color:var(--text-2); }
|
||||||
|
.dl-group { margin-bottom:20px; }
|
||||||
|
.dl-group-title { font-size:14px; font-weight:600; color:var(--text); margin:12px 0 6px; }
|
||||||
|
html[data-layout] .card.dl-row { display:grid; grid-template-columns:72px minmax(0,1fr); gap:12px; padding:12px 8px; }
|
||||||
|
html[data-layout] .dl-row .thumb { grid-column:1; grid-row:1; width:72px; align-self:start; margin-top:3px; }
|
||||||
|
html[data-layout] .dl-row .card-info { grid-column:2; grid-row:1; min-width:0; }
|
||||||
|
.dl-row .card-title { overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
|
||||||
|
.dl-row .card-channel { overflow-wrap:anywhere; color:var(--text-2); }
|
||||||
|
.dl-row .dl-actions { display:flex; flex-wrap:wrap; gap:8px; margin-top:8px; }
|
||||||
|
.dl-row .dl-act.danger { color:var(--accent); background:transparent; }
|
||||||
|
.dl-row .dl-act:disabled { opacity:.5; cursor:wait; }
|
||||||
|
@media(min-width:900px) { html[data-layout] .card.dl-row { grid-template-columns:88px minmax(0,1fr); } html[data-layout] .dl-row .thumb { width:88px; } }
|
||||||
@@ -85,6 +85,8 @@ const SHELL = [
|
|||||||
'/p2p-recv-worker.js',
|
'/p2p-recv-worker.js',
|
||||||
'/settings-sections.js',
|
'/settings-sections.js',
|
||||||
'/downloads.js',
|
'/downloads.js',
|
||||||
|
'/downloads-page.js',
|
||||||
|
'/offline-pages.css',
|
||||||
'/party-dj.js',
|
'/party-dj.js',
|
||||||
'/lyrics-window.js',
|
'/lyrics-window.js',
|
||||||
'/midi.js',
|
'/midi.js',
|
||||||
|
|||||||
2
playwright.offline.config.js
Normal file
2
playwright.offline.config.js
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
const { defineConfig } = require('@playwright/test');
|
||||||
|
module.exports = defineConfig({ ...require('./playwright.classic.config'), testMatch: /offline-pages\.spec\.js/ });
|
||||||
38
tests/offline-pages.spec.js
Normal file
38
tests/offline-pages.spec.js
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
const { test, expect } = require('@playwright/test');
|
||||||
|
const { openClassic } = require('./helpers/classic-fixture');
|
||||||
|
const capture = process.env.YTP_OFFLINE_CAPTURE;
|
||||||
|
async function fixture(page, layout, state) {
|
||||||
|
await openClassic(page);
|
||||||
|
await page.evaluate(({layout,state}) => {
|
||||||
|
data.playlists = [{id:'studio', name:'Studio', videos:Array.from({length:4},(_,i)=>({id:'preview'+String(i).padStart(4,'0'),title:i===2?'An extended conversation about making music and finding a little room to listen':'Studio session '+i,channel:'Preview',thumbnail:'/fixture-cover.svg'}))}];
|
||||||
|
cachedIds.clear(); downloading.clear(); downloadMeta.clear(); SaveQueue.resumeAll=async()=>{};
|
||||||
|
data.settings.layout = layout; applyAppearance();
|
||||||
|
API.cacheList = async () => ({ ok:true, items: state==='downloads-empty' ? [] : data.playlists[0].videos.slice(0,3).map((v,i)=>({id:v.id,size:104857600*(i+1)})), total:state==='downloads-empty'?0:629145600 });
|
||||||
|
if(state==='downloads-populated') {
|
||||||
|
const videos=data.playlists[0].videos.slice(0,4); const statuses=['active','queued','paused','failed'];
|
||||||
|
localStorage.setItem('ytpSaveQueue',JSON.stringify(Object.fromEntries(videos.map((v,i)=>[v.id,{...v,at:Date.now(),status:statuses[i],error:i===3?'Connection refused':''}]))));
|
||||||
|
videos.slice(0,2).forEach((v,i)=>{downloading.add(v.id);downloadMeta.set(v.id,v);SaveQueue.progress(v.id,i?{phase:'waiting'}:{phase:'downloading',received:10485760,total:104857600});});
|
||||||
|
}
|
||||||
|
view={type:state.startsWith('downloads')?'downloads':'saved'};render();
|
||||||
|
},{layout,state});
|
||||||
|
}
|
||||||
|
for(const layout of ['classic','glass-stage'])for(const width of [390,1440])for(const state of ['downloads-empty','downloads-populated','saved-populated'])test(`${layout} ${state} at ${width}px`,async({page})=>{
|
||||||
|
await page.setViewportSize({width,height:900});await fixture(page,layout,state);
|
||||||
|
if(state==='saved-populated')await expect(page.locator('.saved-card')).toHaveCount(3);
|
||||||
|
if(state==='downloads-populated')await expect(page.locator('.dl-row')).toHaveCount(4);
|
||||||
|
await page.locator('#listPane').scrollIntoViewIfNeeded();
|
||||||
|
if(state==='downloads-empty') {await expect(page.locator('#listFilterBar')).toBeHidden();await expect(page.getByRole('button',{name:'View saved videos',exact:true})).toBeVisible();}
|
||||||
|
if(state==='downloads-populated') {await expect(page.getByRole('button',{name:/^Pause /})).toHaveCount(2);await expect(page.getByRole('button',{name:/^Retry /})).toHaveCount(1);}
|
||||||
|
if(capture)await page.screenshot({path:`/tmp/ytplayer-offline-${capture}-${layout}-${width}-${state}.png`});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('download cancellation waits for confirmation and filtering keeps queue intact', async({page})=>{
|
||||||
|
await fixture(page,'classic','downloads-populated');
|
||||||
|
await page.locator('.dl-row').first().getByRole('button',{name:/^Cancel /}).click();
|
||||||
|
await expect(page.getByRole('button',{name:'Cancel save',exact:true})).toBeVisible();
|
||||||
|
await page.getByRole('button',{name:'Keep',exact:true}).click();
|
||||||
|
await expect(page.locator('.dl-row')).toHaveCount(4);
|
||||||
|
await page.locator('#listFilterInput').fill('extended');
|
||||||
|
await expect(page.locator('.dl-row')).toHaveCount(1);
|
||||||
|
expect(await page.evaluate(()=>SaveQueue.pending().length)).toBe(4);
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user