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 = {
|
const _gsState = {
|
||||||
active: false,
|
active: false,
|
||||||
query: '',
|
query: '',
|
||||||
data: null,
|
data: null, // most recent payload {db_artists: [...]} — used as library baseline
|
||||||
sources: {},
|
activeSource: 'spotify', // current source icon (seeded from /api/settings on init)
|
||||||
activeSource: null,
|
// 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,
|
abortCtrl: null,
|
||||||
altAbortCtrl: null,
|
|
||||||
debounceTimer: null,
|
debounceTimer: null,
|
||||||
|
_defaultSourceResolved: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
(function initGlobalSearch() {
|
(function initGlobalSearch() {
|
||||||
|
|
@ -5039,7 +5042,9 @@ const _gsState = {
|
||||||
_gsState.active = true;
|
_gsState.active = true;
|
||||||
const shortcut = document.getElementById('gsearch-shortcut');
|
const shortcut = document.getElementById('gsearch-shortcut');
|
||||||
if (shortcut) shortcut.style.display = 'none';
|
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
|
// 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');
|
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) {
|
async function _gsPerformSearch(query) {
|
||||||
if (_gsState.abortCtrl) _gsState.abortCtrl.abort();
|
await _gsInitDefaultSource();
|
||||||
if (_gsState.altAbortCtrl) _gsState.altAbortCtrl.abort();
|
|
||||||
_gsState.abortCtrl = new AbortController();
|
|
||||||
_gsState.altAbortCtrl = new AbortController();
|
|
||||||
|
|
||||||
const results = document.getElementById('gsearch-results');
|
// Query changed? Wipe the per-query cache so we never serve stale results
|
||||||
if (!results) return;
|
// across different queries.
|
||||||
|
if (query !== _gsState.query) {
|
||||||
results.innerHTML = '<div class="gsearch-loading"><div class="server-search-spinner"></div>Searching...</div>';
|
_gsState.query = query;
|
||||||
results.classList.add('visible');
|
|
||||||
|
|
||||||
try {
|
|
||||||
const data = await enhancedSearchFetch(query, { signal: _gsState.abortCtrl.signal });
|
|
||||||
_gsState.data = data;
|
|
||||||
_gsState.activeSource = data.primary_source || 'spotify';
|
|
||||||
_gsState.sources = {};
|
_gsState.sources = {};
|
||||||
_gsState.sources[_gsState.activeSource] = {
|
_gsState.fallbacks = {};
|
||||||
artists: data.spotify_artists || [],
|
_gsState.loadingSources = new Set();
|
||||||
albums: data.spotify_albums || [],
|
}
|
||||||
tracks: data.spotify_tracks || [],
|
|
||||||
};
|
|
||||||
|
|
||||||
_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);
|
setTimeout(() => _gsLibraryCheck(), 200);
|
||||||
|
return;
|
||||||
// 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>';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 {
|
try {
|
||||||
const res = await fetch(`/api/enhanced-search/source/${src}`, {
|
if (src === 'youtube_videos') {
|
||||||
method: 'POST',
|
await _gsFetchYouTubeVideos(query, _gsState.abortCtrl.signal);
|
||||||
headers: { 'Content-Type': 'application/json' },
|
} else {
|
||||||
body: JSON.stringify({ query }),
|
const data = await enhancedSearchFetch(query, {
|
||||||
signal: _gsState.altAbortCtrl.signal,
|
source: src,
|
||||||
});
|
signal: _gsState.abortCtrl.signal,
|
||||||
if (!res.ok) return;
|
});
|
||||||
|
_gsState.sources[src] = {
|
||||||
if (!_gsState.sources[src]) {
|
artists: data.spotify_artists || [],
|
||||||
const loadingSet = src === 'youtube_videos' ? new Set(['videos']) : new Set(['artists', 'albums', 'tracks']);
|
albums: data.spotify_albums || [],
|
||||||
_gsState.sources[src] = { artists: [], albums: [], tracks: [], videos: [], available: true, _loading: loadingSet };
|
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();
|
_gsState.loadingSources.delete(src);
|
||||||
const decoder = new TextDecoder();
|
_gsRender();
|
||||||
let buffer = '';
|
|
||||||
|
|
||||||
while (true) {
|
if (_gsState.activeSource === src) {
|
||||||
const { done, value } = await reader.read();
|
setTimeout(() => _gsLibraryCheck(), 200);
|
||||||
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) { }
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
_gsRenderTabs();
|
} catch (err) {
|
||||||
} catch (e) {
|
_gsState.loadingSources.delete(src);
|
||||||
if (e.name !== 'AbortError') console.debug(`GS alt source ${src} failed:`, e);
|
_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');
|
const results = document.getElementById('gsearch-results');
|
||||||
if (!results) return;
|
if (!results) return;
|
||||||
|
|
||||||
// Music Videos tab — render video grid instead of regular results
|
const activeSrc = _gsState.activeSource;
|
||||||
if (_gsState.activeSource === 'youtube_videos') {
|
const cached = _gsState.sources[activeSrc];
|
||||||
const src = _gsState.sources['youtube_videos'] || {};
|
const isLoading = _gsState.loadingSources.has(activeSrc);
|
||||||
const videos = src.videos || [];
|
const query = _gsState.query;
|
||||||
const isLoading = src._loading && src._loading.size > 0;
|
const header = _gsSourceRowHtml() + _gsFallbackBannerHtml(activeSrc);
|
||||||
let h = '';
|
|
||||||
|
// 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-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">';
|
h += '<div class="gsearch-results-body">';
|
||||||
if (isLoading) {
|
if (videos.length === 0) {
|
||||||
h += '<div class="gsearch-section-loading"><div class="server-search-spinner" style="width:14px;height:14px"></div> Searching YouTube...</div>';
|
h += `<div class="gsearch-empty">No music videos found for "${_escToast(query)}"</div>`;
|
||||||
} else if (videos.length === 0) {
|
|
||||||
h += `<div class="gsearch-empty">No music videos found for "${_escToast(_gsState.query)}"</div>`;
|
|
||||||
} else {
|
} else {
|
||||||
h += '<div class="gsearch-section-header">🎬 Music Videos</div>';
|
h += '<div class="gsearch-section-header">🎬 Music Videos</div>';
|
||||||
h += '<div class="enh-video-grid">';
|
h += '<div class="enh-video-grid">';
|
||||||
|
|
@ -5270,33 +5380,29 @@ function _gsRender(data) {
|
||||||
h += '</div>';
|
h += '</div>';
|
||||||
results.innerHTML = h;
|
results.innerHTML = h;
|
||||||
results.classList.add('visible');
|
results.classList.add('visible');
|
||||||
_gsRenderTabs();
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const src = _gsState.sources[_gsState.activeSource] || {};
|
// Standard metadata source — render library + artists + albums + singles + tracks.
|
||||||
const loading = src._loading || new Set();
|
const dbArtists = cached.db_artists || [];
|
||||||
const dbArtists = data?.db_artists || [];
|
const artists = cached.artists || [];
|
||||||
const artists = src.artists || [];
|
const allAlbums = cached.albums || [];
|
||||||
const allAlbums = src.albums || [];
|
|
||||||
const albums = allAlbums.filter(a => !a.album_type || a.album_type === 'album' || a.album_type === 'compilation');
|
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 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 total = dbArtists.length + artists.length + albums.length + singles.length + tracks.length;
|
||||||
const isLoading = loading.size > 0;
|
|
||||||
|
|
||||||
if (total === 0 && !isLoading) {
|
if (total === 0) {
|
||||||
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>`;
|
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');
|
results.classList.add('visible');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const sourceLabels = { spotify: 'Spotify', itunes: 'Apple Music', deezer: 'Deezer', discogs: 'Discogs', hydrabase: 'Hydrabase', youtube_videos: 'Music Videos', musicbrainz: 'MusicBrainz' };
|
const srcLabel = (SOURCE_LABELS[activeSrc] || {}).text || activeSrc || '';
|
||||||
const srcLabel = sourceLabels[_gsState.activeSource] || _gsState.activeSource || '';
|
|
||||||
|
|
||||||
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-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">';
|
h += '<div class="gsearch-results-body">';
|
||||||
|
|
||||||
if (dbArtists.length) {
|
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 += `<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 += 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>';
|
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) {
|
if (albums.length) {
|
||||||
h += `<div class="gsearch-section-header">💿 Albums <span class="gsearch-source-badge">${srcLabel}</span></div><div class="gsearch-grid">`;
|
h += `<div class="gsearch-section-header">💿 Albums <span class="gsearch-source-badge">${srcLabel}</span></div><div class="gsearch-grid">`;
|
||||||
h += albums.map(a => {
|
h += albums.map(a => {
|
||||||
|
|
@ -5326,10 +5428,6 @@ function _gsRender(data) {
|
||||||
h += '</div>';
|
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) {
|
if (singles.length) {
|
||||||
h += `<div class="gsearch-section-header">🎶 Singles & EPs <span class="gsearch-source-badge">${srcLabel}</span></div><div class="gsearch-grid">`;
|
h += `<div class="gsearch-section-header">🎶 Singles & EPs <span class="gsearch-source-badge">${srcLabel}</span></div><div class="gsearch-grid">`;
|
||||||
h += singles.map(a => {
|
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>`;
|
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('');
|
}).join('');
|
||||||
h += '</div>';
|
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>';
|
h += '</div>';
|
||||||
results.innerHTML = h;
|
results.innerHTML = h;
|
||||||
results.classList.add('visible');
|
results.classList.add('visible');
|
||||||
_gsRenderTabs();
|
|
||||||
|
|
||||||
// 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();
|
_gsLazyLoadArtistImages();
|
||||||
|
|
@ -5383,42 +5478,31 @@ async function _gsLazyLoadArtistImages() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function _gsRenderTabs() {
|
function _gsSetActiveSource(src) {
|
||||||
const el = document.getElementById('gsearch-tabs');
|
if (!SOURCE_LABELS[src]) return;
|
||||||
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) {
|
|
||||||
_gsState._lastInteraction = Date.now();
|
_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;
|
_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');
|
const input = document.getElementById('gsearch-input');
|
||||||
if (input) input.focus();
|
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.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); }
|
.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 */
|
/* Section headers */
|
||||||
.gsearch-section-header {
|
.gsearch-section-header {
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue