From ca836e0cd21152d2d92bb7b197fd22f3c020aaca Mon Sep 17 00:00:00 2001 From: Jonathan Sykes Date: Sat, 3 Oct 2026 16:10:46 +0800 Subject: [PATCH] Align saved video actions and confirm storage removal --- docs/remaining-features-review.md | 17 +++++++++ frontend/app.js | 63 ++++++++++++++++++++++--------- frontend/index.html | 2 + frontend/saved-page.css | 16 ++++++++ frontend/saved-page.js | 25 ++++++++++++ frontend/saved-page.test.js | 11 ++++++ frontend/sw.js | 2 + tests/offline-pages.spec.js | 25 ++++++++++++ 8 files changed, 143 insertions(+), 18 deletions(-) create mode 100644 docs/remaining-features-review.md create mode 100644 frontend/saved-page.css create mode 100644 frontend/saved-page.js create mode 100644 frontend/saved-page.test.js diff --git a/docs/remaining-features-review.md b/docs/remaining-features-review.md new file mode 100644 index 0000000..217076d --- /dev/null +++ b/docs/remaining-features-review.md @@ -0,0 +1,17 @@ +# Remaining features and screen refinement + +All changes are local commits on main. Nothing was pushed or deployed. + +| Feature | Status | Main files | Validation / reviewer checks | +| --- | --- | --- | --- | +| 9. Ten-band EQ | Done | frontend/eq-core.js, eq-core.test.js, app.js, styles.css; docs/equalizer.md | Five-band curves migrate; live playback and EqRender share bands/presets/widening. Unit and four Classic/Glass browser cases passed. Check headphones, mono compatibility, and saved versus live processing on a real iPhone. | +| 10. Bilingual lyrics | Done | frontend/lyrics-core.js, lyrics-display.js and tests; lower-third.js, app.js, server/notes.js and tests; docs/lyric-languages.md | Primary/secondary/phonetic text preserves timing and round trips. Service, stage, Presenter and OBS use shared display logic. Frontend and 18 server notes tests passed. Check font coverage, multiline fitting and remote output on real devices. | +| 11. Piano transcription | Done; optional heavy worker awaits real inference validation | frontend/piano-core.js, piano-engine.mjs, piano.js and tests; server/piano.js and tests; scripts/piano/; docs/piano-transcription.md | Browser Basic Pitch/TFJS loads only on request, chooses WebGPU where available with WebGL/CPU fallback, caches compact notes with hand heuristic, and supports visualizer/practice queue. Real repeated browser inference passed without growing tensor count. Four server and two Python worker tests passed. ByteDance worker scaffolding, leased API contract and optional compose profile are implemented and off by default; no heavy container was started. Check a known piano recording before enabling it. | +| Downloads polish | Done | frontend/downloads-page.js and tests, offline-pages.css, downloads.js, small app.js hooks; tests/offline-pages.spec.js | Drawn SVG empty state, no empty filter, storage information and Saved link. Active/queued/paused/failed groups reuse the existing manager and confirmed cancellation. Classic/Glass at 390 and 1440 px checked. Test real slow downloads, pause/resume, failure/retry and navigation while saving. | +| Saved polish | Done | frontend/saved-page.js and test, saved-page.css, renderSaved hooks; tests/offline-pages.spec.js | Storage summary and confirmed Clear all share one row. Export/delete have separate horizontal 44 px targets and SVG icons; long titles truncate. Individual deletions confirm, and failed storage operations retain records. Existing save-to-device sheet is called unchanged. Check iPhone export/share and real storage removal. | + +Common asset changes are registered in frontend/index.html and the service-worker shell list. The protected background playback methods, Classic playlist actions and save-to-device sheet were not edited. + +Final checks: every frontend/*.test.js, app.js syntax, server no-bundle build, and the offline-page browser suite. Feature-specific server and Python tests were run with their respective feature commits. The 21-case settings suite and real Basic Pitch inference also passed. Offline-page tests cover all four layout/width combinations, filtering, cancellation, delete failure and action geometry. + +Impeccable context loading, polish and craft-floor references were followed. Batched screenshots were assessed; final screenshots wait for the incumbent card reveal animation to finish. The manual detector found one advisory: the restrained Downloads empty-state heading is 20 px, intentionally smaller than the browsing title; no global typography change was made. The pre-existing design sidecar was reported stale and left outside this refinement. diff --git a/frontend/app.js b/frontend/app.js index 1a402d3..d872c4a 100755 --- a/frontend/app.js +++ b/frontend/app.js @@ -9177,7 +9177,9 @@ async function renderSaved() { resetListHeaderExtras(); els.listActions.innerHTML = ''; els.status.classList.add('hidden'); + $('batchBar').classList.add('hidden'); const c = els.cards; + c.classList.remove('select-mode'); c.innerHTML = '
Loading saved videos…
'; let res; @@ -9185,6 +9187,7 @@ async function renderSaved() { if (view.type !== 'saved') return; // navigated away if (!res || !res.ok) { + $('listFilterBar').classList.add('hidden'); c.innerHTML = ''; const empty = document.createElement('div'); empty.className = 'empty-state'; @@ -9200,6 +9203,7 @@ async function renderSaved() { const total = res.total || 0; if (!items.length) { + $('listFilterBar').classList.add('hidden'); c.innerHTML = ''; const empty = document.createElement('div'); empty.className = 'empty-state'; @@ -9214,12 +9218,18 @@ async function renderSaved() { // Header actions: total + clear all. const clearAll = document.createElement('button'); + clearAll.type = 'button'; + clearAll.className = 'saved-clear'; clearAll.textContent = 'Clear all'; + clearAll.setAttribute('aria-label', 'Clear all saved videos'); clearAll.onclick = () => { showModal('Clear all saved videos?', document.createTextNode('Frees disk space. Normal videos re-download on demand, but edited videos are removed for good (they exist only here).'), [ { label: 'Cancel', onClick: closeModal }, { label: 'Clear all', danger: true, onClick: async () => { - try { await API.cacheClear(); } catch {} + try { + const result = await API.cacheClear(); + if (result?.ok === false) throw new Error(result.error || 'Storage could not be cleared'); + } catch { toast('Could not clear saved videos. Try again.'); closeModal(); return; } cachedIds.clear(); // Custom cuts live only in the cache — clearing it destroys their media, // so drop their registry entries and playlist references too. @@ -9237,12 +9247,14 @@ async function renderSaved() { } }, ]); }; - els.listActions.appendChild(clearAll); + c.innerHTML = ''; const summary = document.createElement('div'); - summary.className = 'saved-summary'; - summary.innerHTML = `${fmtBytes(total)}${items.length} video${items.length === 1 ? '' : 's'} stored offline`; + summary.className = 'saved-summary saved-storage-summary'; + summary.innerHTML = `${fmtBytes(total)} · ${items.length} video${items.length === 1 ? '' : 's'} stored offline`; + summary.appendChild(clearAll); + $('listFilterBar').classList.remove('hidden'); c.appendChild(summary); items.forEach((it) => { @@ -9252,6 +9264,7 @@ async function renderSaved() { const isCustom = (known && known.custom) || String(it.id).startsWith('edit_'); const fallbackThumb = isCustom ? '' : `https://i.ytimg.com/vi/${it.id}/mqdefault.jpg`; const v = known || { id: it.id, title: videoTitleById(it.id), thumbnail: fallbackThumb }; + if (!SavedPage.matches(v, listFilter)) return; const row = document.createElement('div'); row.className = 'card saved-card' + (isCustom ? ' custom' : ''); row.dataset.id = it.id; @@ -9261,27 +9274,41 @@ async function renderSaved() {
${isCustom ? '✂ edited · ' : ''}${fmtBytes(it.size)}
-
- - `; + `; row.querySelector('.card-title').textContent = v.title || it.id; row.addEventListener('click', (e) => { - if (e.target.closest('[data-act="export"]')) { e.stopPropagation(); exportToDevice(v); return; } - if (e.target.closest('.card-del')) return; + if (e.target.closest('.saved-file-actions')) return; playFromList([v], 0, 'saved'); }); - row.querySelector('.card-del').addEventListener('click', async (e) => { - e.stopPropagation(); - if (isCustom) { await deleteCustomVideo(it.id); renderSaved(); return; } - try { await API.cacheDelete(it.id); } catch {} - cachedIds.delete(it.id); - if (current && current.meta && current.meta.id === it.id) updateNowPlayingActions(); - markCardCacheState(it.id, 'none'); - toast('Removed from cache'); - renderSaved(); + SavedPage.actions(row, v, () => exportToDevice(v), () => { + showModal(`Delete saved file “${v.title || v.id}”?`, document.createTextNode(isCustom + ? 'This edited copy exists only on this device and will be removed permanently.' + : 'Frees space on this device. You can save this video again later.'), [ + { label: 'Cancel', onClick: closeModal }, + { label: 'Delete saved file', danger: true, onClick: async () => { + try { + const result = await API.cacheDelete(it.id); + if (result?.ok === false) throw new Error(result.error || 'Storage could not be updated'); + } catch { toast('Could not delete this saved file. Try again.'); closeModal(); return; } + cachedIds.delete(it.id); + if (isCustom) { + data.customVideos = (data.customVideos || []).filter(x => x.id !== it.id); + data.playlists.forEach(pl => { pl.videos = pl.videos.filter(x => x.id !== it.id); }); + data.queue = (data.queue || []).filter(x => x.id !== it.id); + persist(); + } + if (current?.meta?.id === it.id) updateNowPlayingActions(); + markCardCacheState(it.id, 'none'); closeModal(); + toast('Removed saved file'); renderSaved(); + } }, + ]); }); c.appendChild(row); }); + if (!c.querySelector('.saved-card')) { + const message = document.createElement('p'); message.className = 'status'; + message.textContent = 'No saved videos match your filter.'; c.appendChild(message); + } markPlayingCard(); } diff --git a/frontend/index.html b/frontend/index.html index b803769..7b3375a 100755 --- a/frontend/index.html +++ b/frontend/index.html @@ -31,6 +31,7 @@ +
@@ -660,6 +661,7 @@ + diff --git a/frontend/saved-page.css b/frontend/saved-page.css new file mode 100644 index 0000000..6e6f430 --- /dev/null +++ b/frontend/saved-page.css @@ -0,0 +1,16 @@ +.saved-summary.saved-storage-summary { display:flex; align-items:center; justify-content:space-between; gap:12px; padding:8px 0 16px; } +.saved-storage-text { min-width:0; color:var(--text-2); font-size:14px; font-variant-numeric:tabular-nums; } +.saved-clear { flex:none; min-height:44px; padding:0 10px; border:0; background:transparent; color:var(--accent); font:inherit; cursor:pointer; } +html[data-layout] .card.saved-card { display:grid; grid-template-columns:72px minmax(0,1fr) 96px; align-items:center; gap:12px; min-height:88px; } +html[data-layout] .saved-card .thumb { grid-column:1; grid-row:1; width:72px; } +html[data-layout] .saved-card .card-info { grid-column:2; grid-row:1; min-width:0; } +html[data-layout] .saved-card .card-title { display:-webkit-box; -webkit-box-orient:vertical; -webkit-line-clamp:2; overflow:hidden; overflow-wrap:anywhere; } +html[data-layout] .saved-file-actions { grid-column:3; grid-row:1; display:flex; align-items:center; gap:8px; } +.saved-file-action { width:44px; height:44px; min-width:44px; padding:10px; border:1px solid var(--line); border-radius:var(--radius-sm,8px); background:var(--bg-2); color:var(--text-2); cursor:pointer; } +.saved-file-action svg { display:block; width:22px; height:22px; } +.saved-file-action.danger { color:var(--accent); background:transparent; } +.saved-file-action:hover, .saved-clear:hover { background:var(--bg-3); color:var(--text); } +.saved-file-action.danger:hover, .saved-clear:hover { color:var(--accent); } +.saved-file-action:focus-visible, .saved-clear:focus-visible { outline:2px solid var(--accent); outline-offset:3px; } +.saved-file-action:disabled, .saved-clear:disabled { opacity:.5; cursor:wait; } +@media(min-width:900px) { html[data-layout] .card.saved-card { grid-template-columns:88px minmax(0,1fr) 96px; } html[data-layout] .saved-card .thumb { width:88px; } } diff --git a/frontend/saved-page.js b/frontend/saved-page.js new file mode 100644 index 0000000..e5bad22 --- /dev/null +++ b/frontend/saved-page.js @@ -0,0 +1,25 @@ +/* Saved-file rows share one accessible action area in both layouts. */ +(function(root) { + 'use strict'; + const matches = (video, query) => !query || `${video.title || video.id} ${video.channel || ''}`.toLowerCase().includes(query.toLowerCase()); + const paths = { + export: '', + delete: '' + }; + function button(doc, kind, label, action) { + const b = doc.createElement('button'); b.type = 'button'; + b.className = `saved-file-action${kind === 'delete' ? ' danger' : ''}`; + b.setAttribute('aria-label', label); b.title = label; + b.innerHTML = ``; + b.onclick = e => { e.stopPropagation(); action(); }; return b; + } + function actions(row, video, exportFile, deleteFile) { + const doc = row.ownerDocument, group = doc.createElement('div'); + group.className = 'saved-file-actions'; group.setAttribute('role', 'group'); + group.setAttribute('aria-label', `File actions for ${video.title || video.id}`); + group.append(button(doc, 'export', `Save to this device: ${video.title || video.id}`, exportFile), button(doc, 'delete', `Delete saved file: ${video.title || video.id}`, deleteFile)); + row.append(group); + } + const api = { matches, actions }; + if (typeof module !== 'undefined') module.exports = api; else root.SavedPage = api; +})(typeof window !== 'undefined' ? window : globalThis); diff --git a/frontend/saved-page.test.js b/frontend/saved-page.test.js new file mode 100644 index 0000000..d5936be --- /dev/null +++ b/frontend/saved-page.test.js @@ -0,0 +1,11 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const { matches } = require('./saved-page.js'); +test('saved filtering matches title and channel without changing the library', () => { + const video = { id: 'one', title: 'A quiet morning', channel: 'Studio sessions' }; + assert.equal(matches(video, 'QUIET'), true); + assert.equal(matches(video, 'studio'), true); + assert.equal(matches(video, 'evening'), false); + assert.equal(matches({ id: 'unknown' }, 'unknown'), true); + assert.equal(matches(video, ''), true); +}); diff --git a/frontend/sw.js b/frontend/sw.js index 58c95bf..79409c7 100644 --- a/frontend/sw.js +++ b/frontend/sw.js @@ -87,6 +87,8 @@ const SHELL = [ '/downloads.js', '/downloads-page.js', '/offline-pages.css', + '/saved-page.js', + '/saved-page.css', '/party-dj.js', '/lyrics-window.js', '/midi.js', diff --git a/tests/offline-pages.spec.js b/tests/offline-pages.spec.js index a96b13a..6732b0a 100644 --- a/tests/offline-pages.spec.js +++ b/tests/offline-pages.spec.js @@ -23,6 +23,7 @@ for(const layout of ['classic','glass-stage'])for(const width of [390,1440])for( 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);} + await page.locator('.card').evaluateAll(async nodes => { await Promise.all(nodes.flatMap(node => node.getAnimations().map(animation => animation.finished.catch(() => {})))); }); if(capture)await page.screenshot({path:`/tmp/ytplayer-offline-${capture}-${layout}-${width}-${state}.png`}); }); @@ -36,3 +37,27 @@ test('download cancellation waits for confirmation and filtering keeps queue int await expect(page.locator('.dl-row')).toHaveCount(1); expect(await page.evaluate(()=>SaveQueue.pending().length)).toBe(4); }); +for(const layout of ['classic','glass-stage'])for(const width of [390,1440])test(`${layout} Saved actions do not overlap at ${width}px`,async({page})=>{ + await page.setViewportSize({width,height:900});await fixture(page,layout,'saved-populated'); + await expect(page.locator('.saved-file-action')).toHaveCount(6); + const bounds=await page.locator('.saved-file-actions').first().locator('button').evaluateAll(nodes=>nodes.map(n=>{const r=n.getBoundingClientRect();return {width:r.width,height:r.height,left:r.left,right:r.right,top:r.top};})); + expect(bounds[0].width).toBeGreaterThanOrEqual(44);expect(bounds[0].height).toBeGreaterThanOrEqual(44); + expect(bounds[1].left).toBeGreaterThanOrEqual(bounds[0].right);expect(bounds[1].top).toBe(bounds[0].top); + await expect(page.locator('.saved-storage-summary').getByRole('button',{name:'Clear all saved videos'})).toBeVisible(); +}); +test('saved deletion is confirmed and a storage failure retains the file',async({page})=>{ + await fixture(page,'classic','saved-populated'); + await page.evaluate(()=>{API.cacheDelete=async()=>({ok:false,error:'Disk busy'});}); + await page.locator('.saved-file-action.danger').first().click(); + await expect(page.getByRole('button',{name:'Delete saved file',exact:true})).toBeVisible(); + await page.getByRole('button',{name:'Cancel',exact:true}).click(); + await expect(page.locator('.saved-card')).toHaveCount(3); + await page.locator('.saved-file-action.danger').first().click(); + await page.getByRole('button',{name:'Delete saved file',exact:true}).click(); + await expect(page.locator('.saved-card')).toHaveCount(3); + await page.getByRole('button',{name:'Clear all saved videos'}).click(); + await expect(page.getByRole('button',{name:'Clear all',exact:true})).toBeVisible(); + await page.getByRole('button',{name:'Cancel',exact:true}).click(); + await page.locator('#listFilterInput').fill('extended'); + await expect(page.locator('.saved-card')).toHaveCount(1); +});