Add loading indicators for streaming search and lazy-load artist images

- Per-section loading spinners (artists/albums/tracks) shown until each
  NDJSON chunk arrives, auto-replaced with real content on receipt
- Active tab content auto-re-renders as streaming data arrives for both
  enhanced search and global search
- Global search lazy-loads artist images for iTunes/Deezer via
  /api/artist/{id}/image fallback (album art), matching enhanced search
pull/253/head
Broque Thomas 1 month ago
parent 1bb59f3c24
commit b8870e7310

@ -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 = '<div class="enh-section-loading"><div class="server-search-spinner" style="width:16px;height:16px"></div><span>Loading...</span></div>';
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 = `<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.classList.add('visible');
return;
@ -17095,9 +17124,11 @@ function _gsRender(data) {
}
if (artists.length) {
h += `<div class="gsearch-section-header">🎤 Artists <span class="gsearch-source-badge">${srcLabel}</span></div><div class="gsearch-grid">`;
h += artists.map(a => `<div class="gsearch-item" onclick="_gsClickArtist('${a.id}', '${_escToast(a.name).replace(/'/g, "\\'")}', false)"><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 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}', '${_escToast(a.name).replace(/'/g, "\\'")}', 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';
@ -17113,6 +17144,10 @@ 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 => {
@ -17131,12 +17166,39 @@ function _gsRender(data) {
return `<div class="gsearch-track" onclick="_gsClickTrack('${_escToast(ar).replace(/'/g, "\\'")}', '${_escToast(t.name).replace(/'/g, "\\'")}')"><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('${_escToast(t.name).replace(/'/g, "\\'")}', '${_escToast(ar).replace(/'/g, "\\'")}', '${_escToast(t.album || '').replace(/'/g, "\\'")}')" 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();
}
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 = `<img src="${data.image_url}" loading="lazy">`;
card.removeAttribute('data-needs-image');
}
} catch (e) { /* ignore */ }
}
}
function _gsRenderTabs() {

@ -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; }

Loading…
Cancel
Save