diff --git a/webui/static/script.js b/webui/static/script.js
index bd355eab..3fd87125 100644
--- a/webui/static/script.js
+++ b/webui/static/script.js
@@ -8299,7 +8299,7 @@ function initializeSearchModeToggle() {
// Stream NDJSON โ render each search type (artists, albums, tracks) as it arrives
if (!_enhancedSearchData) return;
if (!_enhancedSearchData.sources[sourceName]) {
- _enhancedSearchData.sources[sourceName] = { artists: [], albums: [], tracks: [], available: true };
+ _enhancedSearchData.sources[sourceName] = { artists: [], albums: [], tracks: [], available: true, _loading: new Set(['artists', 'albums', 'tracks']) };
}
const sourceData = _enhancedSearchData.sources[sourceName];
@@ -8320,14 +8320,17 @@ function initializeSearchModeToggle() {
try {
const chunk = JSON.parse(line);
- if (chunk.type === 'artists') sourceData.artists = chunk.data;
- else if (chunk.type === 'albums') sourceData.albums = chunk.data;
- else if (chunk.type === 'tracks') sourceData.tracks = chunk.data;
- else if (chunk.type === 'done') break;
+ 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 === 'done') { delete sourceData._loading; break; }
- // Re-render tabs after each chunk
+ // Re-render tabs + content if this is the active source
if (_enhancedSearchData.primary_source) {
renderSourceTabs(_enhancedSearchData);
+ if (_activeSearchSource === sourceName) {
+ window._switchEnhSourceTab(sourceName);
+ }
}
} catch (parseErr) {
console.debug(`NDJSON parse error for ${sourceName}:`, parseErr);
@@ -8403,6 +8406,25 @@ function initializeSearchModeToggle() {
renderDropdownResults(viewData);
resultsContainer.classList.remove('hidden');
+
+ // Show loading spinners for categories still streaming
+ if (src._loading && src._loading.size > 0) {
+ const loadingHtml = '
';
+ if (src._loading.has('artists')) {
+ const sec = document.getElementById('enh-spotify-artists-section');
+ if (sec) { sec.classList.remove('hidden'); document.getElementById('enh-spotify-artists-list').innerHTML = loadingHtml; }
+ }
+ if (src._loading.has('albums')) {
+ const sec = document.getElementById('enh-albums-section');
+ if (sec) { sec.classList.remove('hidden'); document.getElementById('enh-albums-list').innerHTML = loadingHtml; }
+ const sec2 = document.getElementById('enh-singles-section');
+ if (sec2) { sec2.classList.remove('hidden'); document.getElementById('enh-singles-list').innerHTML = loadingHtml; }
+ }
+ if (src._loading.has('tracks')) {
+ const sec = document.getElementById('enh-tracks-section');
+ if (sec) { sec.classList.remove('hidden'); document.getElementById('enh-tracks-list').innerHTML = loadingHtml; }
+ }
+ }
};
// Lazy load artist images for enhanced search results
@@ -17028,7 +17050,7 @@ async function _gsFetchSourceStream(src, query) {
if (!res.ok) return;
if (!_gsState.sources[src]) {
- _gsState.sources[src] = { artists: [], albums: [], tracks: [], available: true };
+ _gsState.sources[src] = { artists: [], albums: [], tracks: [], available: true, _loading: new Set(['artists', 'albums', 'tracks']) };
}
const sourceData = _gsState.sources[src];
@@ -17048,10 +17070,15 @@ async function _gsFetchSourceStream(src, query) {
if (!line) continue;
try {
const chunk = JSON.parse(line);
- if (chunk.type === 'artists') sourceData.artists = chunk.data;
- else if (chunk.type === 'albums') sourceData.albums = chunk.data;
- else if (chunk.type === 'tracks') sourceData.tracks = chunk.data;
+ 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'); }
+ 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) {}
}
}
@@ -17066,6 +17093,7 @@ function _gsRender(data) {
if (!results) 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 || [];
@@ -17073,8 +17101,9 @@ function _gsRender(data) {
const singles = allAlbums.filter(a => a.album_type === 'single' || a.album_type === 'ep');
const tracks = src.tracks || [];
const total = dbArtists.length + artists.length + albums.length + singles.length + tracks.length;
+ const isLoading = loading.size > 0;
- if (total === 0) {
+ if (total === 0 && !isLoading) {
results.innerHTML = `No results for "${_escToast(_gsState.query)}"
Try different keywords or check spelling
`;
results.classList.add('visible');
return;
@@ -17095,9 +17124,11 @@ function _gsRender(data) {
}
if (artists.length) {
- h += ``;
- h += artists.map(a => `
${a.image_url ? `

` : '๐ค'}
`).join('');
+ h += `
`;
+ h += artists.map(a => `
${a.image_url ? `

` : '๐ค'}
`).join('');
h += '
';
+ } else if (loading.has('artists')) {
+ h += `
`;
}
const activeSrc = _gsState.activeSource || 'spotify';
@@ -17113,6 +17144,10 @@ function _gsRender(data) {
h += '
';
}
+ if (!albums.length && !singles.length && loading.has('albums')) {
+ h += ``;
+ }
+
if (singles.length) {
h += ``;
h += singles.map(a => {
@@ -17131,12 +17166,39 @@ function _gsRender(data) {
return `
${t.image_url ? `

` : '๐ต'}
${_escToast(t.name)}
${_escToast(ar)}${t.album ? ` ยท ${_escToast(t.album)}` : ''}
${dur}
`;
}).join('');
h += '
';
+ } else if (loading.has('tracks')) {
+ h += ``;
}
h += '';
results.innerHTML = h;
results.classList.add('visible');
_gsRenderTabs();
+
+ // Lazy load artist images for sources that don't provide them (iTunes/Deezer)
+ _gsLazyLoadArtistImages();
+}
+
+async function _gsLazyLoadArtistImages() {
+ const grid = document.getElementById('gsearch-artists-grid');
+ if (!grid) return;
+ const cards = grid.querySelectorAll('[data-needs-image="true"]');
+ if (cards.length === 0) return;
+ const activeSrc = _gsState.activeSource || 'spotify';
+
+ for (const card of cards) {
+ const artistId = card.dataset.artistId;
+ if (!artistId) continue;
+ try {
+ const res = await fetch(`/api/artist/${artistId}/image?source=${activeSrc}`);
+ const data = await res.json();
+ if (data.success && data.image_url) {
+ const artDiv = card.querySelector('.gsearch-item-art');
+ if (artDiv) artDiv.innerHTML = `
`;
+ card.removeAttribute('data-needs-image');
+ }
+ } catch (e) { /* ignore */ }
+ }
}
function _gsRenderTabs() {
diff --git a/webui/static/style.css b/webui/static/style.css
index fa0c1935..5eb35402 100644
--- a/webui/static/style.css
+++ b/webui/static/style.css
@@ -5379,6 +5379,16 @@ body.helper-mode-active #dashboard-activity-feed:hover {
text-align: center; padding: 24px; font-size: 12px; color: rgba(255,255,255,0.2);
}
+.gsearch-section-loading,
+.enh-section-loading {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ padding: 12px 8px;
+ font-size: 11px;
+ color: rgba(255,255,255,0.3);
+}
+
/* Track list (not grid) */
.gsearch-track-list { display: flex; flex-direction: column; }