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

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