From cf9b6f413301f8ef4324c11a3c624c37cd5ee3cf Mon Sep 17 00:00:00 2001 From: Jonathan Sykes Date: Sat, 3 Oct 2026 16:05:42 +0800 Subject: [PATCH] Clarify download states and make the empty page useful --- frontend/app.js | 121 ++++--------- frontend/downloads-page.js | 304 ++++++++++++++++++++++++++++++++ frontend/downloads-page.test.js | 50 ++++++ frontend/downloads.js | 8 +- frontend/index.html | 2 + frontend/offline-pages.css | 23 +++ frontend/sw.js | 2 + playwright.offline.config.js | 2 + tests/offline-pages.spec.js | 38 ++++ 9 files changed, 458 insertions(+), 92 deletions(-) create mode 100644 frontend/downloads-page.js create mode 100644 frontend/downloads-page.test.js create mode 100644 frontend/offline-pages.css create mode 100644 playwright.offline.config.js create mode 100644 tests/offline-pages.spec.js 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); +});