Global search widget: same source-picker icon row + per-source cache
Matches the Search page redesign so both surfaces behave identically. The sidebar popover previously always fan-out-fetched all sources on every keystroke (via _gsFetchSourceStream streaming NDJSON for every alternate) and exposed a post-search tab bar to switch views. Now: - The popover renders an always-visible source icon row at the top, one icon per source (Spotify, iTunes, Deezer, Discogs, Hydrabase, MusicBrainz, Music Videos, Soulseek). - Typing fetches only the currently-selected source. No fan-out. - Clicking a different icon: cache hit -> instant re-render; cache miss -> single-source fetch + render. - Per-query cache cleared on query change; cache dots on icons show which sources already have results for the current query. - Default active source read from /api/settings (metadata.fallback_source) on first focus; falls back to Spotify. - Fallback banner shown when the backend served a different source than the one clicked (rate-limit auto-fallback). - Soulseek icon click navigates to /search with the query pre-filled, since the raw file list doesn't fit the popover. The Search page takes over rendering from there. Gone: _gsFetchSourceStream (fan-out), _gsRenderTabs, _gsSwitchSource, _gsState.altAbortCtrl, per-section _loading sets. Added: _gsInitDefaultSource, _gsFetchSource, _gsFetchYouTubeVideos, _gsSourceRowHtml, _gsFallbackBannerHtml, _gsSetActiveSource, _gsNavigateToSearchPage.
This commit is contained in:
parent
a72810ce22
commit
9ddfcf254f
2 changed files with 318 additions and 149 deletions
|
|
@ -5016,12 +5016,15 @@ function _gsClickVideo(cardEl) {
|
|||
const _gsState = {
|
||||
active: false,
|
||||
query: '',
|
||||
data: null,
|
||||
sources: {},
|
||||
activeSource: null,
|
||||
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(),
|
||||
abortCtrl: null,
|
||||
altAbortCtrl: null,
|
||||
debounceTimer: null,
|
||||
_defaultSourceResolved: false,
|
||||
};
|
||||
|
||||
(function initGlobalSearch() {
|
||||
|
|
@ -5039,7 +5042,9 @@ const _gsState = {
|
|||
_gsState.active = true;
|
||||
const shortcut = document.getElementById('gsearch-shortcut');
|
||||
if (shortcut) shortcut.style.display = 'none';
|
||||
if (_gsState.data && _gsState.query) _gsShowResults();
|
||||
// Always redraw on focus so the source icon row is current
|
||||
// (cache dots, active state, etc.).
|
||||
_gsInitDefaultSource().then(() => _gsRender());
|
||||
});
|
||||
|
||||
// No blur handler — closing is handled by click-outside and Escape only
|
||||
|
|
@ -5143,113 +5148,218 @@ 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';
|
||||
}
|
||||
|
||||
async function _gsPerformSearch(query) {
|
||||
if (_gsState.abortCtrl) _gsState.abortCtrl.abort();
|
||||
if (_gsState.altAbortCtrl) _gsState.altAbortCtrl.abort();
|
||||
_gsState.abortCtrl = new AbortController();
|
||||
_gsState.altAbortCtrl = new AbortController();
|
||||
await _gsInitDefaultSource();
|
||||
|
||||
const results = document.getElementById('gsearch-results');
|
||||
if (!results) return;
|
||||
|
||||
results.innerHTML = '<div class="gsearch-loading"><div class="server-search-spinner"></div>Searching...</div>';
|
||||
results.classList.add('visible');
|
||||
|
||||
try {
|
||||
const data = await enhancedSearchFetch(query, { signal: _gsState.abortCtrl.signal });
|
||||
_gsState.data = data;
|
||||
_gsState.activeSource = data.primary_source || 'spotify';
|
||||
// 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.sources[_gsState.activeSource] = {
|
||||
artists: data.spotify_artists || [],
|
||||
albums: data.spotify_albums || [],
|
||||
tracks: data.spotify_tracks || [],
|
||||
};
|
||||
_gsState.fallbacks = {};
|
||||
_gsState.loadingSources = new Set();
|
||||
}
|
||||
|
||||
_gsRender(data);
|
||||
// Render current state immediately (icon row + loading/empty message)
|
||||
_gsRender();
|
||||
|
||||
// Async library ownership check — adds badges + swaps play buttons for library tracks
|
||||
// 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);
|
||||
|
||||
// Fetch alternate sources — stream NDJSON so slow sources render incrementally
|
||||
const alts = data.alternate_sources || [];
|
||||
for (const src of alts) {
|
||||
if (src === _gsState.activeSource) continue;
|
||||
_gsFetchSourceStream(src, query);
|
||||
}
|
||||
} catch (e) {
|
||||
if (e.name !== 'AbortError') results.innerHTML = '<div class="gsearch-empty">Search failed</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
await _gsFetchSource(_gsState.activeSource);
|
||||
}
|
||||
|
||||
async function _gsFetchSourceStream(src, query) {
|
||||
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 {
|
||||
const res = await fetch(`/api/enhanced-search/source/${src}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ query }),
|
||||
signal: _gsState.altAbortCtrl.signal,
|
||||
});
|
||||
if (!res.ok) return;
|
||||
|
||||
if (!_gsState.sources[src]) {
|
||||
const loadingSet = src === 'youtube_videos' ? new Set(['videos']) : new Set(['artists', 'albums', 'tracks']);
|
||||
_gsState.sources[src] = { artists: [], albums: [], tracks: [], videos: [], available: true, _loading: loadingSet };
|
||||
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;
|
||||
}
|
||||
const sourceData = _gsState.sources[src];
|
||||
|
||||
const reader = res.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = '';
|
||||
_gsState.loadingSources.delete(src);
|
||||
_gsRender();
|
||||
|
||||
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 === 'artists') { sourceData.artists = chunk.data; if (sourceData._loading) sourceData._loading.delete('artists'); }
|
||||
else if (chunk.type === 'albums') { sourceData.albums = chunk.data; if (sourceData._loading) sourceData._loading.delete('albums'); }
|
||||
else if (chunk.type === 'tracks') { sourceData.tracks = chunk.data; if (sourceData._loading) sourceData._loading.delete('tracks'); }
|
||||
else if (chunk.type === 'videos') { sourceData.videos = chunk.data; if (sourceData._loading) sourceData._loading.delete('videos'); }
|
||||
if (chunk.type === 'done') delete sourceData._loading;
|
||||
_gsRenderTabs();
|
||||
// Re-render content if this is the active source tab
|
||||
if (_gsState.activeSource === src && _gsState.data) {
|
||||
_gsRender(_gsState.data);
|
||||
}
|
||||
} catch (e) { }
|
||||
}
|
||||
if (_gsState.activeSource === src) {
|
||||
setTimeout(() => _gsLibraryCheck(), 200);
|
||||
}
|
||||
_gsRenderTabs();
|
||||
} catch (e) {
|
||||
if (e.name !== 'AbortError') console.debug(`GS alt source ${src} failed:`, e);
|
||||
} catch (err) {
|
||||
_gsState.loadingSources.delete(src);
|
||||
_gsRender();
|
||||
if (err.name !== 'AbortError') console.debug(`GS source ${src} failed:`, err);
|
||||
}
|
||||
}
|
||||
|
||||
function _gsRender(data) {
|
||||
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;
|
||||
navigateToPage('search');
|
||||
// After the page mounts, mirror the query into the enhanced input so the
|
||||
// user doesn't have to retype it. The Search page's source picker will
|
||||
// pick up `src` via its own default-source flow.
|
||||
setTimeout(() => {
|
||||
const input = document.getElementById('enhanced-search-input');
|
||||
if (input && query) {
|
||||
input.value = query;
|
||||
input.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
}
|
||||
}, 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 classes = ['gsearch-source-icon',
|
||||
active ? 'active' : '',
|
||||
cached ? 'cached' : '',
|
||||
loading ? 'loading' : '',
|
||||
fallback ? 'fallback-warning' : ''].filter(Boolean).join(' ');
|
||||
const title = fallback
|
||||
? `${info.text} unavailable — served from ${(SOURCE_LABELS[fallback] || {}).text || fallback}`
|
||||
: info.text;
|
||||
return `<button class="${classes}" data-source="${src}" onclick="_gsSetActiveSource('${src}')" title="${_escAttr(title)}">
|
||||
<span class="gsearch-source-icon-glyph">${loading ? '⏳' : info.icon}</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() {
|
||||
const results = document.getElementById('gsearch-results');
|
||||
if (!results) return;
|
||||
|
||||
// Music Videos tab — render video grid instead of regular results
|
||||
if (_gsState.activeSource === 'youtube_videos') {
|
||||
const src = _gsState.sources['youtube_videos'] || {};
|
||||
const videos = src.videos || [];
|
||||
const isLoading = src._loading && src._loading.size > 0;
|
||||
let h = '';
|
||||
const activeSrc = _gsState.activeSource;
|
||||
const cached = _gsState.sources[activeSrc];
|
||||
const isLoading = _gsState.loadingSources.has(activeSrc);
|
||||
const query = _gsState.query;
|
||||
const header = _gsSourceRowHtml() + _gsFallbackBannerHtml(activeSrc);
|
||||
|
||||
// No query yet — just show the icon row and an empty hint.
|
||||
if (!query) {
|
||||
results.innerHTML = header + '<div class="gsearch-empty">Type to search…</div>';
|
||||
results.classList.add('visible');
|
||||
return;
|
||||
}
|
||||
|
||||
// In-flight for this source, 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>`;
|
||||
results.classList.add('visible');
|
||||
return;
|
||||
}
|
||||
|
||||
// No cache, not loading — user hasn't triggered a fetch (e.g. just switched sources).
|
||||
if (!cached) {
|
||||
results.innerHTML = header + '<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.
|
||||
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>`;
|
||||
h += '<div class="gsearch-tabs" id="gsearch-tabs"></div>';
|
||||
h += '<div class="gsearch-results-body">';
|
||||
if (isLoading) {
|
||||
h += '<div class="gsearch-section-loading"><div class="server-search-spinner" style="width:14px;height:14px"></div> Searching YouTube...</div>';
|
||||
} else if (videos.length === 0) {
|
||||
h += `<div class="gsearch-empty">No music videos found for "${_escToast(_gsState.query)}"</div>`;
|
||||
if (videos.length === 0) {
|
||||
h += `<div class="gsearch-empty">No music videos found for "${_escToast(query)}"</div>`;
|
||||
} else {
|
||||
h += '<div class="gsearch-section-header">🎬 Music Videos</div>';
|
||||
h += '<div class="enh-video-grid">';
|
||||
|
|
@ -5270,33 +5380,29 @@ function _gsRender(data) {
|
|||
h += '</div>';
|
||||
results.innerHTML = h;
|
||||
results.classList.add('visible');
|
||||
_gsRenderTabs();
|
||||
return;
|
||||
}
|
||||
|
||||
const src = _gsState.sources[_gsState.activeSource] || {};
|
||||
const loading = src._loading || new Set();
|
||||
const dbArtists = data?.db_artists || [];
|
||||
const artists = src.artists || [];
|
||||
const allAlbums = src.albums || [];
|
||||
// Standard metadata source — render library + artists + albums + singles + tracks.
|
||||
const dbArtists = cached.db_artists || [];
|
||||
const artists = cached.artists || [];
|
||||
const allAlbums = cached.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');
|
||||
const tracks = src.tracks || [];
|
||||
const tracks = cached.tracks || [];
|
||||
const total = dbArtists.length + artists.length + albums.length + singles.length + tracks.length;
|
||||
const isLoading = loading.size > 0;
|
||||
|
||||
if (total === 0 && !isLoading) {
|
||||
results.innerHTML = `<div class="gsearch-empty">No results for "${_escToast(_gsState.query)}"<br><span style="font-size:10px;opacity:0.5">Try different keywords or check spelling</span></div>`;
|
||||
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>`;
|
||||
results.classList.add('visible');
|
||||
return;
|
||||
}
|
||||
|
||||
const sourceLabels = { spotify: 'Spotify', itunes: 'Apple Music', deezer: 'Deezer', discogs: 'Discogs', hydrabase: 'Hydrabase', youtube_videos: 'Music Videos', musicbrainz: 'MusicBrainz' };
|
||||
const srcLabel = sourceLabels[_gsState.activeSource] || _gsState.activeSource || '';
|
||||
const srcLabel = (SOURCE_LABELS[activeSrc] || {}).text || activeSrc || '';
|
||||
|
||||
let h = '';
|
||||
let h = header;
|
||||
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-tabs" id="gsearch-tabs"></div>';
|
||||
h += '<div class="gsearch-results-body">';
|
||||
|
||||
if (dbArtists.length) {
|
||||
|
|
@ -5309,12 +5415,8 @@ function _gsRender(data) {
|
|||
h += `<div class="gsearch-section-header">🎤 Artists <span class="gsearch-source-badge">${srcLabel}</span></div><div class="gsearch-grid" id="gsearch-artists-grid">`;
|
||||
h += artists.map(a => `<div class="gsearch-item" onclick="_gsClickArtist('${a.id}', '${_escAttr(a.name)}', false)" ${!a.image_url ? `data-artist-id="${a.id}" data-needs-image="true"` : ''}><div class="gsearch-item-art">${a.image_url ? `<img src="${a.image_url}" loading="lazy">` : '🎤'}</div><div class="gsearch-item-info"><div class="gsearch-item-title">${_escToast(a.name)}</div></div></div>`).join('');
|
||||
h += '</div>';
|
||||
} else if (loading.has('artists')) {
|
||||
h += `<div class="gsearch-section-header">🎤 Artists <span class="gsearch-source-badge">${srcLabel}</span></div><div class="gsearch-section-loading"><div class="server-search-spinner" style="width:14px;height:14px"></div> Loading artists...</div>`;
|
||||
}
|
||||
|
||||
const activeSrc = _gsState.activeSource || 'spotify';
|
||||
|
||||
if (albums.length) {
|
||||
h += `<div class="gsearch-section-header">💿 Albums <span class="gsearch-source-badge">${srcLabel}</span></div><div class="gsearch-grid">`;
|
||||
h += albums.map(a => {
|
||||
|
|
@ -5326,10 +5428,6 @@ function _gsRender(data) {
|
|||
h += '</div>';
|
||||
}
|
||||
|
||||
if (!albums.length && !singles.length && loading.has('albums')) {
|
||||
h += `<div class="gsearch-section-header">💿 Albums <span class="gsearch-source-badge">${srcLabel}</span></div><div class="gsearch-section-loading"><div class="server-search-spinner" style="width:14px;height:14px"></div> Loading albums...</div>`;
|
||||
}
|
||||
|
||||
if (singles.length) {
|
||||
h += `<div class="gsearch-section-header">🎶 Singles & EPs <span class="gsearch-source-badge">${srcLabel}</span></div><div class="gsearch-grid">`;
|
||||
h += singles.map(a => {
|
||||
|
|
@ -5348,14 +5446,11 @@ function _gsRender(data) {
|
|||
return `<div class="gsearch-track" onclick="_gsClickTrack('${_escAttr(ar)}', '${_escAttr(t.name)}', '${_escAttr(t.album || '')}', '${_escAttr(t.id || '')}', '${_escAttr(t.image_url || '')}', ${t.duration_ms || 0})"><div class="gsearch-item-art" style="width:32px;height:32px;border-radius:6px">${t.image_url ? `<img src="${t.image_url}" loading="lazy">` : '🎵'}</div><div class="gsearch-item-info"><div class="gsearch-item-title">${_escToast(t.name)}</div><div class="gsearch-item-sub">${_escToast(ar)}${t.album ? ` · ${_escToast(t.album)}` : ''}</div></div><div class="gsearch-track-dur">${dur}</div><button class="gsearch-play-btn" onclick="event.stopPropagation(); _gsPlayTrack('${_escAttr(t.name)}', '${_escAttr(ar)}', '${_escAttr(t.album || '')}')" title="Stream">▶</button></div>`;
|
||||
}).join('');
|
||||
h += '</div>';
|
||||
} else if (loading.has('tracks')) {
|
||||
h += `<div class="gsearch-section-header">🎵 Tracks <span class="gsearch-source-badge">${srcLabel}</span></div><div class="gsearch-section-loading"><div class="server-search-spinner" style="width:14px;height:14px"></div> Loading tracks...</div>`;
|
||||
}
|
||||
|
||||
h += '</div>';
|
||||
results.innerHTML = h;
|
||||
results.classList.add('visible');
|
||||
_gsRenderTabs();
|
||||
|
||||
// Lazy load artist images for sources that don't provide them (iTunes/Deezer)
|
||||
_gsLazyLoadArtistImages();
|
||||
|
|
@ -5383,42 +5478,31 @@ async function _gsLazyLoadArtistImages() {
|
|||
}
|
||||
}
|
||||
|
||||
function _gsRenderTabs() {
|
||||
const el = document.getElementById('gsearch-tabs');
|
||||
if (!el) return;
|
||||
const sources = Object.keys(_gsState.sources);
|
||||
const labels = {
|
||||
spotify: 'Spotify',
|
||||
itunes: 'Apple Music',
|
||||
deezer: 'Deezer',
|
||||
discogs: 'Discogs',
|
||||
hydrabase: 'Hydrabase',
|
||||
youtube_videos: 'Music Videos',
|
||||
musicbrainz: 'MusicBrainz',
|
||||
};
|
||||
const visibleSources = sources.filter(s => {
|
||||
const d = _gsState.sources[s] || {};
|
||||
const count = s === 'youtube_videos'
|
||||
? (d.videos?.length || 0)
|
||||
: (d.artists?.length || 0) + (d.albums?.length || 0) + (d.tracks?.length || 0);
|
||||
const isLoading = !!(d._loading && d._loading.size > 0);
|
||||
return isLoading || count > 0 || s === _gsState.activeSource;
|
||||
});
|
||||
if (visibleSources.length < 2) { el.style.display = 'none'; return; }
|
||||
el.style.display = 'flex';
|
||||
el.innerHTML = visibleSources.map(s => {
|
||||
const d = _gsState.sources[s];
|
||||
const c = s === 'youtube_videos'
|
||||
? (d.videos?.length || 0)
|
||||
: (d.artists?.length || 0) + (d.albums?.length || 0) + (d.tracks?.length || 0);
|
||||
return `<button class="gsearch-tab${s === _gsState.activeSource ? ' active' : ''}" onclick="_gsSwitchSource('${s}')">${labels[s] || s} (${c})</button>`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
function _gsSwitchSource(src) {
|
||||
function _gsSetActiveSource(src) {
|
||||
if (!SOURCE_LABELS[src]) return;
|
||||
_gsState._lastInteraction = Date.now();
|
||||
|
||||
// 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;
|
||||
_gsRender(_gsState.data);
|
||||
|
||||
// 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();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5479,6 +5479,91 @@ body.helper-mode-active #dashboard-activity-feed:hover {
|
|||
.gsearch-tab.active { background: rgba(var(--accent-rgb), 0.12); color: var(--accent); border-color: rgba(var(--accent-rgb), 0.2); }
|
||||
.gsearch-tab:hover:not(.active) { background: rgba(255,255,255,0.06); }
|
||||
|
||||
/* Source icon row (replaces post-search tabs) */
|
||||
.gsearch-source-row {
|
||||
display: flex;
|
||||
flex-wrap: nowrap;
|
||||
overflow-x: auto;
|
||||
gap: 6px;
|
||||
padding: 10px 14px 8px;
|
||||
flex-shrink: 0;
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: rgba(255, 255, 255, 0.2) transparent;
|
||||
}
|
||||
.gsearch-source-row::-webkit-scrollbar { height: 4px; }
|
||||
.gsearch-source-row::-webkit-scrollbar-track { background: transparent; }
|
||||
.gsearch-source-row::-webkit-scrollbar-thumb { background: rgba(255, 255, 255, 0.15); border-radius: 2px; }
|
||||
|
||||
.gsearch-source-icon {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 2px;
|
||||
min-width: 56px;
|
||||
padding: 6px 8px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.06);
|
||||
border-radius: 8px;
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
color: rgba(255, 255, 255, 0.55);
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
transition: border-color 0.15s, background 0.15s, color 0.15s;
|
||||
}
|
||||
.gsearch-source-icon:hover {
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
color: #fff;
|
||||
border-color: rgba(255, 255, 255, 0.14);
|
||||
}
|
||||
.gsearch-source-icon-glyph { font-size: 16px; line-height: 1; }
|
||||
.gsearch-source-icon-label { font-size: 10px; letter-spacing: 0.02em; }
|
||||
|
||||
.gsearch-source-icon[data-source="spotify"].active { background: rgba(29, 185, 84, 0.18); color: #1db954; border-color: rgba(29, 185, 84, 0.4); }
|
||||
.gsearch-source-icon[data-source="itunes"].active { background: rgba(252, 60, 68, 0.18); color: #fc3c44; border-color: rgba(252, 60, 68, 0.4); }
|
||||
.gsearch-source-icon[data-source="deezer"].active { background: rgba(162, 56, 255, 0.18); color: #a238ff; border-color: rgba(162, 56, 255, 0.4); }
|
||||
.gsearch-source-icon[data-source="discogs"].active { background: rgba(212, 165, 116, 0.18); color: #D4A574; border-color: rgba(212, 165, 116, 0.4); }
|
||||
.gsearch-source-icon[data-source="hydrabase"].active { background: rgba(0, 180, 216, 0.18); color: #00b4d8; border-color: rgba(0, 180, 216, 0.4); }
|
||||
.gsearch-source-icon[data-source="musicbrainz"].active { background: rgba(186, 51, 88, 0.18); color: #BA3358; border-color: rgba(186, 51, 88, 0.4); }
|
||||
.gsearch-source-icon[data-source="youtube_videos"].active { background: rgba(255, 0, 0, 0.18); color: #ff4444; border-color: rgba(255, 0, 0, 0.4); }
|
||||
.gsearch-source-icon[data-source="soulseek"].active { background: rgba(255, 255, 255, 0.14); color: #fff; border-color: rgba(255, 255, 255, 0.3); }
|
||||
|
||||
.gsearch-source-icon.cached::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 4px;
|
||||
right: 5px;
|
||||
width: 5px;
|
||||
height: 5px;
|
||||
border-radius: 50%;
|
||||
background: currentColor;
|
||||
opacity: 0.75;
|
||||
}
|
||||
.gsearch-source-icon.loading .gsearch-source-icon-glyph {
|
||||
animation: gsearch-source-loading-spin 1.2s linear infinite;
|
||||
opacity: 0.7;
|
||||
}
|
||||
@keyframes gsearch-source-loading-spin {
|
||||
from { transform: rotate(0); }
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
.gsearch-source-icon.fallback-warning { border-color: rgba(250, 176, 5, 0.5); }
|
||||
|
||||
.gsearch-fallback-banner {
|
||||
padding: 6px 14px;
|
||||
margin: 0 12px 6px;
|
||||
border-radius: 6px;
|
||||
background: rgba(250, 176, 5, 0.12);
|
||||
border: 1px solid rgba(250, 176, 5, 0.3);
|
||||
color: #fab005;
|
||||
font-size: 10.5px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* Section headers */
|
||||
.gsearch-section-header {
|
||||
font-size: 10px;
|
||||
|
|
|
|||
Loading…
Reference in a new issue