Keep search results with thumbnails until cleared in Settings; add stale-while-revalidate search and longer asset caching

This commit is contained in:
Claude
2026-10-01 22:04:51 +00:00
parent 61a8f63385
commit 14e2307a92
6 changed files with 273 additions and 58 deletions

View File

@@ -135,31 +135,89 @@ const RecentSearches = (() => {
list.unshift(q);
const trimmed = list.slice(0, MAX);
save(trimmed);
pruneResults(trimmed);
},
remove(q) {
const list = load().filter((x) => x.toLowerCase() !== (q || '').toLowerCase());
save(list);
pruneResults(list);
},
clear() { save([]); saveResults({}); },
clear() { save([]); }, // saved results stay in SearchLibrary (cleared only from Settings)
// Cache the just-fetched result set for `q` (called on every successful search).
cacheResults(q, results) {
q = (q || '').trim();
if (!q || !Array.isArray(results) || !results.length) return;
const r = loadResults();
r[norm(q)] = { query: q, results: results.slice(0, RESULTS_PER_QUERY_MAX), ts: Date.now() };
saveResults(r);
pruneResults(load());
},
cacheResults(q, results) { SearchLibrary.put(q, results); },
// Cached {query, results, ts} for `q`, or null.
getResults(q) {
const hit = loadResults()[norm(q)];
return hit && Array.isArray(hit.results) && hit.results.length ? hit : null;
return SearchLibrary.get(q);
},
// Normalized queries that have a cached result set — one localStorage
// read for the whole dropdown, rather than one per row.
cachedQueries() { return new Set(Object.keys(loadResults())); },
cachedQueries() { return new Set(SearchLibrary.list().map((x) => x.key)); },
};
})();
// ============================================================================
// Search library — every result set (with thumbnail URLs) is kept on this device
// ============================================================================
// Clearing the search box or the recent-searches dropdown never deletes these;
// they are removed only from Settings → Saved searches (all, or a selection).
// The thumbnails themselves live in the service worker's `ytplayer-thumbs`
// cache (warmed below), so a saved search renders with its pictures offline.
const SearchLibrary = (() => {
const KEY = 'ytpSearchLibrary';
const LEGACY = 'ytpRecentSearchResults';
const MAX_QUERIES = 150;
const PER_QUERY = 50;
const norm = (q) => String(q || '').trim().toLowerCase();
let mem = null;
function read() {
if (mem) return mem;
let o = {};
try { o = JSON.parse(localStorage.getItem(KEY) || '{}') || {}; } catch { o = {}; }
// One-time import of the old per-recent-search cache.
try {
const old = JSON.parse(localStorage.getItem(LEGACY) || 'null');
if (old && typeof old === 'object') {
for (const [k, v] of Object.entries(old)) if (v && Array.isArray(v.results) && !o[k]) o[k] = v;
localStorage.removeItem(LEGACY);
}
} catch { /* ignore */ }
mem = o;
return mem;
}
function write() {
const o = read();
for (let attempt = 0; attempt < 4; attempt++) {
try { localStorage.setItem(KEY, JSON.stringify(o)); return true; } catch {
// Quota: drop the oldest fifth and retry.
const keys = Object.keys(o).sort((a, b) => (o[a].ts || 0) - (o[b].ts || 0));
keys.slice(0, Math.max(1, Math.ceil(keys.length / 5))).forEach((k) => delete o[k]);
}
}
return false;
}
return {
put(q, results) {
q = String(q || '').trim();
if (!q || !Array.isArray(results) || !results.length) return;
const o = read();
o[norm(q)] = { query: q, results: results.slice(0, PER_QUERY), ts: Date.now() };
const keys = Object.keys(o);
if (keys.length > MAX_QUERIES) {
keys.sort((a, b) => (o[a].ts || 0) - (o[b].ts || 0)).slice(0, keys.length - MAX_QUERIES).forEach((k) => delete o[k]);
}
write();
},
get(q) { const h = read()[norm(q)]; return h && Array.isArray(h.results) && h.results.length ? h : null; },
has(q) { return !!read()[norm(q)]; },
// Newest first: [{ query, count, ts, thumb }]
list() {
const o = read();
return Object.keys(o).map((k) => ({ key: k, query: o[k].query || k, count: (o[k].results || []).length, ts: o[k].ts || 0, thumb: ((o[k].results || [])[0] || {}).thumbnail || '' }))
.sort((a, b) => b.ts - a.ts);
},
remove(keys) { const o = read(); (keys || []).forEach((k) => delete o[k]); write(); },
clear() { mem = {}; try { localStorage.removeItem(KEY); } catch { /* blocked */ } },
bytes() { try { return (localStorage.getItem(KEY) || '').length; } catch { return 0; } },
count() { return Object.keys(read()).length; },
};
})();
@@ -1242,6 +1300,14 @@ function startCacheResyncWatch() {
// is only cached once something has actually requested it. Pull it through the
// service worker (which caches thumbnail responses) so a video saved for
// offline still has its art when the network is gone.
// Pull a result set's artwork into the service worker's thumbnail cache, a few at
// a time, so a saved search still has pictures offline.
function warmSearchThumbs(results) {
const urls = (results || []).map((r) => r && r.thumbnail).filter(Boolean).slice(0, 50);
let i = 0;
const next = () => { if (i >= urls.length) return; warmThumb(urls[i++]); setTimeout(next, 40); };
next(); next(); next();
}
function warmThumb(url) {
if (!WEB || !url || !/^https?:/i.test(url)) return;
fetch(url, { mode: 'cors', credentials: 'omit' }).catch(() => {});
@@ -4482,7 +4548,7 @@ const Remote = (() => {
} catch { found = []; }
results.innerHTML = found.length ? found.slice(0, 20).map((v, i) => `
<button class="rv-add-item" type="button" data-k="${i}">
<img src="${escapeHtml(v.thumbnail || '')}" alt="" loading="lazy" />
<img src="${escapeHtml(v.thumbnail || '')}" alt="" loading="lazy" decoding="async" />
<span><b>${escapeHtml(v.title || '')}</b><small>${escapeHtml(v.channel || '')}${v.duration ? ' · ' + clock(v.duration) : ''}</small></span>
</button>`).join('') : '<div class="rv-empty">No results.</div>';
});
@@ -6905,7 +6971,7 @@ function renderUpNext() {
const item = document.createElement('div');
item.className = 'upnext-item';
item.innerHTML = `
<img src="${escapeHtml(v.thumbnail || '')}" alt="" loading="lazy" />
<img src="${escapeHtml(v.thumbnail || '')}" alt="" loading="lazy" decoding="async" />
<div class="upnext-info">
<div class="upnext-title"></div>
<div class="upnext-channel"></div>
@@ -7333,7 +7399,7 @@ function renderHomePlaylists() {
card.type = 'button';
card.innerHTML = `
<span class="home-pl-thumb">
${thumb ? `<img src="${escapeHtml(thumb)}" alt="" loading="lazy" />`
${thumb ? `<img src="${escapeHtml(thumb)}" alt="" loading="lazy" decoding="async" />`
: '<span class="home-pl-thumb-fallback">🎵</span>'}
${offlineHtml}
<span class="home-pl-play" title="Play all">▶</span>
@@ -7414,6 +7480,7 @@ function renderList() {
if (view.type === 'settings') { renderSettings(); return; }
if (view.type === 'saved') { renderSaved(); return; }
if (view.type === 'p2p') { renderP2pList(); return; }
if (view.type === 'savedSearches') { renderSavedSearches(); return; }
if (view.type === 'downloads') { renderDownloads(); return; }
if (view.type === 'stats') { renderStats(); return; }
if (view.type === 'notes') { renderNotesView(); return; }
@@ -7776,6 +7843,47 @@ async function renderP2pStrip() {
box.classList.remove('hidden');
}
// ============================================================================
// Saved searches — browse (read-only here; deleting happens in Settings)
// ============================================================================
function fmtAgo(ts) {
const s = Math.max(0, (Date.now() - ts) / 1000);
if (s < 90) return 'just now';
if (s < 3600) return Math.round(s / 60) + ' min ago';
if (s < 86400) return Math.round(s / 3600) + ' h ago';
return Math.round(s / 86400) + ' d ago';
}
function renderSavedSearches() {
els.listTitle.textContent = '📚 Saved searches';
els.listActions.innerHTML = '';
els.status.classList.add('hidden');
const c = els.cards;
c.innerHTML = '';
const items = SearchLibrary.list();
if (!items.length) {
c.innerHTML = '<div class="empty-state"><div class="empty-icon">📚</div><h3 class="empty-title">No saved searches yet</h3><p class="empty-desc">Every search you run is kept here with its thumbnails, even after you clear the search box. You can remove them in Settings.</p></div>';
return;
}
const sum = document.createElement('div');
sum.className = 'saved-summary';
sum.innerHTML = `<span class="saved-total">${items.length}</span><span class="saved-sub">saved search${items.length === 1 ? '' : 'es'} · ${fmtBytes(SearchLibrary.bytes())} · manage in Settings</span>`;
c.appendChild(sum);
items.forEach((it) => {
const row = document.createElement('div');
row.className = 'card saved-card ss-card';
row.innerHTML = `
<div class="thumb"><img loading="lazy" decoding="async" src="${escapeHtml(it.thumb)}" alt="" onerror="this.classList.add('no-art')" /></div>
<div class="card-info"><div class="card-title"></div><div class="card-channel">${it.count} result${it.count === 1 ? '' : 's'} · ${fmtAgo(it.ts)}</div></div>`;
row.querySelector('.card-title').textContent = it.query;
row.addEventListener('click', () => {
els.searchInput.value = it.query;
if (els.runSearch) els.runSearch(it.query, { instant: SearchLibrary.get(it.query) });
});
c.appendChild(row);
});
}
async function renderP2pList() {
els.listTitle.textContent = '📡 On other devices';
els.listActions.innerHTML = '';
@@ -7822,7 +7930,7 @@ async function renderP2pList() {
row.dataset.id = it.id;
const state = it.online ? `<b class="p2p-on">● ${it.online} online</b>` : '<span class="p2p-off">○ none online</span>';
row.innerHTML = `
<div class="thumb"><img loading="lazy" src="${escapeHtml(v.thumbnail)}" alt="" onerror="this.classList.add('no-art')" /></div>
<div class="thumb"><img loading="lazy" decoding="async" src="${escapeHtml(v.thumbnail)}" alt="" onerror="this.classList.add('no-art')" /></div>
<div class="card-info">
<div class="card-title"></div>
<div class="card-channel">${state} · ${it.holders} device${it.holders === 1 ? '' : 's'} · ${fmtBytes(it.size)}${it.height ? ' · ' + it.height + 'p' : ''}${it.stale ? ' · <i>not checked lately</i>' : ''}</div>
@@ -7923,7 +8031,7 @@ async function renderSaved() {
row.className = 'card saved-card' + (isCustom ? ' custom' : '');
row.dataset.id = it.id;
row.innerHTML = `
<div class="thumb"><img loading="lazy" src="${escapeHtml(v.thumbnail || thumbUrlFor(v.id, null) || '')}" alt=""
<div class="thumb"><img loading="lazy" decoding="async" src="${escapeHtml(v.thumbnail || thumbUrlFor(v.id, null) || '')}" alt=""
onerror="this.classList.add('no-art')" />${isCustom ? '<span class="edit-badge" title="Edited copy">✂</span>' : ''}${isUploadId(v.id) ? `<span class="upload-badge" title="On this server">${v.kind === 'audio' ? '♪' : '📁'}</span>` : ''}</div>
<div class="card-info">
<div class="card-title"></div>
@@ -7990,7 +8098,7 @@ function renderDownloads() {
row.dataset.id = v.id;
row.innerHTML = `
<div class="thumb">
<img loading="lazy" src="${escapeHtml(v.thumbnail || '')}" alt="" />
<img loading="lazy" decoding="async" src="${escapeHtml(v.thumbnail || '')}" alt="" />
<div class="dl-progress"><div class="dl-bar"></div></div>
</div>
<div class="card-info">
@@ -8179,6 +8287,23 @@ async function renderSettings() {
<div id="cacheList" class="cache-list"></div>
</div>
<div class="set-group">
<div class="set-group-title">Saved searches</div>
<div class="set-row">
<span>
Kept on this device
<small>Search results and their thumbnails stay saved after you clear the search box. Only you can remove them, here.</small>
</span>
<span id="ssTotal" class="set-stat">…</span>
</div>
<div class="set-actions">
<button id="ssSelectAll" class="btn">Select all</button>
<button id="ssDeleteSel" class="btn danger" disabled>Delete selected</button>
<button id="ssClearAll" class="btn danger">Clear all</button>
</div>
<div id="ssList" class="ss-list"></div>
</div>
<div class="set-group">
<div class="set-group-title">Playlists</div>
<div class="set-row" style="border:none;gap:8px;flex-wrap:wrap">
@@ -8288,6 +8413,40 @@ async function renderSettings() {
$('setRepeat').addEventListener('change', (e) => { data.settings.repeatMode = e.target.checked ? 'all' : 'off'; updateLoopRepeatButtons(); persist(); });
$('setLoopOne').addEventListener('change', (e) => { data.settings.loopOne = e.target.checked; updateLoopRepeatButtons(); persist(); });
// ---- Saved searches manager ----
{
const listEl = $('ssList');
const sel = new Set();
const refresh = () => {
const items = SearchLibrary.list();
for (const k of [...sel]) if (!items.some((i) => i.key === k)) sel.delete(k);
$('ssTotal').textContent = items.length ? `${items.length} · ${fmtBytes(SearchLibrary.bytes())}` : 'None';
listEl.innerHTML = items.length ? items.map((i) => `
<label class="ss-row"><input type="checkbox" data-k="${escapeHtml(i.key)}" ${sel.has(i.key) ? 'checked' : ''} />
<span class="ss-q">${escapeHtml(i.query)}</span><small>${i.count} · ${fmtAgo(i.ts)}</small></label>`).join('') : '<div class="muted" style="padding:8px 2px">No saved searches.</div>';
$('ssDeleteSel').disabled = !sel.size;
$('ssDeleteSel').textContent = sel.size ? `Delete selected (${sel.size})` : 'Delete selected';
$('ssSelectAll').textContent = items.length && sel.size === items.length ? 'Select none' : 'Select all';
$('ssClearAll').disabled = !items.length;
};
listEl.addEventListener('change', (e) => { const k = e.target.dataset && e.target.dataset.k; if (!k) return; e.target.checked ? sel.add(k) : sel.delete(k); refresh(); });
$('ssSelectAll').addEventListener('click', () => {
const items = SearchLibrary.list();
if (sel.size === items.length) sel.clear(); else items.forEach((i) => sel.add(i.key));
refresh();
});
$('ssDeleteSel').addEventListener('click', () => {
if (!sel.size || !confirm(`Delete ${sel.size} saved search${sel.size === 1 ? '' : 'es'}? Their results and thumbnails are removed from this device.`)) return;
SearchLibrary.remove([...sel]); sel.clear(); refresh(); toast('Saved searches deleted');
});
$('ssClearAll').addEventListener('click', () => {
if (!confirm('Delete ALL saved searches and their thumbnails from this device?')) return;
SearchLibrary.clear(); sel.clear(); refresh(); toast('All saved searches deleted');
});
refresh();
}
// ---- Wire playback controls ----
$('setQuality').addEventListener('change', (e) => {
data.settings.quality = e.target.value;
@@ -8520,7 +8679,7 @@ function renderCard(v, index, list) {
card.innerHTML = `
<div class="batch-check"></div>
<div class="thumb">
<img loading="lazy" src="${escapeHtml(v.thumbnail || '')}" alt="" />
<img loading="lazy" decoding="async" src="${escapeHtml(v.thumbnail || '')}" alt="" />
${v.duration ? `<span class="dur">${fmtTime(v.duration)}</span>` : ''}
<span class="saved-badge" title="${isDownloading ? 'Downloading…' : 'Saved offline'}">${isDownloading ? '' : '⬇'}</span>
${isDownloading ? '<div class="dl-progress"><div class="dl-bar"></div></div>' : ''}
@@ -9404,6 +9563,7 @@ function wireUI() {
searchAttempted = true;
renderList();
RecentSearches.cacheResults(q, results);
warmSearchThumbs(results);
warmStreams(results.slice(0, 3).map((r) => r.id));
perfMeasure('ytp:search', 'ytp:search-start');
} catch (err) {
@@ -9415,6 +9575,7 @@ function wireUI() {
}
}
els.runSearch = runSearchQuery;
els.searchForm.addEventListener('submit', (e) => {
e.preventDefault();
runSearchQuery(els.searchInput.value);
@@ -9423,7 +9584,7 @@ function wireUI() {
// ---- Recent searches dropdown (device-local; see RecentSearches above) ----
function renderRecentSearches() {
const list = RecentSearches.list();
if (!list.length) { els.searchRecent.innerHTML = ''; return; }
if (!list.length && !SearchLibrary.count()) { els.searchRecent.innerHTML = ''; return; }
const cachedQueries = RecentSearches.cachedQueries();
els.searchRecent.innerHTML = list.map((q) => {
const isCached = cachedQueries.has(q.trim().toLowerCase());
@@ -9432,11 +9593,12 @@ function wireUI() {
<span class="sr-icon" title="${isCached ? 'Loads instantly, then refreshes' : 'Recent search'}">${isCached ? '⚡' : '🕐'}</span><span class="sr-text">${escapeHtml(q)}</span>
<button type="button" class="sr-remove" data-remove="${escapeHtml(q)}" aria-label="Remove ‘${escapeHtml(q)}’ from recent searches">✕</button>
</div>`;
}).join('') + '<button type="button" class="search-recent-clear" data-clear-all>Clear recent searches</button>';
}).join('') + `<button type="button" class="search-recent-clear" data-clear-all>Clear this list <small>(saved results are kept)</small></button>`
+ (SearchLibrary.count() ? `<button type="button" class="search-recent-clear" data-open-saved>📚 Saved searches (${SearchLibrary.count()})</button>` : '');
}
function showRecentSearches() {
renderRecentSearches();
if (!RecentSearches.list().length) return;
if (!RecentSearches.list().length && !SearchLibrary.count()) return;
els.searchRecent.classList.remove('hidden');
}
function hideRecentSearches() { els.searchRecent.classList.add('hidden'); }
@@ -9455,6 +9617,13 @@ function wireUI() {
showRecentSearches();
return;
}
if (e.target.closest('[data-open-saved]')) {
e.preventDefault();
hideRecentSearches();
view = { type: 'savedSearches' };
render();
return;
}
if (e.target.closest('[data-clear-all]')) {
e.preventDefault();
RecentSearches.clear();
@@ -10105,7 +10274,7 @@ function renderRelated() {
relatedVideos.forEach((v) => {
const item = document.createElement('div');
item.className = 'related-item';
item.innerHTML = `<img src="${escapeHtml(v.thumbnail || '')}" alt="" loading="lazy" /><div class="ri-info"><div class="ri-title"></div><div class="ri-channel"></div></div>`;
item.innerHTML = `<img src="${escapeHtml(v.thumbnail || '')}" alt="" loading="lazy" decoding="async" /><div class="ri-info"><div class="ri-title"></div><div class="ri-channel"></div></div>`;
item.querySelector('.ri-title').textContent = v.title;
const riChEl = item.querySelector('.ri-channel');
riChEl.textContent = v.channel || '';

View File

@@ -20,6 +20,7 @@
<link rel="preload" href="fonts/BricolageGrotesque-latin.woff2" as="font" type="font/woff2" crossorigin />
<link rel="preload" href="fonts/HankenGrotesk-latin.woff2" as="font" type="font/woff2" crossorigin />
<link rel="stylesheet" href="fonts/fonts.css" />
<link rel="preconnect" href="https://i.ytimg.com" crossorigin />
<link rel="stylesheet" href="styles.css" />
<link rel="stylesheet" href="service-ui.css" />
<link rel="stylesheet" href="refresh.css" />

View File

@@ -535,3 +535,13 @@
}
@media (max-width: 380px) { #nowPlayingMeta .np-actions #addBtn, #nowPlayingMeta .np-actions #serviceBtn { flex-basis: calc(50% - 8px); } }
.player-stage { flex-shrink: 0; }
/* Saved searches */
.ss-list { max-height: 280px; overflow-y: auto; margin-top: 8px; border: 1px solid var(--line); border-radius: 14px; }
.ss-row { display: flex; align-items: center; gap: 10px; padding: 10px 12px; border-bottom: 1px solid var(--line-soft, var(--line)); cursor: pointer; }
.ss-row:last-child { border-bottom: 0; }
.ss-row input { width: 18px; height: 18px; accent-color: var(--accent); flex: none; }
.ss-q { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.ss-row small { color: var(--text-dim); flex: none; font-size: 11px; }
.search-recent-clear small { opacity: 0.6; }
.topbar { position: relative; z-index: 80; } /* keeps the recent-searches dropdown above the page content */

View File

@@ -46,7 +46,7 @@ const THUMB_HOSTS = new Set([
]);
// Thumbnails accumulate with every card ever scrolled past — keep it bounded.
const THUMB_CACHE_MAX = 800;
const THUMB_CACHE_MAX = 2500;
function isVersionedShellCache(key) {
return key.startsWith(SHELL_CACHE_PREFIX) && !UTILITY_CACHES.has(key);
}