From 13629720e89f4a1a9b1fb27bc76f834616f0e0f0 Mon Sep 17 00:00:00 2001 From: Broque Thomas <26755000+Nezreka@users.noreply.github.com> Date: Wed, 1 Apr 2026 22:31:12 -0700 Subject: [PATCH] Add global search bar with full enhanced search parity Persistent Spotlight-style search bar at bottom-center, accessible from any page via click, /, or Ctrl+K. Hidden on Downloads page where enhanced search already exists. Features matching enhanced search: - Clear button when input has text - Source tabs with live switching - Source badges, library check, play buttons - Album click opens download modal directly - Artist click navigates to detail page - Tab switching stays open (timestamp guard) - Mobile responsive --- webui/index.html | 11 + webui/static/script.js | 461 +++++++++++++++++++++++++++++++++++++++++ webui/static/style.css | 276 ++++++++++++++++++++++++ 3 files changed, 748 insertions(+) diff --git a/webui/index.html b/webui/index.html index 17916c83..1b0b0dcf 100644 --- a/webui/index.html +++ b/webui/index.html @@ -6791,6 +6791,17 @@ + +
+
+ +
+ + + / +
+
+ `; + }).join(''); + h += ''; + } + + h += ''; + results.innerHTML = h; + results.classList.add('visible'); + _gsRenderTabs(); +} + +function _gsRenderTabs() { + const el = document.getElementById('gsearch-tabs'); + if (!el) return; + const sources = Object.keys(_gsState.sources); + if (sources.length < 2) { el.style.display = 'none'; return; } + const labels = { spotify: 'Spotify', itunes: 'Apple Music', deezer: 'Deezer', hydrabase: 'Hydrabase' }; + el.style.display = 'flex'; + el.innerHTML = sources.map(s => { + const d = _gsState.sources[s]; + const c = (d.artists?.length || 0) + (d.albums?.length || 0) + (d.tracks?.length || 0); + return ``; + }).join(''); +} + +function _gsSwitchSource(src) { + _gsState._lastInteraction = Date.now(); + _gsState.activeSource = src; + _gsRender(_gsState.data); + const input = document.getElementById('gsearch-input'); + if (input) input.focus(); +} + +function _gsClickArtist(id, name, isLibrary) { + _gsDeactivate(); + if (isLibrary) { + // Same as enhanced search: navigateToArtistDetail + navigateToArtistDetail(id, name); + } else { + // Same as enhanced search: navigate to Artists page + selectArtistForDetail + navigateToPage('artists'); + setTimeout(() => { + selectArtistForDetail({ id, name, image_url: '' }, { + source: _gsState.activeSource || '', + }); + }, 150); + } +} + +async function _gsClickAlbum(albumId, albumName, artistName, imageUrl, source) { + _gsDeactivate(); + // Same flow as handleEnhancedSearchAlbumClick — fetch album, open download modal + showLoadingOverlay('Loading album...'); + try { + const params = new URLSearchParams({ name: albumName, artist: artistName }); + if (source && source !== 'spotify') params.set('source', source); + const response = await fetch(`/api/spotify/album/${albumId}?${params}`); + if (!response.ok) throw new Error(`Failed to load album: ${response.status}`); + const albumData = await response.json(); + + if (!albumData || !albumData.tracks || albumData.tracks.length === 0) { + hideLoadingOverlay(); + showToast(`No tracks available for "${albumName}"`, 'warning'); + return; + } + + const enrichedTracks = albumData.tracks.map(t => ({ + ...t, + album: { name: albumData.name, id: albumData.id, album_type: albumData.album_type || 'album', images: albumData.images || [], release_date: albumData.release_date, total_tracks: albumData.total_tracks } + })); + + const virtualPlaylistId = `enhanced_search_album_${albumId}`; + const firstArtist = (albumData.artists || [])[0] || {}; + const artistObj = { id: firstArtist.id || '', name: firstArtist.name || artistName, source: source || '' }; + const albumObj = { name: albumData.name, id: albumData.id, album_type: albumData.album_type || 'album', images: albumData.images || [], release_date: albumData.release_date, total_tracks: albumData.total_tracks, artists: albumData.artists || [{ name: artistName }] }; + + await openDownloadMissingModalForArtistAlbum(virtualPlaylistId, `[${artistName}] ${albumData.name}`, enrichedTracks, albumObj, artistObj, false); + + } catch (e) { + hideLoadingOverlay(); + showToast('Failed to load album: ' + e.message, 'error'); + } +} + +function _gsClickTrack(artistName, trackName) { + _gsDeactivate(); + navigateToPage('downloads'); + setTimeout(() => { + const input = document.getElementById('enhanced-search-input'); + if (input) { input.value = `${artistName} ${trackName}`.trim(); input.dispatchEvent(new Event('input')); } + }, 300); +} + +async function _gsPlayTrack(trackName, artistName, albumName) { + try { + showToast('Searching for stream...', 'info'); + const res = await fetch('/api/enhanced-search/stream-track', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ track_name: trackName, artist_name: artistName, album_name: albumName }) + }); + const data = await res.json(); + if (data.success && data.result) { + if (typeof startStream === 'function') { + startStream(data.result); + } else { + showToast('Streaming not available', 'error'); + } + } else { + showToast(data.error || 'No stream found', 'error'); + } + } catch (e) { + showToast('Stream failed: ' + e.message, 'error'); + } +} + +// Async library check for global search results — adds badges + swaps play buttons +async function _gsLibraryCheck() { + try { + const src = _gsState.sources[_gsState.activeSource] || {}; + const allAlbums = src.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 || []; + if (!allAlbums.length && !tracks.length) return; + + const res = await fetch('/api/enhanced-search/library-check', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + albums: allAlbums.map(a => ({ name: a.name, artist: a.artist || (a.artists ? a.artists.join(', ') : '') })), + tracks: tracks.map(t => ({ name: t.name, artist: t.artist || (t.artists ? t.artists.join(', ') : '') })), + }) + }); + const checkData = await res.json(); + + // Add "In Library" badges to albums — match by index against allAlbums order + const albumResults = checkData.albums || []; + let albumIdx = 0; + // Albums section + document.querySelectorAll('#gsearch-results .gsearch-results-body').forEach(body => { + // Find all gsearch-item elements and tag ones that are albums + const sections = body.querySelectorAll('.gsearch-section-header'); + sections.forEach(header => { + const text = header.textContent; + const isAlbumSection = text.includes('Albums') || text.includes('Singles'); + if (!isAlbumSection) return; + const grid = header.nextElementSibling; + if (!grid) return; + const items = grid.querySelectorAll('.gsearch-item'); + items.forEach(item => { + if (albumIdx < albumResults.length && albumResults[albumIdx]) { + if (!item.querySelector('.gsearch-item-badge')) { + const badge = document.createElement('span'); + badge.className = 'gsearch-item-badge'; + badge.textContent = 'In Library'; + item.appendChild(badge); + } + } + albumIdx++; + }); + }); + }); + + // Tag tracks + swap play buttons for library playback + const trackResults = checkData.tracks || []; + const trackEls = document.querySelectorAll('#gsearch-results .gsearch-track'); + trackEls.forEach((el, i) => { + const tr = trackResults[i]; + if (tr && tr.in_library) { + // Add badge + if (!el.querySelector('.gsearch-item-badge')) { + const badge = document.createElement('span'); + badge.className = 'gsearch-item-badge'; + badge.textContent = 'In Library'; + badge.style.marginRight = '4px'; + el.querySelector('.gsearch-track-dur')?.before(badge); + } + + // Swap play button to library playback + if (tr.file_path) { + const playBtn = el.querySelector('.gsearch-play-btn'); + if (playBtn) { + const newBtn = playBtn.cloneNode(true); + newBtn.title = 'Play from library'; + newBtn.style.background = 'rgba(76,175,80,0.15)'; + newBtn.style.color = '#4caf50'; + newBtn.addEventListener('click', e => { + e.stopPropagation(); + playLibraryTrack( + { id: tr.track_id, title: tr.title, file_path: tr.file_path }, + tr.album_title || '', + tr.artist_name || '' + ); + }); + playBtn.replaceWith(newBtn); + } + } + } + }); + } catch (e) { + // Non-critical + } +} + function escapeHtml(text) { const div = document.createElement('div'); div.textContent = text; diff --git a/webui/static/style.css b/webui/static/style.css index 4ca43548..d2e49c94 100644 --- a/webui/static/style.css +++ b/webui/static/style.css @@ -5169,6 +5169,282 @@ body.helper-mode-active #dashboard-activity-feed:hover { } .notif-entry-link:hover { opacity: 0.7; } +/* ================================================================================== + GLOBAL SEARCH BAR — Spotlight-style search from anywhere + ================================================================================== */ + +.gsearch-bar { + position: fixed; + bottom: 24px; + left: 50%; + transform: translateX(-50%); + width: 380px; + height: 42px; + display: flex; + align-items: center; + gap: 8px; + padding: 0 16px; + background: rgba(18, 18, 26, 0.65); + backdrop-filter: blur(12px); + border: 1px solid rgba(255, 255, 255, 0.1); + border-radius: 21px; + z-index: 99998; + opacity: 0.55; + transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); + cursor: text; +} +.gsearch-bar:hover { + opacity: 0.8; + border-color: rgba(255, 255, 255, 0.15); + background: rgba(18, 18, 26, 0.8); +} +.gsearch-bar.active { + opacity: 1; + width: 560px; + background: rgba(18, 18, 26, 0.92); + backdrop-filter: blur(24px); + border-color: rgba(var(--accent-rgb), 0.3); + box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4), 0 0 0 1px rgba(var(--accent-rgb), 0.08); +} + +.gsearch-icon { + color: rgba(255, 255, 255, 0.3); + display: flex; + align-items: center; + flex-shrink: 0; + transition: color 0.2s; +} +.gsearch-bar.active .gsearch-icon { color: var(--accent); } + +.gsearch-input { + flex: 1; + background: none; + border: none; + outline: none; + color: #fff; + font-size: 13px; + font-weight: 500; + min-width: 0; +} +.gsearch-input::placeholder { color: rgba(255, 255, 255, 0.25); } +.gsearch-bar.active .gsearch-input::placeholder { color: rgba(255, 255, 255, 0.4); } + +.gsearch-clear { + width: 20px; height: 20px; border-radius: 50%; border: none; + background: rgba(255,255,255,0.08); color: rgba(255,255,255,0.4); + font-size: 14px; cursor: pointer; display: flex; align-items: center; + justify-content: center; flex-shrink: 0; transition: all 0.15s; padding: 0; +} +.gsearch-clear:hover { background: rgba(255,255,255,0.15); color: #fff; } + +.gsearch-shortcut { + font-size: 11px; + font-weight: 700; + color: rgba(255, 255, 255, 0.15); + background: rgba(255, 255, 255, 0.05); + border: 1px solid rgba(255, 255, 255, 0.08); + border-radius: 5px; + padding: 2px 7px; + flex-shrink: 0; + transition: opacity 0.2s; +} +.gsearch-bar.active .gsearch-shortcut { opacity: 0; pointer-events: none; } + +/* ── Results Panel ── */ +.gsearch-results { + position: fixed; + bottom: 76px; + left: 50%; + transform: translateX(-50%); + width: 620px; + max-width: 95vw; + max-height: 60vh; + background: rgba(14, 14, 20, 0.97); + backdrop-filter: blur(30px); + border: 1px solid rgba(255, 255, 255, 0.08); + border-radius: 18px; + box-shadow: 0 20px 60px rgba(0, 0, 0, 0.6), 0 0 0 1px rgba(255, 255, 255, 0.03) inset; + z-index: 99997; + display: none; + flex-direction: column; + overflow: hidden; +} +.gsearch-results.visible { display: flex; animation: gsearchSlideUp 0.2s ease; } + +@keyframes gsearchSlideUp { + from { opacity: 0; transform: translateX(-50%) translateY(10px); } + to { opacity: 1; transform: translateX(-50%) translateY(0); } +} + +.gsearch-results-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 12px 18px 8px; + border-bottom: 1px solid rgba(255, 255, 255, 0.05); + flex-shrink: 0; +} +.gsearch-results-title { font-size: 11px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.5px; color: rgba(255,255,255,0.3); } +.gsearch-results-count { font-size: 10px; color: rgba(255,255,255,0.2); } + +.gsearch-results-body { + overflow-y: auto; + flex: 1; + padding: 8px 12px 14px; +} +.gsearch-results-body::-webkit-scrollbar { width: 4px; } +.gsearch-results-body::-webkit-scrollbar-thumb { background: rgba(255,255,255,0.08); border-radius: 2px; } + +/* Source tabs */ +.gsearch-tabs { + display: flex; + gap: 4px; + padding: 8px 18px 4px; + flex-shrink: 0; +} +.gsearch-tab { + padding: 5px 12px; + border-radius: 8px; + border: 1px solid rgba(255, 255, 255, 0.06); + background: rgba(255, 255, 255, 0.03); + color: rgba(255, 255, 255, 0.45); + font-size: 11px; + font-weight: 600; + cursor: pointer; + transition: all 0.15s; +} +.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); } + +/* Section headers */ +.gsearch-section-header { + font-size: 10px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.5px; + color: rgba(255, 255, 255, 0.25); + padding: 10px 6px 6px; +} + +/* Result items — compact grid */ +.gsearch-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(160px, 1fr)); + gap: 6px; +} + +.gsearch-item { + display: flex; + align-items: center; + gap: 10px; + padding: 8px 10px; + border-radius: 10px; + cursor: pointer; + transition: background 0.15s; +} +.gsearch-item:hover { background: rgba(255, 255, 255, 0.05); } + +.gsearch-item-art { + width: 40px; + height: 40px; + border-radius: 8px; + overflow: hidden; + flex-shrink: 0; + background: rgba(255, 255, 255, 0.04); + display: flex; + align-items: center; + justify-content: center; + font-size: 16px; + color: rgba(255, 255, 255, 0.15); +} +.gsearch-item-art img { width: 100%; height: 100%; object-fit: cover; } + +.gsearch-item-info { flex: 1; min-width: 0; } +.gsearch-item-title { font-size: 12px; font-weight: 600; color: rgba(255,255,255,0.85); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +.gsearch-item-sub { font-size: 10px; color: rgba(255,255,255,0.35); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; margin-top: 1px; } + +.gsearch-item-badge { + font-size: 8px; font-weight: 700; text-transform: uppercase; + padding: 2px 6px; border-radius: 4px; + background: rgba(76,175,80,0.12); color: #4caf50; + flex-shrink: 0; +} + +.gsearch-loading { + text-align: center; padding: 24px; font-size: 12px; color: rgba(255,255,255,0.2); +} + +.gsearch-empty { + text-align: center; padding: 24px; font-size: 12px; color: rgba(255,255,255,0.2); +} + +/* Track list (not grid) */ +.gsearch-track-list { display: flex; flex-direction: column; } + +.gsearch-track { + display: flex; + align-items: center; + gap: 10px; + padding: 6px 10px; + border-radius: 8px; + cursor: pointer; + transition: background 0.15s; +} +.gsearch-track:hover { background: rgba(255, 255, 255, 0.04); } + +.gsearch-track-dur { + font-size: 10px; color: rgba(255,255,255,0.2); + flex-shrink: 0; min-width: 32px; text-align: right; +} + +.gsearch-play-btn { + width: 26px; height: 26px; border-radius: 50%; border: none; + background: rgba(var(--accent-rgb), 0.12); color: var(--accent); + font-size: 10px; cursor: pointer; display: flex; align-items: center; + justify-content: center; flex-shrink: 0; transition: all 0.15s; + opacity: 0; +} +.gsearch-track:hover .gsearch-play-btn { opacity: 1; } +.gsearch-play-btn:hover { background: var(--accent); color: #fff; transform: scale(1.1); } + +.gsearch-source-badge { + font-size: 9px; font-weight: 600; padding: 1px 6px; border-radius: 4px; + background: rgba(var(--accent-rgb), 0.1); color: var(--accent); + margin-left: 6px; vertical-align: middle; +} + +/* ── Global Search Mobile ── */ +@media (max-width: 768px) { + .gsearch-bar { + width: calc(100% - 48px); + left: 24px; + right: 24px; + transform: none; + bottom: 80px; + height: 40px; + } + .gsearch-bar.active { + width: calc(100% - 32px); + left: 16px; + right: 16px; + } + .gsearch-results { + width: calc(100% - 32px); + left: 16px; + right: 16px; + transform: none; + bottom: 130px; + max-height: 50vh; + border-radius: 14px; + } + .gsearch-grid { + grid-template-columns: 1fr; + } + .gsearch-shortcut { display: none; } + .gsearch-item-art { width: 36px; height: 36px; } + .gsearch-tabs { flex-wrap: wrap; } +} + /* ── Wing It Button ── */ /* ── Wing It Button + Dropdown ── */ .wing-it-wrap {