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

View File

@@ -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
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);

View 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>'), '&lt;script&gt;alert(&quot;xss&quot;)&lt;/script&gt;');
assert.equal(DownloadsPage.escapeHtml('Tom & Jerry'), 'Tom &amp; Jerry');
});

View File

@@ -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);

View File

@@ -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>

View 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; } }

View File

@@ -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',

View File

@@ -0,0 +1,2 @@
const { defineConfig } = require('@playwright/test');
module.exports = defineConfig({ ...require('./playwright.classic.config'), testMatch: /offline-pages\.spec\.js/ });

View 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);
});