Extract source-picker into shared createSearchController factory
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.
This commit is contained in:
parent
481d3d940f
commit
9f63280677
4 changed files with 501 additions and 581 deletions
|
|
@ -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 = `
|
||||
<div class="gsearch-source-row" id="gsearch-source-row"></div>
|
||||
<div class="gsearch-fallback-banner hidden" id="gsearch-fallback-banner"></div>
|
||||
<div id="gsearch-body"></div>
|
||||
`;
|
||||
|
||||
_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 '<div class="gsearch-source-row" id="gsearch-source-row">' + 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
|
||||
? `<img src="${_escAttr(info.logo)}" alt="" loading="lazy">`
|
||||
: info.icon);
|
||||
return `<button class="${classes}" data-source="${src}" onclick="_gsSetActiveSource('${src}')" title="${_escAttr(title)}">
|
||||
<span class="gsearch-source-icon-glyph">${glyph}</span>
|
||||
<span class="gsearch-source-icon-label">${_escToast(info.text)}</span>
|
||||
</button>`;
|
||||
}).join('') + '</div>';
|
||||
}
|
||||
|
||||
function _gsFallbackBannerHtml(src) {
|
||||
const actual = _gsState.fallbacks[src];
|
||||
if (!actual || actual === src) return '';
|
||||
const clicked = (SOURCE_LABELS[src] || {}).text || src;
|
||||
const served = (SOURCE_LABELS[actual] || {}).text || actual;
|
||||
return `<div class="gsearch-fallback-banner">${_escToast(clicked)} unavailable — showing ${_escToast(served)}.</div>`;
|
||||
}
|
||||
|
||||
function _gsRender() {
|
||||
// Re-render the results body + fallback banner whenever the controller's
|
||||
// state changes (cache hit, fetch settle, query reset). The icon row itself
|
||||
// is rendered by the controller into `#gsearch-source-row`.
|
||||
function _gsRenderFromState(state) {
|
||||
const results = document.getElementById('gsearch-results');
|
||||
if (!results) return;
|
||||
const body = document.getElementById('gsearch-body');
|
||||
if (!results || !body) return;
|
||||
|
||||
const activeSrc = _gsState.activeSource;
|
||||
const cached = _gsState.sources[activeSrc];
|
||||
const isLoading = _gsState.loadingSources.has(activeSrc);
|
||||
const query = _gsState.query;
|
||||
const header = _gsSourceRowHtml() + _gsFallbackBannerHtml(activeSrc);
|
||||
// Fallback banner — independent of body content.
|
||||
const banner = document.getElementById('gsearch-fallback-banner');
|
||||
const activeSrc = state.activeSource;
|
||||
const actual = state.fallbacks[activeSrc];
|
||||
if (banner) {
|
||||
if (actual && actual !== activeSrc) {
|
||||
const clicked = (SOURCE_LABELS[activeSrc] || {}).text || activeSrc;
|
||||
const served = (SOURCE_LABELS[actual] || {}).text || actual;
|
||||
banner.textContent = `${clicked} unavailable — showing ${served}.`;
|
||||
banner.classList.remove('hidden');
|
||||
} else {
|
||||
banner.classList.add('hidden');
|
||||
}
|
||||
}
|
||||
|
||||
// No query yet — just show the icon row and an empty hint.
|
||||
// Soulseek has its own dedicated handler (navigate to /search); there's
|
||||
// nothing to render in the popover.
|
||||
if (activeSrc === 'soulseek') return;
|
||||
|
||||
const cached = state.sources[activeSrc];
|
||||
const isLoading = state.loadingSources.has(activeSrc);
|
||||
const query = state.query;
|
||||
|
||||
// No query yet — prompt.
|
||||
if (!query) {
|
||||
results.innerHTML = header + '<div class="gsearch-empty">Type to search…</div>';
|
||||
body.innerHTML = '<div class="gsearch-empty">Type to search…</div>';
|
||||
results.classList.add('visible');
|
||||
return;
|
||||
}
|
||||
|
||||
// In-flight for this source, nothing cached yet — loading state.
|
||||
// In-flight, nothing cached yet — loading state.
|
||||
if (isLoading && !cached) {
|
||||
const info = SOURCE_LABELS[activeSrc];
|
||||
results.innerHTML = header
|
||||
+ `<div class="gsearch-loading"><div class="server-search-spinner"></div>Searching ${_escToast((info && info.text) || activeSrc)}...</div>`;
|
||||
body.innerHTML = `<div class="gsearch-loading"><div class="server-search-spinner"></div>Searching ${_escToast((info && info.text) || activeSrc)}...</div>`;
|
||||
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 + '<div class="gsearch-empty">Click the source above to search.</div>';
|
||||
body.innerHTML = '<div class="gsearch-empty">Click the source above to search.</div>';
|
||||
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 += `<div class="gsearch-results-header"><span class="gsearch-results-title">Results</span><span class="gsearch-results-count">${videos.length} videos</span></div>`;
|
||||
let h = `<div class="gsearch-results-header"><span class="gsearch-results-title">Results</span><span class="gsearch-results-count">${videos.length} videos</span></div>`;
|
||||
h += '<div class="gsearch-results-body">';
|
||||
if (videos.length === 0) {
|
||||
h += `<div class="gsearch-empty">No music videos found for "${_escToast(query)}"</div>`;
|
||||
|
|
@ -5410,12 +5271,12 @@ function _gsRender() {
|
|||
h += '</div>';
|
||||
}
|
||||
h += '</div>';
|
||||
results.innerHTML = h;
|
||||
body.innerHTML = h;
|
||||
results.classList.add('visible');
|
||||
return;
|
||||
}
|
||||
|
||||
// Standard metadata source — render library + artists + albums + singles + tracks.
|
||||
// Standard metadata source — library + artists + albums + singles + tracks.
|
||||
const dbArtists = cached.db_artists || [];
|
||||
const artists = cached.artists || [];
|
||||
const allAlbums = cached.albums || [];
|
||||
|
|
@ -5425,15 +5286,14 @@ function _gsRender() {
|
|||
const total = dbArtists.length + artists.length + albums.length + singles.length + tracks.length;
|
||||
|
||||
if (total === 0) {
|
||||
results.innerHTML = header
|
||||
+ `<div class="gsearch-empty">No results for "${_escToast(query)}"<br><span style="font-size:10px;opacity:0.5">Try different keywords or check spelling</span></div>`;
|
||||
body.innerHTML = `<div class="gsearch-empty">No results for "${_escToast(query)}"<br><span style="font-size:10px;opacity:0.5">Try different keywords or check spelling</span></div>`;
|
||||
results.classList.add('visible');
|
||||
return;
|
||||
}
|
||||
|
||||
const srcLabel = (SOURCE_LABELS[activeSrc] || {}).text || activeSrc || '';
|
||||
|
||||
let h = header;
|
||||
let h = '';
|
||||
h += `<div class="gsearch-results-header"><span class="gsearch-results-title">Results</span><span class="gsearch-results-count">${total} items</span></div>`;
|
||||
h += '<div class="gsearch-results-body">';
|
||||
|
||||
|
|
@ -5481,11 +5341,16 @@ function _gsRender() {
|
|||
}
|
||||
|
||||
h += '</div>';
|
||||
results.innerHTML = h;
|
||||
body.innerHTML = h;
|
||||
results.classList.add('visible');
|
||||
|
||||
// Lazy load artist images for sources that don't provide them (iTunes/Deezer)
|
||||
// Lazy load artist images for sources that don't provide them (iTunes/Deezer).
|
||||
_gsLazyLoadArtistImages();
|
||||
|
||||
// Library ownership check — adds "In Library" badges + swaps play buttons.
|
||||
// Idempotent enough to run on every render with a cache hit; the old flow
|
||||
// also fired it on both cache-hit and fetch-settle.
|
||||
setTimeout(() => _gsLibraryCheck(), 200);
|
||||
}
|
||||
|
||||
async function _gsLazyLoadArtistImages() {
|
||||
|
|
@ -5493,7 +5358,7 @@ async function _gsLazyLoadArtistImages() {
|
|||
if (!grid) return;
|
||||
const cards = grid.querySelectorAll('[data-needs-image="true"]');
|
||||
if (cards.length === 0) return;
|
||||
const activeSrc = _gsState.activeSource || 'spotify';
|
||||
const activeSrc = (_gsController && _gsController.state.activeSource) || 'spotify';
|
||||
|
||||
for (const card of cards) {
|
||||
const artistId = card.dataset.artistId;
|
||||
|
|
@ -5510,45 +5375,10 @@ async function _gsLazyLoadArtistImages() {
|
|||
}
|
||||
}
|
||||
|
||||
function _gsSetActiveSource(src) {
|
||||
if (!SOURCE_LABELS[src]) return;
|
||||
_gsState._lastInteraction = Date.now();
|
||||
|
||||
// Not configured — jump to Settings instead of firing a doomed search.
|
||||
if (_gsState.configuredSources[src] === false) {
|
||||
_gsDeactivate();
|
||||
openSettingsForSource(src);
|
||||
return;
|
||||
}
|
||||
|
||||
// Soulseek — hand off to the Search page since its raw file results need
|
||||
// more room than the popover provides.
|
||||
if (src === 'soulseek') {
|
||||
_gsNavigateToSearchPage(_gsState.query, 'soulseek');
|
||||
return;
|
||||
}
|
||||
|
||||
if (src === _gsState.activeSource) return;
|
||||
_gsState.activeSource = src;
|
||||
|
||||
// Cache hit — render from cached payload. Cache miss — fetch, which
|
||||
// will re-render on completion.
|
||||
if (_gsState.sources[src]) {
|
||||
_gsRender();
|
||||
} else if (_gsState.query) {
|
||||
_gsFetchSource(src);
|
||||
} else {
|
||||
// No query yet — just redraw the icon row so the active state changes.
|
||||
_gsRender();
|
||||
}
|
||||
|
||||
const input = document.getElementById('gsearch-input');
|
||||
if (input) input.focus();
|
||||
}
|
||||
|
||||
function _gsClickArtist(id, name, isLibrary) {
|
||||
_gsDeactivate();
|
||||
const source = isLibrary ? null : (_gsState.activeSource || null);
|
||||
const activeSource = _gsController && _gsController.state.activeSource;
|
||||
const source = isLibrary ? null : (activeSource || null);
|
||||
navigateToArtistDetail(id, name, source);
|
||||
}
|
||||
|
||||
|
|
@ -5680,7 +5510,8 @@ async function _gsPlayTrack(trackName, artistName, albumName) {
|
|||
// Async library check for global search results — adds badges + swaps play buttons
|
||||
async function _gsLibraryCheck() {
|
||||
try {
|
||||
const src = _gsState.sources[_gsState.activeSource] || {};
|
||||
if (!_gsController) return;
|
||||
const src = _gsController.state.sources[_gsController.state.activeSource] || {};
|
||||
const allAlbums = src.albums || [];
|
||||
const albums = allAlbums.filter(a => !a.album_type || a.album_type === 'album' || a.album_type === 'compilation');
|
||||
const singles = allAlbums.filter(a => a.album_type === 'single' || a.album_type === 'ep');
|
||||
|
|
|
|||
|
|
@ -3454,6 +3454,7 @@ const WHATS_NEW = {
|
|||
{ title: 'Artists Sidebar Entry Retired — Use Search Instead', desc: 'Cin flagged that "Artists" in the sidebar read like a library section but was actually a dedicated artist-search page, duplicating what the unified Search already does. The sidebar entry is gone. New flow: Sidebar → Search → type artist name → click their result. "Browse Artists" on the empty Watchlist page and "View artist from Wishlist" now open Search pre-filled with the artist\'s name. Removed "Artists" from profile Home Page + Page Access options. Deep link to /artists still resolves so old bookmarks keep working — the page just isn\'t promoted anywhere', page: 'search', unreleased: true },
|
||||
{ title: 'Artist Detail Back Button Fallback', desc: 'The back button on the Artists-page inline detail view used to dump users on an empty "Search for an artist..." screen when they arrived from outside the Artists page — a dead end now that Artists isn\'t in the sidebar. If you searched inside the Artists page, back still returns to your results list. Otherwise (arriving from Search, Discover, Watchlist, etc.), back uses the browser history to land you on whichever page you came from. Falls back to the Search page only when there\'s no browser history to go back to (the natural place to find another artist)', page: 'search', unreleased: true },
|
||||
{ title: 'Interactive Help Updated for Unified Search', desc: 'The click-for-help annotations and the "Your First Download" guided tour were rewritten for the new Search page. Stale annotations pointing at removed elements (Basic/Enhanced toggle button, side-panel queues, download-manager controls) are deleted. The first-download tour now runs on /search and opens with the source picker. PAGE_TOUR_MAP accepts both "search" and the legacy "downloads" id so old bookmarks still match a tour. Retired the standalone "Browse Artists" tour', page: 'help', unreleased: true },
|
||||
{ title: 'Unified Source-Picker Controller (Search Page + Global Widget)', desc: 'Internal refactor — the source picker state machine (query, active source, per-query cache, fallbacks, loading state, configured-source discovery) is now a single createSearchController factory in shared-helpers.js. Both the full Search page and the sidebar global search popover consume the same controller with per-surface wiring (DOM elements, Soulseek handoff, unconfigured-source click). About 380 lines of near-duplicated state + fetch + render code consolidated into one implementation, so a bug fix or behavior tweak to the picker lands everywhere at once. Zero UX change — every keystroke, icon click, cache hit, rate-limit fallback, and unconfigured-source redirect behaves identically to before', page: 'search', unreleased: true },
|
||||
],
|
||||
'2.39': [
|
||||
// --- April 22, 2026 ---
|
||||
|
|
|
|||
|
|
@ -55,32 +55,11 @@ function initializeSearchModeToggle() {
|
|||
searchModeToggleInitialized = true;
|
||||
console.log('✅ Initializing search source picker (first time only)');
|
||||
|
||||
// Active source — the icon the user is currently viewing. Initialized from
|
||||
// the user's configured primary source via /api/settings (below). No 'auto'
|
||||
// / fan-out mode: every search targets exactly one source.
|
||||
let currentSearchSource = 'spotify';
|
||||
// State + fetch dispatch + icon-row rendering live in the shared
|
||||
// `createSearchController` factory (shared-helpers.js) so this page and
|
||||
// the global search widget share one implementation. This closure wires
|
||||
// the controller up with Search-page-specific DOM + callbacks.
|
||||
|
||||
// Per-query cache. `sources[src]` holds the result payload the last time
|
||||
// `src` was fetched for the current query. `fallbacks[src]` records the
|
||||
// source the backend actually served when it auto-fell-back (e.g. user
|
||||
// clicked Spotify but got Deezer because Spotify is rate-limited).
|
||||
// `loadingSources` drives per-icon spinners. Cleared whenever the query
|
||||
// string changes — we never serve stale results across queries.
|
||||
let _cachedData = {
|
||||
query: '',
|
||||
sources: {},
|
||||
fallbacks: {},
|
||||
loadingSources: new Set(),
|
||||
};
|
||||
|
||||
// Which sources have credentials saved. Populated asynchronously on init
|
||||
// via /api/settings/config-status. Unconfigured sources render dimmed
|
||||
// and clicking them redirects to Settings → Connections instead of
|
||||
// firing a search.
|
||||
let _configuredSources = {};
|
||||
for (const src of SOURCE_ORDER) _configuredSources[src] = true; // optimistic default
|
||||
|
||||
// Initialize enhanced search
|
||||
const enhancedInput = document.getElementById('enhanced-search-input');
|
||||
const enhancedSearchBtn = document.getElementById('enhanced-search-btn');
|
||||
const enhancedCancelBtn = document.getElementById('enhanced-cancel-btn');
|
||||
|
|
@ -90,148 +69,92 @@ function initializeSearchModeToggle() {
|
|||
const resultsContainer = document.getElementById('enhanced-results-container');
|
||||
|
||||
let debounceTimer = null;
|
||||
let abortController = null;
|
||||
|
||||
// SOURCE_LABELS + SOURCE_ORDER now live in shared-helpers.js.
|
||||
|
||||
// ── Source icon row ────────────────────────────────────────────────
|
||||
function renderSourceRow() {
|
||||
sourceRow.innerHTML = SOURCE_ORDER.map(src => {
|
||||
const info = SOURCE_LABELS[src];
|
||||
if (!info) return '';
|
||||
const active = src === currentSearchSource;
|
||||
const cached = !!_cachedData.sources[src];
|
||||
const loading = _cachedData.loadingSources.has(src);
|
||||
const fallback = _cachedData.fallbacks[src];
|
||||
const configured = _configuredSources[src] !== false;
|
||||
const classes = [
|
||||
'enh-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;
|
||||
}
|
||||
// Prefer the brand logo when available; fall back to the emoji.
|
||||
// Spinner glyph overrides both while loading.
|
||||
const glyph = loading
|
||||
? '⏳'
|
||||
: (info.logo
|
||||
? `<img src="${escapeHtml(info.logo)}" alt="" loading="lazy">`
|
||||
: info.icon);
|
||||
return `
|
||||
<button class="${classes}" data-source="${src}" role="tab"
|
||||
aria-selected="${active}" title="${escapeHtml(title)}">
|
||||
<span class="enh-source-icon-glyph">${glyph}</span>
|
||||
<span class="enh-source-icon-label">${escapeHtml(info.text)}</span>
|
||||
</button>`;
|
||||
}).join('');
|
||||
sourceRow.querySelectorAll('.enh-source-icon').forEach(btn => {
|
||||
btn.addEventListener('click', (e) => {
|
||||
// Stop the outside-click document handler from dismissing the
|
||||
// dropdown. `renderSourceRow` re-renders after a source switch,
|
||||
// detaching this button from the DOM, so the bubbled handler's
|
||||
// `closest('#enh-source-row')` would fail on the detached target.
|
||||
e.stopPropagation();
|
||||
setActiveSource(btn.dataset.source);
|
||||
});
|
||||
});
|
||||
// ── Fallback banner ("Spotify unavailable — showing Deezer") ───────
|
||||
function _renderFallbackBanner(state) {
|
||||
const banner = document.getElementById('enh-fallback-banner');
|
||||
if (!banner) return;
|
||||
const src = state.activeSource;
|
||||
const actual = state.fallbacks[src];
|
||||
if (actual && actual !== src) {
|
||||
const clicked = (SOURCE_LABELS[src] || {}).text || src;
|
||||
const served = (SOURCE_LABELS[actual] || {}).text || actual;
|
||||
banner.textContent = `${clicked} unavailable — showing ${served}.`;
|
||||
banner.classList.remove('hidden');
|
||||
} else {
|
||||
banner.classList.add('hidden');
|
||||
}
|
||||
}
|
||||
|
||||
async function _initDefaultSource() {
|
||||
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]) {
|
||||
currentSearchSource = cfg;
|
||||
}
|
||||
}
|
||||
} catch (_) { /* settings fetch best-effort */ }
|
||||
if (!SOURCE_LABELS[currentSearchSource]) currentSearchSource = 'spotify';
|
||||
// Pull per-source configured state in parallel so the dimmed icons
|
||||
// don't flash on first render. Errors fall through to the optimistic
|
||||
// default set at init.
|
||||
try {
|
||||
_configuredSources = await fetchSourceConfiguredMap();
|
||||
} catch (_) { /* keep optimistic default */ }
|
||||
// If the configured primary turns out to be unconfigured (e.g.
|
||||
// spotify saved as primary but the user never entered credentials),
|
||||
// bump to the first source that is configured so the default click
|
||||
// doesn't land on a dimmed "set up" icon.
|
||||
if (_configuredSources[currentSearchSource] === false) {
|
||||
const firstConfigured = SOURCE_ORDER.find(s => _configuredSources[s] !== false);
|
||||
if (firstConfigured) currentSearchSource = firstConfigured;
|
||||
}
|
||||
renderSourceRow();
|
||||
}
|
||||
_initDefaultSource();
|
||||
// Central re-render callback — called by the controller whenever state
|
||||
// changes (cache hit, fetch settle, query reset). Drives the enhanced
|
||||
// dropdown UI: loading state, empty state, results render, fallback
|
||||
// banner.
|
||||
function _renderFromState(state) {
|
||||
const src = state.activeSource;
|
||||
|
||||
// ── Source selection ───────────────────────────────────────────────
|
||||
function setActiveSource(src) {
|
||||
if (!SOURCE_LABELS[src]) return;
|
||||
// Soulseek has its own surface (basic-section) — the controller fires
|
||||
// onSoulseekSelected for that, so there's nothing to render here.
|
||||
if (src === 'soulseek') return;
|
||||
|
||||
// Not configured — jump to the relevant card in Settings rather than
|
||||
// firing a search that can't succeed. Don't swap activeSource so the
|
||||
// user's previous pick stays current when they come back.
|
||||
if (_configuredSources[src] === false) {
|
||||
openSettingsForSource(src);
|
||||
return;
|
||||
}
|
||||
|
||||
if (src === currentSearchSource) return;
|
||||
currentSearchSource = src;
|
||||
|
||||
// Soulseek routes to the basic search UI (raw file results). We keep
|
||||
// no cache for it — the basic search renders straight to the page.
|
||||
if (src === 'soulseek') {
|
||||
basicSection.classList.add('active');
|
||||
enhancedSection.classList.remove('active');
|
||||
hideDropdown();
|
||||
renderSourceRow();
|
||||
const basicInput = document.getElementById('downloads-search-input');
|
||||
if (basicInput && _cachedData.query) {
|
||||
basicInput.value = _cachedData.query;
|
||||
if (typeof performDownloadsSearch === 'function') performDownloadsSearch();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Other sources use the enhanced dropdown.
|
||||
// Ensure the enhanced section is visible (may have been hidden if the
|
||||
// user was previously on Soulseek).
|
||||
basicSection.classList.remove('active');
|
||||
enhancedSection.classList.add('active');
|
||||
renderSourceRow();
|
||||
|
||||
if (!_cachedData.query) return;
|
||||
_renderFallbackBanner(state);
|
||||
|
||||
if (_cachedData.sources[src]) {
|
||||
_renderFromCache(src);
|
||||
} else {
|
||||
_fetchAndRenderSource(src);
|
||||
const cached = state.sources[src];
|
||||
const loading = state.loadingSources.has(src);
|
||||
|
||||
// Mid-fetch with no cache yet → loading state.
|
||||
if (loading && !cached) {
|
||||
emptyState.classList.add('hidden');
|
||||
resultsContainer.classList.add('hidden');
|
||||
loadingState.classList.remove('hidden');
|
||||
const loadingText = document.getElementById('enhanced-loading-text');
|
||||
if (loadingText) {
|
||||
const info = SOURCE_LABELS[src];
|
||||
loadingText.textContent = `Searching ${(info && info.text) || src} and your library...`;
|
||||
}
|
||||
showDropdown();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Render + fetch ─────────────────────────────────────────────────
|
||||
function _renderFromCache(src) {
|
||||
const cached = _cachedData.sources[src];
|
||||
if (!cached) return;
|
||||
// No cache + no query → nothing to show; hide the dropdown.
|
||||
if (!cached) {
|
||||
if (!state.query) {
|
||||
hideDropdown();
|
||||
return;
|
||||
}
|
||||
// Fetch settled with no data — empty state.
|
||||
loadingState.classList.add('hidden');
|
||||
resultsContainer.classList.add('hidden');
|
||||
emptyState.classList.remove('hidden');
|
||||
showDropdown();
|
||||
return;
|
||||
}
|
||||
|
||||
const total = src === 'youtube_videos'
|
||||
? ((cached.videos && cached.videos.length) || 0)
|
||||
: ((cached.db_artists && cached.db_artists.length) || 0)
|
||||
+ ((cached.artists && cached.artists.length) || 0)
|
||||
+ ((cached.albums && cached.albums.length) || 0)
|
||||
+ ((cached.tracks && cached.tracks.length) || 0);
|
||||
|
||||
loadingState.classList.add('hidden');
|
||||
|
||||
if (total === 0) {
|
||||
resultsContainer.classList.add('hidden');
|
||||
emptyState.classList.remove('hidden');
|
||||
showDropdown();
|
||||
return;
|
||||
}
|
||||
|
||||
emptyState.classList.add('hidden');
|
||||
resultsContainer.classList.remove('hidden');
|
||||
showDropdown();
|
||||
|
||||
_renderFallbackBanner(src);
|
||||
|
||||
if (src === 'youtube_videos') {
|
||||
['enh-db-artists-section', 'enh-spotify-artists-section', 'enh-albums-section', 'enh-singles-section', 'enh-tracks-section'].forEach(id => {
|
||||
const el = document.getElementById(id);
|
||||
|
|
@ -257,136 +180,27 @@ function initializeSearchModeToggle() {
|
|||
});
|
||||
}
|
||||
|
||||
function _renderFallbackBanner(src) {
|
||||
const banner = document.getElementById('enh-fallback-banner');
|
||||
if (!banner) return;
|
||||
const actual = _cachedData.fallbacks[src];
|
||||
if (actual && actual !== src) {
|
||||
const clicked = (SOURCE_LABELS[src] || {}).text || src;
|
||||
const served = (SOURCE_LABELS[actual] || {}).text || actual;
|
||||
banner.textContent = `${clicked} unavailable — showing ${served}.`;
|
||||
banner.classList.remove('hidden');
|
||||
} else {
|
||||
banner.classList.add('hidden');
|
||||
}
|
||||
}
|
||||
|
||||
async function _fetchAndRenderSource(src) {
|
||||
const query = _cachedData.query;
|
||||
if (!query) return;
|
||||
|
||||
_cachedData.loadingSources.add(src);
|
||||
renderSourceRow();
|
||||
|
||||
showDropdown();
|
||||
emptyState.classList.add('hidden');
|
||||
resultsContainer.classList.add('hidden');
|
||||
loadingState.classList.remove('hidden');
|
||||
const loadingText = document.getElementById('enhanced-loading-text');
|
||||
if (loadingText) {
|
||||
const info = SOURCE_LABELS[src];
|
||||
loadingText.textContent = `Searching ${(info && info.text) || src} and your library...`;
|
||||
}
|
||||
|
||||
if (abortController) abortController.abort();
|
||||
abortController = new AbortController();
|
||||
|
||||
try {
|
||||
if (src === 'youtube_videos') {
|
||||
await _fetchYouTubeVideos(query, abortController.signal);
|
||||
} else {
|
||||
const data = await enhancedSearchFetch(query, {
|
||||
source: src,
|
||||
signal: abortController.signal,
|
||||
});
|
||||
_cachedData.sources[src] = {
|
||||
artists: data.spotify_artists || [],
|
||||
albums: data.spotify_albums || [],
|
||||
tracks: data.spotify_tracks || [],
|
||||
videos: [],
|
||||
available: true,
|
||||
db_artists: data.db_artists || [],
|
||||
};
|
||||
const served = data.primary_source || data.metadata_source;
|
||||
if (served && served !== src) {
|
||||
_cachedData.fallbacks[src] = served;
|
||||
const searchController = createSearchController({
|
||||
sourceRowElement: sourceRow,
|
||||
iconClassPrefix: 'enh',
|
||||
onStateChange: _renderFromState,
|
||||
onSoulseekSelected: (query) => {
|
||||
// Soulseek returns raw file results, rendered by the basic-search
|
||||
// UI — swap sections and re-fire the basic search with the
|
||||
// current query.
|
||||
basicSection.classList.add('active');
|
||||
enhancedSection.classList.remove('active');
|
||||
hideDropdown();
|
||||
const basicInput = document.getElementById('downloads-search-input');
|
||||
if (basicInput) {
|
||||
if (query) basicInput.value = query;
|
||||
if (basicInput.value && typeof performDownloadsSearch === 'function') {
|
||||
performDownloadsSearch();
|
||||
}
|
||||
}
|
||||
|
||||
_cachedData.loadingSources.delete(src);
|
||||
renderSourceRow();
|
||||
|
||||
if (currentSearchSource === src) {
|
||||
const cached = _cachedData.sources[src];
|
||||
const total = src === 'youtube_videos'
|
||||
? ((cached && cached.videos && cached.videos.length) || 0)
|
||||
: ((cached && cached.db_artists && cached.db_artists.length) || 0)
|
||||
+ ((cached && cached.artists && cached.artists.length) || 0)
|
||||
+ ((cached && cached.albums && cached.albums.length) || 0)
|
||||
+ ((cached && cached.tracks && cached.tracks.length) || 0);
|
||||
loadingState.classList.add('hidden');
|
||||
if (total === 0) {
|
||||
emptyState.classList.remove('hidden');
|
||||
} else {
|
||||
_renderFromCache(src);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
_cachedData.loadingSources.delete(src);
|
||||
renderSourceRow();
|
||||
if (err.name !== 'AbortError') {
|
||||
console.error(`Source fetch failed for ${src}:`, err);
|
||||
if (currentSearchSource === src) {
|
||||
loadingState.classList.add('hidden');
|
||||
emptyState.classList.remove('hidden');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function _fetchYouTubeVideos(query, signal) {
|
||||
const response = await fetch('/api/enhanced-search/source/youtube_videos', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ query }),
|
||||
signal,
|
||||
});
|
||||
if (!response.ok) throw new Error(`YouTube search failed: ${response.status}`);
|
||||
|
||||
_cachedData.sources['youtube_videos'] = {
|
||||
artists: [], albums: [], tracks: [], videos: [],
|
||||
available: true, db_artists: [],
|
||||
};
|
||||
const cache = _cachedData.sources['youtube_videos'];
|
||||
|
||||
const reader = response.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 newlineIdx;
|
||||
while ((newlineIdx = buffer.indexOf('\n')) !== -1) {
|
||||
const line = buffer.slice(0, newlineIdx).trim();
|
||||
buffer = buffer.slice(newlineIdx + 1);
|
||||
if (!line) continue;
|
||||
try {
|
||||
const chunk = JSON.parse(line);
|
||||
if (chunk.type === 'videos') {
|
||||
cache.videos = chunk.data;
|
||||
// Live-render if still active — lets the user see results
|
||||
// as soon as the chunk arrives.
|
||||
if (currentSearchSource === 'youtube_videos') {
|
||||
_renderFromCache('youtube_videos');
|
||||
}
|
||||
}
|
||||
} catch (_) { /* best-effort NDJSON parse */ }
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
searchController.init();
|
||||
|
||||
// Live search with debouncing
|
||||
if (enhancedInput) {
|
||||
|
|
@ -409,7 +223,7 @@ function initializeSearchModeToggle() {
|
|||
|
||||
// Debounce search
|
||||
debounceTimer = setTimeout(() => {
|
||||
performEnhancedSearch(query);
|
||||
searchController.submitQuery(query);
|
||||
}, 300);
|
||||
});
|
||||
|
||||
|
|
@ -418,7 +232,7 @@ function initializeSearchModeToggle() {
|
|||
const query = e.target.value.trim();
|
||||
if (query.length >= 2) {
|
||||
clearTimeout(debounceTimer);
|
||||
performEnhancedSearch(query);
|
||||
searchController.submitQuery(query);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
|
@ -487,44 +301,14 @@ function initializeSearchModeToggle() {
|
|||
}
|
||||
});
|
||||
|
||||
async function performEnhancedSearch(query) {
|
||||
console.log('Enhanced search:', query, '→', currentSearchSource);
|
||||
|
||||
// Query changed? Drop the whole per-query cache so we never serve
|
||||
// stale results across queries. Icon dots and fallback banner reset.
|
||||
if (query !== _cachedData.query) {
|
||||
_cachedData.query = query;
|
||||
_cachedData.sources = {};
|
||||
_cachedData.fallbacks = {};
|
||||
_cachedData.loadingSources = new Set();
|
||||
renderSourceRow();
|
||||
}
|
||||
|
||||
// Soulseek → existing basic search flow, no cache here.
|
||||
if (currentSearchSource === 'soulseek') {
|
||||
const basicInput = document.getElementById('downloads-search-input');
|
||||
if (basicInput) {
|
||||
basicInput.value = query;
|
||||
if (typeof performDownloadsSearch === 'function') performDownloadsSearch();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Same query + same source that's already cached → instant render.
|
||||
if (_cachedData.sources[currentSearchSource]) {
|
||||
_renderFromCache(currentSearchSource);
|
||||
return;
|
||||
}
|
||||
|
||||
await _fetchAndRenderSource(currentSearchSource);
|
||||
}
|
||||
|
||||
function renderDropdownResults(data) {
|
||||
const activeSource = searchController.state.activeSource;
|
||||
|
||||
// Music Videos tab — don't render regular sections
|
||||
if (currentSearchSource === 'youtube_videos') return;
|
||||
if (activeSource === 'youtube_videos') return;
|
||||
|
||||
// Determine source badge from active tab (not just primary)
|
||||
const displaySource = currentSearchSource || data.metadata_source || 'spotify';
|
||||
const displaySource = activeSource || data.metadata_source || 'spotify';
|
||||
const sourceInfo = SOURCE_LABELS[displaySource] || SOURCE_LABELS.spotify;
|
||||
const sourceBadge = { text: sourceInfo.text, class: sourceInfo.badgeClass };
|
||||
|
||||
|
|
@ -561,7 +345,7 @@ function initializeSearchModeToggle() {
|
|||
meta: 'Artist',
|
||||
badge: sourceBadge,
|
||||
onClick: () => {
|
||||
const sourceOverride = currentSearchSource;
|
||||
const sourceOverride = searchController.state.activeSource;
|
||||
console.log(`🎵 Opening artist detail: ${artist.name} (ID: ${artist.id}, source: ${sourceOverride})`);
|
||||
hideDropdown();
|
||||
navigateToArtistDetail(artist.id, artist.name, sourceOverride || null);
|
||||
|
|
@ -797,8 +581,9 @@ function initializeSearchModeToggle() {
|
|||
if (!artistId) continue;
|
||||
|
||||
try {
|
||||
const imgUrl = currentSearchSource && currentSearchSource !== 'spotify'
|
||||
? `/api/artist/${artistId}/image?source=${currentSearchSource}`
|
||||
const activeSource = searchController.state.activeSource;
|
||||
const imgUrl = activeSource && activeSource !== 'spotify'
|
||||
? `/api/artist/${artistId}/image?source=${activeSource}`
|
||||
: `/api/artist/${artistId}/image`;
|
||||
const response = await fetch(imgUrl);
|
||||
const data = await response.json();
|
||||
|
|
@ -846,8 +631,9 @@ function initializeSearchModeToggle() {
|
|||
try {
|
||||
// Fetch full album data with tracks — pass source for correct routing
|
||||
const albumParams = new URLSearchParams({ name: album.name || '', artist: album.artist || '' });
|
||||
if (currentSearchSource && currentSearchSource !== 'spotify') {
|
||||
albumParams.set('source', currentSearchSource);
|
||||
const activeSource = searchController.state.activeSource;
|
||||
if (activeSource && activeSource !== 'spotify') {
|
||||
albumParams.set('source', activeSource);
|
||||
}
|
||||
// Pass Hydrabase plugin origin so server routes to correct client
|
||||
if (album.external_urls?.hydrabase_plugin) {
|
||||
|
|
@ -913,7 +699,7 @@ function initializeSearchModeToggle() {
|
|||
id: firstArtist.id || album.id?.split?.('_')?.[0] || '',
|
||||
name: firstArtist.name || album.artist,
|
||||
image_url: firstArtist.image_url || firstArtist.images?.[0]?.url || '',
|
||||
source: currentSearchSource || '',
|
||||
source: activeSource || '',
|
||||
};
|
||||
|
||||
// Prepare full album object for modal
|
||||
|
|
|
|||
|
|
@ -114,6 +114,308 @@ async function fetchSourceConfiguredMap() {
|
|||
return map;
|
||||
}
|
||||
|
||||
// Shared source-picker controller used by both the unified Search page
|
||||
// and the global search widget. Owns all the query/active-source/per-query
|
||||
// cache state, fetch dispatch (enhanced-search for standard sources, NDJSON
|
||||
// for YouTube Music Videos), configured-source discovery, fallback tracking,
|
||||
// and icon-row rendering. Each surface passes per-surface wiring — DOM
|
||||
// elements, a CSS class prefix, and callbacks — and the controller takes
|
||||
// care of the rest.
|
||||
//
|
||||
// Config:
|
||||
// sourceRowElement — HTMLElement where the icon row is rendered
|
||||
// iconClassPrefix — 'enh' or 'gsearch' (drives CSS class names)
|
||||
// onStateChange(state) — called whenever the surface should re-render
|
||||
// results (cache hit, fetch settle, query reset)
|
||||
// onSoulseekSelected(q) — surface decides what happens when the user
|
||||
// clicks the Soulseek icon (basic-section swap
|
||||
// on the Search page, /search handoff on the
|
||||
// global widget)
|
||||
// onUnconfiguredClick(src)— override the default "open Settings" behaviour
|
||||
//
|
||||
// Returned methods:
|
||||
// init() — async; reads /api/settings + /api/settings/
|
||||
// config-status, seeds default source, falls
|
||||
// forward if primary is unconfigured, draws row
|
||||
// submitQuery(query) — user typed a new query (clears cache on change)
|
||||
// setActiveSource(src) — user clicked a different source icon
|
||||
// renderSourceRow() — re-draws the icon row (call after state edits)
|
||||
function createSearchController({
|
||||
sourceRowElement,
|
||||
iconClassPrefix = 'enh',
|
||||
onStateChange,
|
||||
onSoulseekSelected,
|
||||
onUnconfiguredClick,
|
||||
} = {}) {
|
||||
const iconClass = `${iconClassPrefix}-source-icon`;
|
||||
const glyphClass = `${iconClassPrefix}-source-icon-glyph`;
|
||||
const labelClass = `${iconClassPrefix}-source-icon-label`;
|
||||
|
||||
// Per-query cache. `sources[src]` holds the result payload the last
|
||||
// time `src` was fetched for the current query. `fallbacks[src]`
|
||||
// records the source the backend actually served when it auto-fell-
|
||||
// back (e.g. user clicked Spotify but got Deezer because Spotify is
|
||||
// rate-limited). `loadingSources` drives per-icon spinners. The whole
|
||||
// cache is cleared whenever the query string changes — we never
|
||||
// serve stale results across queries.
|
||||
const state = {
|
||||
query: '',
|
||||
activeSource: 'spotify',
|
||||
sources: {},
|
||||
fallbacks: {},
|
||||
loadingSources: new Set(),
|
||||
configuredSources: {},
|
||||
_initialized: false,
|
||||
};
|
||||
// Optimistic default — replaced by the real config-status lookup on
|
||||
// init. Prevents a flash of "all unconfigured" icons.
|
||||
for (const src of SOURCE_ORDER) state.configuredSources[src] = true;
|
||||
|
||||
let abortCtrl = null;
|
||||
|
||||
function _notify() { if (onStateChange) onStateChange(state); }
|
||||
|
||||
function renderSourceRow() {
|
||||
if (!sourceRowElement) return;
|
||||
sourceRowElement.innerHTML = SOURCE_ORDER.map(src => {
|
||||
const info = SOURCE_LABELS[src];
|
||||
if (!info) return '';
|
||||
const active = src === state.activeSource;
|
||||
const cached = !!state.sources[src];
|
||||
const loading = state.loadingSources.has(src);
|
||||
const fallback = state.fallbacks[src];
|
||||
const configured = state.configuredSources[src] !== false;
|
||||
|
||||
const classes = [
|
||||
iconClass,
|
||||
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
|
||||
? `<img src="${escapeHtml(info.logo)}" alt="" loading="lazy">`
|
||||
: info.icon);
|
||||
|
||||
return `
|
||||
<button class="${classes}" data-source="${src}" role="tab"
|
||||
aria-selected="${active}" title="${escapeHtml(title)}">
|
||||
<span class="${glyphClass}">${glyph}</span>
|
||||
<span class="${labelClass}">${escapeHtml(info.text)}</span>
|
||||
</button>`;
|
||||
}).join('');
|
||||
|
||||
sourceRowElement.querySelectorAll(`.${iconClass}`).forEach(btn => {
|
||||
btn.addEventListener('click', (e) => {
|
||||
// stopPropagation prevents surface-level outside-click handlers
|
||||
// from dismissing the results while we re-render the icon row
|
||||
// (which detaches the clicked button from the DOM).
|
||||
e.stopPropagation();
|
||||
setActiveSource(btn.dataset.source);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function init() {
|
||||
if (state._initialized) return;
|
||||
state._initialized = true;
|
||||
|
||||
// Resolve the user's configured primary source.
|
||||
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]) state.activeSource = cfg;
|
||||
}
|
||||
} catch (_) { /* best-effort */ }
|
||||
if (!SOURCE_LABELS[state.activeSource]) state.activeSource = 'spotify';
|
||||
|
||||
// Figure out which sources actually have credentials saved.
|
||||
try {
|
||||
state.configuredSources = await fetchSourceConfiguredMap();
|
||||
} catch (_) { /* keep optimistic default */ }
|
||||
|
||||
// If the configured primary is itself unconfigured (Spotify saved
|
||||
// as primary but no client_id yet), fall forward to the first
|
||||
// configured source so the default active icon is usable.
|
||||
if (state.configuredSources[state.activeSource] === false) {
|
||||
const firstConfigured = SOURCE_ORDER.find(s => state.configuredSources[s] !== false);
|
||||
if (firstConfigured) state.activeSource = firstConfigured;
|
||||
}
|
||||
|
||||
renderSourceRow();
|
||||
_notify();
|
||||
}
|
||||
|
||||
function setActiveSource(src) {
|
||||
if (!SOURCE_LABELS[src]) return;
|
||||
|
||||
// Unconfigured — jump to the relevant card in Settings rather than
|
||||
// firing a search that can't succeed. Don't swap activeSource so the
|
||||
// user's previous pick stays current when they come back.
|
||||
if (state.configuredSources[src] === false) {
|
||||
if (onUnconfiguredClick) onUnconfiguredClick(src);
|
||||
else openSettingsForSource(src);
|
||||
return;
|
||||
}
|
||||
|
||||
// Clicking the already-active source is a no-op for normal sources,
|
||||
// but for Soulseek we still re-fire the callback so the surface can
|
||||
// re-issue the handoff (e.g. user typed and wants a fresh search).
|
||||
if (src === state.activeSource) {
|
||||
if (src === 'soulseek' && onSoulseekSelected) onSoulseekSelected(state.query);
|
||||
return;
|
||||
}
|
||||
|
||||
state.activeSource = src;
|
||||
renderSourceRow();
|
||||
|
||||
// Soulseek — let the surface decide what to do (basic-section swap
|
||||
// on Search page, /search handoff on global widget). We don't cache
|
||||
// or auto-fetch soulseek results in the controller.
|
||||
if (src === 'soulseek') {
|
||||
if (onSoulseekSelected) onSoulseekSelected(state.query);
|
||||
return;
|
||||
}
|
||||
|
||||
if (state.sources[src]) {
|
||||
_notify();
|
||||
} else if (state.query) {
|
||||
_fetchSource(src);
|
||||
} else {
|
||||
_notify();
|
||||
}
|
||||
}
|
||||
|
||||
async function _fetchSource(src) {
|
||||
const query = state.query;
|
||||
if (!query) return;
|
||||
|
||||
state.loadingSources.add(src);
|
||||
renderSourceRow();
|
||||
_notify();
|
||||
|
||||
if (abortCtrl) abortCtrl.abort();
|
||||
abortCtrl = new AbortController();
|
||||
|
||||
try {
|
||||
if (src === 'youtube_videos') {
|
||||
await _fetchYouTubeVideos(query, abortCtrl.signal);
|
||||
} else {
|
||||
const data = await enhancedSearchFetch(query, {
|
||||
source: src,
|
||||
signal: abortCtrl.signal,
|
||||
});
|
||||
state.sources[src] = {
|
||||
artists: data.spotify_artists || [],
|
||||
albums: data.spotify_albums || [],
|
||||
tracks: data.spotify_tracks || [],
|
||||
videos: [],
|
||||
db_artists: data.db_artists || [],
|
||||
};
|
||||
const served = data.primary_source || data.metadata_source;
|
||||
if (served && served !== src) state.fallbacks[src] = served;
|
||||
}
|
||||
|
||||
state.loadingSources.delete(src);
|
||||
renderSourceRow();
|
||||
_notify();
|
||||
} catch (err) {
|
||||
state.loadingSources.delete(src);
|
||||
renderSourceRow();
|
||||
_notify();
|
||||
if (err.name !== 'AbortError') {
|
||||
console.debug(`Source fetch failed for ${src}:`, err);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function _fetchYouTubeVideos(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: ${res.status}`);
|
||||
|
||||
state.sources['youtube_videos'] = {
|
||||
artists: [], albums: [], tracks: [], videos: [], db_artists: [],
|
||||
};
|
||||
const cache = state.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;
|
||||
// Live-render if still the active source.
|
||||
if (state.activeSource === 'youtube_videos') _notify();
|
||||
}
|
||||
} catch (_) { /* best-effort NDJSON parse */ }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function submitQuery(query) {
|
||||
if (query !== state.query) {
|
||||
state.query = query;
|
||||
state.sources = {};
|
||||
state.fallbacks = {};
|
||||
state.loadingSources = new Set();
|
||||
renderSourceRow();
|
||||
}
|
||||
|
||||
// Soulseek — surface handles the full query handoff.
|
||||
if (state.activeSource === 'soulseek') {
|
||||
if (onSoulseekSelected) onSoulseekSelected(query);
|
||||
return;
|
||||
}
|
||||
|
||||
// Cache hit — instant re-render, no fetch.
|
||||
if (state.sources[state.activeSource]) {
|
||||
_notify();
|
||||
return;
|
||||
}
|
||||
|
||||
_fetchSource(state.activeSource);
|
||||
}
|
||||
|
||||
return {
|
||||
state,
|
||||
init,
|
||||
submitQuery,
|
||||
setActiveSource,
|
||||
renderSourceRow,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
// Navigate to Settings → Connections tab and scroll to the service card that
|
||||
// matches the picker's source id. Called when a user clicks an unconfigured
|
||||
// source icon.
|
||||
|
|
|
|||
Loading…
Reference in a new issue