From b8870e73109e8be327eaaadfb37cc072e559c040 Mon Sep 17 00:00:00 2001 From: Broque Thomas <26755000+Nezreka@users.noreply.github.com> Date: Thu, 2 Apr 2026 09:06:06 -0700 Subject: [PATCH] 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 --- webui/static/script.js | 88 +++++++++++++++++++++++++++++++++++------- webui/static/style.css | 10 +++++ 2 files changed, 85 insertions(+), 13 deletions(-) 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 = '
Loading...
'; + 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 += `
๐ŸŽค Artists ${srcLabel}
`; - h += artists.map(a => `
${a.image_url ? `` : '๐ŸŽค'}
${_escToast(a.name)}
`).join(''); + h += `
๐ŸŽค Artists ${srcLabel}
`; + h += artists.map(a => `
${a.image_url ? `` : '๐ŸŽค'}
${_escToast(a.name)}
`).join(''); h += '
'; + } else if (loading.has('artists')) { + h += `
๐ŸŽค Artists ${srcLabel}
Loading artists...
`; } const activeSrc = _gsState.activeSource || 'spotify'; @@ -17113,6 +17144,10 @@ function _gsRender(data) { h += '
'; } + if (!albums.length && !singles.length && loading.has('albums')) { + h += `
๐Ÿ’ฟ Albums ${srcLabel}
Loading albums...
`; + } + if (singles.length) { h += `
๐ŸŽถ Singles & EPs ${srcLabel}
`; 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 += `
๐ŸŽต Tracks ${srcLabel}
Loading tracks...
`; } 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; }