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
|
// Recent searches — device-local only (localStorage), never synced to the
|
||||||
// server or another device. Most-recent-first, case-insensitive de-dupe.
|
// 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 RecentSearches = (() => {
|
||||||
const KEY = 'ytpRecentSearches';
|
const KEY = 'ytpRecentSearches';
|
||||||
|
const RESULTS_KEY = 'ytpRecentSearchResults';
|
||||||
const MAX = 10;
|
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 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 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 {
|
return {
|
||||||
list: load,
|
list: load,
|
||||||
add(q) {
|
add(q) {
|
||||||
@@ -95,10 +121,30 @@ const RecentSearches = (() => {
|
|||||||
if (!q) return;
|
if (!q) return;
|
||||||
const list = load().filter((x) => x.toLowerCase() !== q.toLowerCase());
|
const list = load().filter((x) => x.toLowerCase() !== q.toLowerCase());
|
||||||
list.unshift(q);
|
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
|
// Distinguishes "never searched" (hide the list pane, show the landing) from
|
||||||
// "searched and got nothing back" (keep the pane so the user sees why).
|
// "searched and got nothing back" (keep the pane so the user sees why).
|
||||||
let searchAttempted = false;
|
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 channelData = { name: '', url: '', key: '', results: [], loading: false };
|
||||||
let queue = []; // list of video objects for autoplay
|
let queue = []; // list of video objects for autoplay
|
||||||
let queueIndex = -1;
|
let queueIndex = -1;
|
||||||
@@ -8521,29 +8571,51 @@ function wireUI() {
|
|||||||
$('homePlGrid').addEventListener('scroll', updateHomeGridScrollState, { passive: true });
|
$('homePlGrid').addEventListener('scroll', updateHomeGridScrollState, { passive: true });
|
||||||
window.addEventListener('resize', updateHomeGridScrollState);
|
window.addEventListener('resize', updateHomeGridScrollState);
|
||||||
|
|
||||||
els.searchForm.addEventListener('submit', async (e) => {
|
// Runs a search, optionally painting a cached result set immediately
|
||||||
e.preventDefault();
|
// (`instant`, from RecentSearches.getResults) before the network reply
|
||||||
const q = els.searchInput.value.trim();
|
// 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;
|
if (!q) return;
|
||||||
|
const mySeq = ++searchSeq;
|
||||||
RecentSearches.add(q);
|
RecentSearches.add(q);
|
||||||
hideRecentSearches();
|
hideRecentSearches();
|
||||||
view = { type: 'search' };
|
view = { type: 'search' };
|
||||||
|
if (instant) {
|
||||||
|
searchResults = instant.results;
|
||||||
|
searchAttempted = true;
|
||||||
|
render();
|
||||||
|
} else {
|
||||||
render();
|
render();
|
||||||
showSearchSkeletons();
|
showSearchSkeletons();
|
||||||
|
}
|
||||||
// Even when already on the search view (no view change for render() to
|
// 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.
|
// detect), a new query means the user wants to see the results.
|
||||||
scrollListIntoViewPortrait();
|
scrollListIntoViewPortrait();
|
||||||
try {
|
try {
|
||||||
const res = await API.search(q);
|
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');
|
if (!res || !res.ok) throw new Error(res?.error || 'Search failed');
|
||||||
searchResults = res.results || [];
|
const results = res.results || [];
|
||||||
|
searchResults = results;
|
||||||
searchAttempted = true;
|
searchAttempted = true;
|
||||||
renderList();
|
renderList();
|
||||||
|
RecentSearches.cacheResults(q, results);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
if (mySeq !== searchSeq) return;
|
||||||
|
if (instant) return; // keep showing the cached results; fail silently
|
||||||
els.cards.innerHTML = '';
|
els.cards.innerHTML = '';
|
||||||
els.status.classList.remove('hidden');
|
els.status.classList.remove('hidden');
|
||||||
els.status.textContent = '⚠ ' + err.message;
|
els.status.textContent = '⚠ ' + err.message;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
els.searchForm.addEventListener('submit', (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
runSearchQuery(els.searchInput.value);
|
||||||
});
|
});
|
||||||
|
|
||||||
// ---- Recent searches dropdown (device-local; see RecentSearches above) ----
|
// ---- Recent searches dropdown (device-local; see RecentSearches above) ----
|
||||||
@@ -8586,8 +8658,9 @@ function wireUI() {
|
|||||||
const row = e.target.closest('.search-recent-row');
|
const row = e.target.closest('.search-recent-row');
|
||||||
if (row) {
|
if (row) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
els.searchInput.value = row.dataset.q;
|
const q = row.dataset.q;
|
||||||
els.searchForm.requestSubmit();
|
els.searchInput.value = q;
|
||||||
|
runSearchQuery(q, { instant: RecentSearches.getResults(q) });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
document.addEventListener('click', (e) => {
|
document.addEventListener('click', (e) => {
|
||||||
|
|||||||
Reference in New Issue
Block a user