Align saved video actions and confirm storage removal
This commit is contained in:
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
@@ -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
16
frontend/saved-page.css
Normal 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
25
frontend/saved-page.js
Normal 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);
|
||||
11
frontend/saved-page.test.js
Normal file
11
frontend/saved-page.test.js
Normal 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);
|
||||
});
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user