cache recent search results locally so re-running one is instant
This commit is contained in:
@@ -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' };
|
||||
render();
|
||||
showSearchSkeletons();
|
||||
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) => {
|
||||
|
||||
Reference in New Issue
Block a user