diff --git a/frontend/app.js b/frontend/app.js
index 1ca5452..1a402d3 100755
--- a/frontend/app.js
+++ b/frontend/app.js
@@ -337,13 +337,13 @@ const SaveQueue = (() => {
const get = (id) => prog.get(id) || null;
function label(p) {
- if (!p) return '⏳ Saving for offline…';
- if (p.phase === 'waiting') return '⏳ Waiting for a free slot…';
+ if (!p) return 'Saving for offline…';
+ if (p.phase === 'waiting') return 'Waiting for a free slot…';
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 === 'verifying') return '✓ Verifying…';
- if (p.total) return `⬇ ${mb(p.received)} / ${mb(p.total)} MB`;
- return '⏳ Saving for offline…';
+ 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 `${mb(p.received)} / ${mb(p.total)} MB`;
+ return 'Saving for offline…';
}
function paint(id) {
// Only the Downloads view's own rows: search/playlist cards also carry
@@ -3769,7 +3769,11 @@ const Notes = (() => {
try {
const res = await fetch(`/api/notes/${encodeURIComponent(id)}/lyrics/web`, {
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);
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');
const c = els.cards;
c.innerHTML = '';
+ $('listFilterBar').classList.toggle('hidden', !Downloads.getJobs().length);
+ $('batchBar').classList.add('hidden');
+ c.classList.remove('select-mode');
- const active = [...downloadMeta.values()];
- const paused = SaveQueue.paused();
- if (!active.length && !paused.length) {
- const empty = document.createElement('div');
- empty.className = 'empty-state';
- empty.innerHTML = `
-
⬇
- No active downloads
- Saves in progress show here with live status. Finished videos land in Saved.
`;
- const cta = document.createElement('button');
- cta.className = 'empty-cta';
- cta.textContent = '💾 View saved videos';
- cta.addEventListener('click', () => { view = { type: 'saved' }; render(); });
- empty.appendChild(cta);
- c.appendChild(empty);
- return;
+ if (window.DownloadsPage && window.DownloadsPage.render) {
+ window.DownloadsPage.render(c, {
+ downloadsApi: window.Downloads,
+ filter: listFilter,
+ isActive: () => view.type === 'downloads',
+ showModal: typeof showModal === 'function' ? showModal : null,
+ closeModal: typeof closeModal === 'function' ? closeModal : null,
+ onNavigateSaved: () => { view = { type: 'saved' }; render(); },
+ thumbUrlFor: typeof thumbUrlFor === 'function' ? thumbUrlFor : null,
+ refresh: renderList
+ });
}
- const note = document.createElement('div');
- note.className = 'saved-summary';
- const n = active.length + paused.length;
- note.innerHTML = `${n}download${n === 1 ? '' : 's'}${paused.length ? ` · ${paused.length} paused` : ' in progress'}`;
- 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 = `
-
-
 || '')})
-
-
- `;
- 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));
+ const jobs = window.Downloads && window.Downloads.getJobs ? window.Downloads.getJobs() : [];
+ jobs.filter(j => j.status === 'active' || j.status === 'queued').forEach(v => {
+ if (typeof SaveQueue !== 'undefined' && SaveQueue.progress) {
+ 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(() => {});
- }
}
// ============================================================================
diff --git a/frontend/downloads-page.js b/frontend/downloads-page.js
new file mode 100644
index 0000000..8d45e80
--- /dev/null
+++ b/frontend/downloads-page.js
@@ -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, ''');
+ }
+
+ 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 = `
+
+ 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))}
+
+ `;
+
+ 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);
diff --git a/frontend/downloads-page.test.js b/frontend/downloads-page.test.js
new file mode 100644
index 0000000..eab3dc1
--- /dev/null
+++ b/frontend/downloads-page.test.js
@@ -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>');
+ assert.equal(DownloadsPage.escapeHtml('Tom & Jerry'), 'Tom & Jerry');
+});
+
diff --git a/frontend/downloads.js b/frontend/downloads.js
index a5ea373..dc5c4e2 100644
--- a/frontend/downloads.js
+++ b/frontend/downloads.js
@@ -45,5 +45,11 @@
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);
diff --git a/frontend/index.html b/frontend/index.html
index e7187df..b803769 100755
--- a/frontend/index.html
+++ b/frontend/index.html
@@ -30,6 +30,7 @@
+
@@ -658,6 +659,7 @@
+
diff --git a/frontend/offline-pages.css b/frontend/offline-pages.css
new file mode 100644
index 0000000..b701f4e
--- /dev/null
+++ b/frontend/offline-pages.css
@@ -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; } }
diff --git a/frontend/sw.js b/frontend/sw.js
index c06b038..58c95bf 100644
--- a/frontend/sw.js
+++ b/frontend/sw.js
@@ -85,6 +85,8 @@ const SHELL = [
'/p2p-recv-worker.js',
'/settings-sections.js',
'/downloads.js',
+ '/downloads-page.js',
+ '/offline-pages.css',
'/party-dj.js',
'/lyrics-window.js',
'/midi.js',
diff --git a/playwright.offline.config.js b/playwright.offline.config.js
new file mode 100644
index 0000000..aef41a1
--- /dev/null
+++ b/playwright.offline.config.js
@@ -0,0 +1,2 @@
+const { defineConfig } = require('@playwright/test');
+module.exports = defineConfig({ ...require('./playwright.classic.config'), testMatch: /offline-pages\.spec\.js/ });
diff --git a/tests/offline-pages.spec.js b/tests/offline-pages.spec.js
new file mode 100644
index 0000000..a96b13a
--- /dev/null
+++ b/tests/offline-pages.spec.js
@@ -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);
+});