cache recent search results locally so re-running one is instant

This commit is contained in:
Jonathan Sykes
2026-09-30 00:16:35 +08:00
parent 6480b314d5
commit 4f0ee45980

View File

@@ -83,11 +83,37 @@ function hevcCapable() {
// Recent searches — device-local only (localStorage), never synced to the
// server or another device. Most-recent-first, case-insensitive de-dupe.
// A paired store (RESULTS_KEY) caches the actual result cards for each
// recent query, so re-running one from the dropdown can paint instantly
// instead of waiting on a fresh /api/search round-trip (yt-dlp, several
// seconds). Cards are already slim ({id,title,channel,channelId,
// channelUrl,duration,thumbnail}, server's slimEntry()) so no field
// trimming is needed; entries are pruned to whatever's still in the query
// list so the two stores never drift apart.
const RecentSearches = (() => {
const KEY = 'ytpRecentSearches';
const RESULTS_KEY = 'ytpRecentSearchResults';
const MAX = 10;
const RESULTS_PER_QUERY_MAX = 50; // defensive cap; server returns ~45
const norm = (q) => (q || '').trim().toLowerCase();
const load = () => { try { const l = JSON.parse(localStorage.getItem(KEY) || '[]'); return Array.isArray(l) ? l : []; } catch { return []; } };
const save = (list) => { try { localStorage.setItem(KEY, JSON.stringify(list)); } catch { /* storage full / blocked */ } };
const loadResults = () => { try { const o = JSON.parse(localStorage.getItem(RESULTS_KEY) || '{}'); return o && typeof o === 'object' ? o : {}; } catch { return {}; } };
const saveResults = (obj) => {
try { localStorage.setItem(RESULTS_KEY, JSON.stringify(obj)); } catch {
// Quota exceeded — drop the (non-essential) results cache rather than
// risk the query-list save above also failing later.
try { localStorage.removeItem(RESULTS_KEY); } catch { /* storage blocked */ }
}
};
// Drop any cached result set whose query is no longer in `list`.
const pruneResults = (list) => {
const keep = new Set(list.map(norm));
const r = loadResults();
let changed = false;
for (const k of Object.keys(r)) if (!keep.has(k)) { delete r[k]; changed = true; }
if (changed) saveResults(r);
};
return {
list: load,
add(q) {
@@ -95,10 +121,30 @@ const RecentSearches = (() => {
if (!q) return;
const list = load().filter((x) => x.toLowerCase() !== q.toLowerCase());
list.unshift(q);
save(list.slice(0, MAX));
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({}); },
// 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());
},
// 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;
},
remove(q) { save(load().filter((x) => x.toLowerCase() !== (q || '').toLowerCase())); },
clear() { save([]); },
};
})();
@@ -296,6 +342,10 @@ let searchResults = [];
// Distinguishes "never searched" (hide the list pane, show the landing) from
// "searched and got nothing back" (keep the pane so the user sees why).
let searchAttempted = false;
// Bumped on every dispatched search so a slower, superseded /api/search
// response (or background refresh — see runSearchQuery) can't clobber a
// newer one that already landed.
let searchSeq = 0;
let channelData = { name: '', url: '', key: '', results: [], loading: false };
let queue = []; // list of video objects for autoplay
let queueIndex = -1;
@@ -8521,29 +8571,51 @@ function wireUI() {
$('homePlGrid').addEventListener('scroll', updateHomeGridScrollState, { passive: true });
window.addEventListener('resize', updateHomeGridScrollState);
els.searchForm.addEventListener('submit', async (e) => {
e.preventDefault();
const q = els.searchInput.value.trim();
// Runs a search, optionally painting a cached result set immediately
// (`instant`, from RecentSearches.getResults) before the network reply
// lands — the perceived-speed win for re-running a recent search.
// `searchSeq` guards against a slower response overwriting a newer one
// (the instant paint above can itself be "in flight" behind a background
// refresh when the user searches something else right after).
async function runSearchQuery(q, { instant = null } = {}) {
q = (q || '').trim();
if (!q) return;
const mySeq = ++searchSeq;
RecentSearches.add(q);
hideRecentSearches();
view = { type: 'search' };
if (instant) {
searchResults = instant.results;
searchAttempted = true;
render();
} else {
render();
showSearchSkeletons();
}
// Even when already on the search view (no view change for render() to
// detect), a new query means the user wants to see the results.
scrollListIntoViewPortrait();
try {
const res = await API.search(q);
if (mySeq !== searchSeq) return; // superseded by a newer search
if (!res || !res.ok) throw new Error(res?.error || 'Search failed');
searchResults = res.results || [];
const results = res.results || [];
searchResults = results;
searchAttempted = true;
renderList();
RecentSearches.cacheResults(q, results);
} catch (err) {
if (mySeq !== searchSeq) return;
if (instant) return; // keep showing the cached results; fail silently
els.cards.innerHTML = '';
els.status.classList.remove('hidden');
els.status.textContent = '⚠ ' + err.message;
}
}
els.searchForm.addEventListener('submit', (e) => {
e.preventDefault();
runSearchQuery(els.searchInput.value);
});
// ---- Recent searches dropdown (device-local; see RecentSearches above) ----
@@ -8586,8 +8658,9 @@ function wireUI() {
const row = e.target.closest('.search-recent-row');
if (row) {
e.preventDefault();
els.searchInput.value = row.dataset.q;
els.searchForm.requestSubmit();
const q = row.dataset.q;
els.searchInput.value = q;
runSearchQuery(q, { instant: RecentSearches.getResults(q) });
}
});
document.addEventListener('click', (e) => {