Align saved video actions and confirm storage removal

This commit is contained in:
Jonathan Sykes
2026-10-03 16:10:46 +08:00
parent cf9b6f4133
commit ca836e0cd2
8 changed files with 143 additions and 18 deletions

View File

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

View File

@@ -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 = '<div class="status">Loading saved videos…</div>';
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 = `<span class="saved-total">${fmtBytes(total)}</span><span class="saved-sub">${items.length} video${items.length === 1 ? '' : 's'} stored offline</span>`;
summary.className = 'saved-summary saved-storage-summary';
summary.innerHTML = `<span class="saved-storage-text">${fmtBytes(total)} · ${items.length} video${items.length === 1 ? '' : 's'} stored offline</span>`;
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() {
<div class="card-info">
<div class="card-title"></div>
<div class="card-channel saved-size">${isCustom ? '✂ edited · ' : ''}${fmtBytes(it.size)}</div>
</div>
<button class="card-act" data-act="export" title="Save to gallery" aria-label="Save to gallery">⤓</button>
<button class="card-del" title="Delete saved file">✕</button>`;
</div>`;
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();
}

View File

@@ -31,6 +31,7 @@
<link rel="stylesheet" href="lower-third.css" />
<link rel="stylesheet" href="car-mode.css" />
<link rel="stylesheet" href="offline-pages.css" />
<link rel="stylesheet" href="saved-page.css" />
</head>
<body>
<div class="app">
@@ -660,6 +661,7 @@
<script src="settings-sections.js"></script>
<script src="downloads.js"></script>
<script src="downloads-page.js"></script>
<script src="saved-page.js"></script>
<script src="party-dj.js"></script>
<script src="lyrics-window.js"></script>
<script src="midi.js"></script>

16
frontend/saved-page.css Normal file
View File

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

25
frontend/saved-page.js Normal file
View File

@@ -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: '<path d="M12 3v12m-5-5 5 5 5-5M4 17v4h16v-4"/>',
delete: '<path d="M4 7h16M9 7V4h6v3M6 7l1 13h10l1-13M10 11v6M14 11v6"/>'
};
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 = `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">${paths[kind]}</svg>`;
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);

View File

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

View File

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

View File

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