From 9f632806773558017589b0dd83644a6ace37e805 Mon Sep 17 00:00:00 2001
From: Broque Thomas <26755000+Nezreka@users.noreply.github.com>
Date: Thu, 23 Apr 2026 16:14:08 -0700
Subject: [PATCH] Extract source-picker into shared createSearchController
factory
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Both the Search page and the global search widget ran the same source-
picker state machine (query, activeSource, per-query cache, fallbacks,
loading set, configured-source discovery, NDJSON streaming for YouTube,
default-source fall-forward). That was ~380 lines of near-duplicated
logic split across search.js and downloads.js, which meant every bug fix
or behavior tweak had to land twice and inevitably drifted.
createSearchController in shared-helpers.js now owns all of that. Each
surface passes per-surface wiring — a source-row DOM element, a CSS
class prefix, and callbacks for Soulseek handoff + unconfigured-source
redirect — and consumes the controller's state via an onStateChange
callback. The surface files shrink to their actual responsibilities:
results rendering, click handlers, and surface-specific visibility.
Zero UX change. Every keystroke, icon click, cache hit, rate-limit
fallback, and unconfigured-source redirect behaves identically to before
— verified via full pytest suite (395 passed) and node --check on all
three files.
WHATS_NEW entry added under the 2.40 unified-search bucket.
---
webui/static/downloads.js | 353 +++++++--------------------
webui/static/helper.js | 1 +
webui/static/search.js | 426 ++++++++-------------------------
webui/static/shared-helpers.js | 302 +++++++++++++++++++++++
4 files changed, 501 insertions(+), 581 deletions(-)
diff --git a/webui/static/downloads.js b/webui/static/downloads.js
index ec55d38f..75e1607d 100644
--- a/webui/static/downloads.js
+++ b/webui/static/downloads.js
@@ -5013,23 +5013,16 @@ function _gsClickVideo(cardEl) {
// GLOBAL SEARCH BAR — Spotlight-style search from anywhere
// ==================================================================================
+// Popover-only state. Query/source/cache/config all live in `_gsController`
+// (shared with the Search page via createSearchController in shared-helpers.js).
const _gsState = {
active: false,
- query: '',
- data: null, // most recent payload {db_artists: [...]} — used as library baseline
- activeSource: 'spotify', // current source icon (seeded from /api/settings on init)
- // Per-query cache for all sources. Cleared whenever `query` changes.
- sources: {}, // src -> {artists, albums, tracks, videos, db_artists, ...}
- fallbacks: {}, // src -> actual source served when backend fell back (rate-limit)
- loadingSources: new Set(),
- configuredSources: {}, // src -> bool, populated from /api/settings/config-status
- abortCtrl: null,
+ _lastInteraction: 0,
debounceTimer: null,
- _defaultSourceResolved: false,
};
-// Permissive initial map — replaced by the real config-status lookup on
-// first popover open. Prevents a flash of "all unconfigured" icons.
-for (const _src of SOURCE_ORDER) _gsState.configuredSources[_src] = true;
+
+// Shared source-picker controller — built on DOM-ready in `_doInit`.
+let _gsController = null;
(function initGlobalSearch() {
// Defer init until DOM is ready
@@ -5037,7 +5030,26 @@ for (const _src of SOURCE_ORDER) _gsState.configuredSources[_src] = true;
const bar = document.getElementById('gsearch-bar');
const input = document.getElementById('gsearch-input');
const results = document.getElementById('gsearch-results');
- if (!input || !bar) return;
+ if (!input || !bar || !results) return;
+
+ // Build the stable results-panel structure up front so the controller
+ // has a sourceRow element to render into on its first _notify().
+ results.innerHTML = `
+
+
+
+ `;
+
+ _gsController = createSearchController({
+ sourceRowElement: document.getElementById('gsearch-source-row'),
+ iconClassPrefix: 'gsearch',
+ onStateChange: _gsRenderFromState,
+ onSoulseekSelected: (query) => _gsNavigateToSearchPage(query, 'soulseek'),
+ onUnconfiguredClick: (src) => {
+ _gsDeactivate();
+ openSettingsForSource(src);
+ },
+ });
bar.addEventListener('click', () => input.focus());
@@ -5049,8 +5061,9 @@ for (const _src of SOURCE_ORDER) _gsState.configuredSources[_src] = true;
const shortcut = document.getElementById('gsearch-shortcut');
if (shortcut) shortcut.style.display = 'none';
// Always redraw on focus so the source icon row is current
- // (cache dots, active state, etc.).
- _gsInitDefaultSource().then(() => _gsRender());
+ // (cache dots, active state, etc.). init() is a no-op after the
+ // first call — safe to invoke on every focus.
+ _gsController.init().then(() => _gsRenderFromState(_gsController.state));
});
// No blur handler — closing is handled by click-outside and Escape only
@@ -5060,20 +5073,26 @@ for (const _src of SOURCE_ORDER) _gsState.configuredSources[_src] = true;
input.addEventListener('input', () => {
const q = input.value.trim();
- _gsState.query = q;
if (clearBtn) clearBtn.style.display = q.length > 0 ? '' : 'none';
if (_gsState.debounceTimer) clearTimeout(_gsState.debounceTimer);
if (q.length < 2) { _gsHideResults(); return; }
- _gsState.debounceTimer = setTimeout(() => _gsPerformSearch(q), 300);
+ _gsState.debounceTimer = setTimeout(() => _gsController.submitQuery(q), 300);
});
if (clearBtn) {
clearBtn.addEventListener('click', e => {
e.stopPropagation();
input.value = '';
- _gsState.query = '';
- _gsState.data = null;
clearBtn.style.display = 'none';
+ // Drop cache so the next search starts clean, but don't
+ // auto-fire a fetch for an empty query.
+ if (_gsController) {
+ _gsController.state.query = '';
+ _gsController.state.sources = {};
+ _gsController.state.fallbacks = {};
+ _gsController.state.loadingSources = new Set();
+ _gsController.renderSourceRow();
+ }
_gsHideResults();
input.focus();
});
@@ -5084,7 +5103,7 @@ for (const _src of SOURCE_ORDER) _gsState.configuredSources[_src] = true;
e.preventDefault();
if (_gsState.debounceTimer) clearTimeout(_gsState.debounceTimer);
const q = input.value.trim();
- if (q.length >= 2) _gsPerformSearch(q);
+ if (q.length >= 2) _gsController.submitQuery(q);
} else if (e.key === 'Escape') {
_gsDeactivate();
input.blur();
@@ -5158,140 +5177,6 @@ function _gsShowResults() {
if (r && r.innerHTML.trim()) r.classList.add('visible');
}
-async function _gsInitDefaultSource() {
- if (_gsState._defaultSourceResolved) return;
- _gsState._defaultSourceResolved = true;
- try {
- const resp = await fetch('/api/settings');
- if (resp.ok) {
- const settings = await resp.json();
- const cfg = settings.metadata && settings.metadata.fallback_source;
- if (cfg && SOURCE_LABELS[cfg]) _gsState.activeSource = cfg;
- }
- } catch (_) { /* best-effort */ }
- if (!SOURCE_LABELS[_gsState.activeSource]) _gsState.activeSource = 'spotify';
- // Fetch per-source configured state so unconfigured icons render dimmed.
- try {
- _gsState.configuredSources = await fetchSourceConfiguredMap();
- } catch (_) { /* keep optimistic default */ }
- // If the configured primary is itself unconfigured, fall forward to the
- // first configured source so the default active icon is actually usable.
- if (_gsState.configuredSources[_gsState.activeSource] === false) {
- const firstConfigured = SOURCE_ORDER.find(s => _gsState.configuredSources[s] !== false);
- if (firstConfigured) _gsState.activeSource = firstConfigured;
- }
-}
-
-async function _gsPerformSearch(query) {
- await _gsInitDefaultSource();
-
- // Query changed? Wipe the per-query cache so we never serve stale results
- // across different queries.
- if (query !== _gsState.query) {
- _gsState.query = query;
- _gsState.sources = {};
- _gsState.fallbacks = {};
- _gsState.loadingSources = new Set();
- }
-
- // Render current state immediately (icon row + loading/empty message)
- _gsRender();
-
- // Soulseek needs a full page to render raw file results — hand off to /search.
- if (_gsState.activeSource === 'soulseek') {
- _gsNavigateToSearchPage(query, 'soulseek');
- return;
- }
-
- // Cache hit? No fetch needed.
- if (_gsState.sources[_gsState.activeSource]) {
- setTimeout(() => _gsLibraryCheck(), 200);
- return;
- }
-
- await _gsFetchSource(_gsState.activeSource);
-}
-
-async function _gsFetchSource(src) {
- const query = _gsState.query;
- if (!query) return;
-
- _gsState.loadingSources.add(src);
- _gsRender();
-
- if (_gsState.abortCtrl) _gsState.abortCtrl.abort();
- _gsState.abortCtrl = new AbortController();
-
- try {
- if (src === 'youtube_videos') {
- await _gsFetchYouTubeVideos(query, _gsState.abortCtrl.signal);
- } else {
- const data = await enhancedSearchFetch(query, {
- source: src,
- signal: _gsState.abortCtrl.signal,
- });
- _gsState.sources[src] = {
- artists: data.spotify_artists || [],
- albums: data.spotify_albums || [],
- tracks: data.spotify_tracks || [],
- videos: [],
- db_artists: data.db_artists || [],
- };
- _gsState.data = data; // baseline for library-check helper
- const served = data.primary_source || data.metadata_source;
- if (served && served !== src) _gsState.fallbacks[src] = served;
- }
-
- _gsState.loadingSources.delete(src);
- _gsRender();
-
- if (_gsState.activeSource === src) {
- setTimeout(() => _gsLibraryCheck(), 200);
- }
- } catch (err) {
- _gsState.loadingSources.delete(src);
- _gsRender();
- if (err.name !== 'AbortError') console.debug(`GS source ${src} failed:`, err);
- }
-}
-
-async function _gsFetchYouTubeVideos(query, signal) {
- const res = await fetch('/api/enhanced-search/source/youtube_videos', {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({ query }),
- signal,
- });
- if (!res.ok) throw new Error('YouTube search failed');
-
- _gsState.sources['youtube_videos'] = {
- artists: [], albums: [], tracks: [], videos: [], db_artists: [],
- };
- const cache = _gsState.sources['youtube_videos'];
-
- const reader = res.body.getReader();
- const decoder = new TextDecoder();
- let buffer = '';
- while (true) {
- const { done, value } = await reader.read();
- if (done) break;
- buffer += decoder.decode(value, { stream: true });
- let idx;
- while ((idx = buffer.indexOf('\n')) !== -1) {
- const line = buffer.slice(0, idx).trim();
- buffer = buffer.slice(idx + 1);
- if (!line) continue;
- try {
- const chunk = JSON.parse(line);
- if (chunk.type === 'videos') {
- cache.videos = chunk.data;
- if (_gsState.activeSource === 'youtube_videos') _gsRender();
- }
- } catch (_) { /* best-effort */ }
- }
- }
-}
-
function _gsNavigateToSearchPage(query, src) {
_gsDeactivate();
if (typeof navigateToPage !== 'function') return;
@@ -5308,87 +5193,63 @@ function _gsNavigateToSearchPage(query, src) {
}, 300);
}
-function _gsSourceRowHtml() {
- return '
' + SOURCE_ORDER.map(src => {
- const info = SOURCE_LABELS[src];
- if (!info) return '';
- const active = src === _gsState.activeSource;
- const cached = !!_gsState.sources[src];
- const loading = _gsState.loadingSources.has(src);
- const fallback = _gsState.fallbacks[src];
- const configured = _gsState.configuredSources[src] !== false;
- const classes = ['gsearch-source-icon',
- active ? 'active' : '',
- cached ? 'cached' : '',
- loading ? 'loading' : '',
- fallback ? 'fallback-warning' : '',
- configured ? '' : 'unconfigured'].filter(Boolean).join(' ');
- let title;
- if (!configured) {
- title = `${info.text} — set up in Settings`;
- } else if (fallback) {
- title = `${info.text} unavailable — served from ${(SOURCE_LABELS[fallback] || {}).text || fallback}`;
- } else {
- title = info.text;
- }
- const glyph = loading
- ? '⏳'
- : (info.logo
- ? ``
- : info.icon);
- return ``;
- }).join('') + '
`;
results.classList.add('visible');
return;
}
- // No cache, not loading — user hasn't triggered a fetch (e.g. just switched sources).
+ // No cache, not loading — source switch before fetch fired (e.g. empty query).
if (!cached) {
- results.innerHTML = header + '
Click the source above to search.
';
+ body.innerHTML = '
Click the source above to search.
';
results.classList.add('visible');
return;
}
- // Music Videos — render video grid instead of regular sections.
+ // Music Videos — video grid instead of regular sections.
if (activeSrc === 'youtube_videos') {
const videos = cached.videos || [];
- let h = header;
- h += `
Results${videos.length} videos
`;
+ let h = `
Results${videos.length} videos
`;
h += '
';
if (videos.length === 0) {
h += `
No music videos found for "${_escToast(query)}"
`;
@@ -5410,12 +5271,12 @@ function _gsRender() {
h += '