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
pull/253/head
Broque Thomas 2 months ago
parent 0f0ec3acb8
commit 13629720e8

@ -6791,6 +6791,17 @@
<script src="https://cdn.jsdelivr.net/npm/chart.js@4/dist/chart.umd.min.js"></script>
<script src="{{ url_for('static', filename='script.js') }}"></script>
<!-- Notification bell + floating helper toggle — always accessible above modals -->
<!-- Global Search Bar — Spotlight-style search from anywhere -->
<div class="gsearch-bar" id="gsearch-bar">
<div class="gsearch-icon">
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round"><circle cx="11" cy="11" r="8"/><path d="m21 21-4.35-4.35"/></svg>
</div>
<input type="text" class="gsearch-input" id="gsearch-input" placeholder="Search artists, albums, tracks..." autocomplete="off" spellcheck="false">
<button class="gsearch-clear" id="gsearch-clear" style="display:none" title="Clear">&times;</button>
<span class="gsearch-shortcut" id="gsearch-shortcut">/</span>
</div>
<div class="gsearch-results" id="gsearch-results"></div>
<button class="notif-bell-btn" id="notif-bell-btn" onclick="toggleNotifPanel()" title="Notifications">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9"/><path d="M13.73 21a2 2 0 0 1-3.46 0"/></svg>
<span class="notif-bell-badge" id="notif-bell-badge" style="display:none">0</span>

@ -2743,6 +2743,9 @@ function navigateToPage(pageId) {
currentPage = pageId;
// Show/hide global search bar (hide on downloads page where enhanced search exists)
if (typeof _gsUpdateVisibility === 'function') _gsUpdateVisibility();
// Show/hide discover download sidebar based on page
const downloadSidebar = document.getElementById('discover-download-sidebar');
if (downloadSidebar) {
@ -16803,6 +16806,464 @@ function _notifTimeAgo(ts) {
return `${Math.floor(h / 24)}d ago`;
}
// ==================================================================================
// GLOBAL SEARCH BAR — Spotlight-style search from anywhere
// ==================================================================================
const _gsState = {
active: false,
query: '',
data: null,
sources: {},
activeSource: null,
abortCtrl: null,
altAbortCtrl: null,
debounceTimer: null,
};
(function initGlobalSearch() {
// Defer init until DOM is ready
const _doInit = () => {
const bar = document.getElementById('gsearch-bar');
const input = document.getElementById('gsearch-input');
const results = document.getElementById('gsearch-results');
if (!input || !bar) return;
bar.addEventListener('click', () => input.focus());
input.addEventListener('focus', () => {
bar.classList.add('active');
_gsState.active = true;
const shortcut = document.getElementById('gsearch-shortcut');
if (shortcut) shortcut.style.display = 'none';
if (_gsState.data && _gsState.query) _gsShowResults();
});
// No blur handler — closing is handled by click-outside and Escape only
// This prevents tab switching and result clicks from closing the panel
const clearBtn = document.getElementById('gsearch-clear');
input.addEventListener('input', () => {
const q = input.value.trim();
_gsState.query = q;
if (clearBtn) clearBtn.style.display = q.length > 0 ? '' : 'none';
if (_gsState.debounceTimer) clearTimeout(_gsState.debounceTimer);
if (q.length < 2) { _gsHideResults(); return; }
_gsState.debounceTimer = setTimeout(() => _gsPerformSearch(q), 300);
});
if (clearBtn) {
clearBtn.addEventListener('click', e => {
e.stopPropagation();
input.value = '';
_gsState.query = '';
_gsState.data = null;
clearBtn.style.display = 'none';
_gsHideResults();
input.focus();
});
}
input.addEventListener('keydown', e => {
if (e.key === 'Enter') {
e.preventDefault();
if (_gsState.debounceTimer) clearTimeout(_gsState.debounceTimer);
const q = input.value.trim();
if (q.length >= 2) _gsPerformSearch(q);
} else if (e.key === 'Escape') {
_gsDeactivate();
input.blur();
}
});
// Keyboard shortcuts
document.addEventListener('keydown', e => {
if ((e.ctrlKey || e.metaKey) && e.key === 'k') { e.preventDefault(); input.focus(); return; }
if (e.key === '/' && !['INPUT', 'TEXTAREA', 'SELECT'].includes(document.activeElement?.tagName)) { e.preventDefault(); input.focus(); }
});
// Click outside to close — uses delayed check because tab clicks replace DOM
document.addEventListener('click', e => {
if (!_gsState.active) return;
// Skip if click was recent interaction with search system (within 100ms of a switch)
if (_gsState._lastInteraction && Date.now() - _gsState._lastInteraction < 200) return;
setTimeout(() => {
if (!_gsState.active) return;
const freshBar = document.getElementById('gsearch-bar');
const freshResults = document.getElementById('gsearch-results');
const target = e.target;
if (freshBar?.contains(target) || freshResults?.contains(target)) return;
_gsDeactivate();
}, 100);
});
// Collapse on sidebar navigation + hide on downloads page
document.addEventListener('click', e => {
if (e.target.closest('.sidebar-link, .nav-item, .back-btn')) {
if (_gsState.active) _gsDeactivate();
// Check after navigation which page we're on
setTimeout(_gsUpdateVisibility, 200);
}
});
};
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', () => { _doInit(); _gsUpdateVisibility(); });
else { _doInit(); setTimeout(_gsUpdateVisibility, 500); }
})();
function _gsUpdateVisibility() {
const bar = document.getElementById('gsearch-bar');
if (!bar) return;
// Hide on downloads page where enhanced search already exists
const onDownloads = typeof currentPage !== 'undefined' && currentPage === 'downloads';
bar.style.display = onDownloads ? 'none' : '';
if (onDownloads && _gsState.active) _gsDeactivate();
}
function _gsDeactivate() {
console.log('[GSearch] _gsDeactivate called', new Error().stack.split('\n')[2]?.trim());
const bar = document.getElementById('gsearch-bar');
const shortcut = document.getElementById('gsearch-shortcut');
if (bar) bar.classList.remove('active');
if (shortcut) shortcut.style.display = '';
_gsState.active = false;
_gsHideResults();
}
function _gsHideResults() {
const r = document.getElementById('gsearch-results');
if (r) r.classList.remove('visible');
}
function _gsShowResults() {
const r = document.getElementById('gsearch-results');
if (r && r.innerHTML.trim()) r.classList.add('visible');
}
async function _gsPerformSearch(query) {
if (_gsState.abortCtrl) _gsState.abortCtrl.abort();
if (_gsState.altAbortCtrl) _gsState.altAbortCtrl.abort();
_gsState.abortCtrl = new AbortController();
_gsState.altAbortCtrl = new AbortController();
const results = document.getElementById('gsearch-results');
if (!results) return;
results.innerHTML = '<div class="gsearch-loading"><div class="server-search-spinner"></div>Searching...</div>';
results.classList.add('visible');
try {
const res = await fetch('/api/enhanced-search', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ query }),
signal: _gsState.abortCtrl.signal,
});
const data = await res.json();
_gsState.data = data;
_gsState.activeSource = data.primary_source || 'spotify';
_gsState.sources = {};
_gsState.sources[_gsState.activeSource] = {
artists: data.spotify_artists || [],
albums: data.spotify_albums || [],
tracks: data.spotify_tracks || [],
};
_gsRender(data);
// Async library ownership check — adds badges + swaps play buttons for library tracks
setTimeout(() => _gsLibraryCheck(), 200);
// Fetch alternate sources
const alts = data.alternate_sources || [];
for (const src of alts) {
if (src === _gsState.activeSource) continue;
fetch(`/api/enhanced-search/source/${src}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ query }),
signal: _gsState.altAbortCtrl.signal,
}).then(r => r.json()).then(altData => {
if (altData.available) { _gsState.sources[src] = altData; _gsRenderTabs(); }
}).catch(() => {});
}
} catch (e) {
if (e.name !== 'AbortError') results.innerHTML = '<div class="gsearch-empty">Search failed</div>';
}
}
function _gsRender(data) {
const results = document.getElementById('gsearch-results');
if (!results) return;
const src = _gsState.sources[_gsState.activeSource] || {};
const dbArtists = data?.db_artists || [];
const artists = src.artists || [];
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 || [];
const total = dbArtists.length + artists.length + albums.length + singles.length + tracks.length;
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.classList.add('visible');
return;
}
const sourceLabels = { spotify: 'Spotify', itunes: 'Apple Music', deezer: 'Deezer', hydrabase: 'Hydrabase' };
const srcLabel = sourceLabels[_gsState.activeSource] || _gsState.activeSource || '';
let h = '';
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">';
if (dbArtists.length) {
h += '<div class="gsearch-section-header">📚 In Your Library</div><div class="gsearch-grid">';
h += dbArtists.map(a => `<div class="gsearch-item" onclick="_gsClickArtist('${a.id}', '${_escToast(a.name).replace(/'/g, "\\'")}', 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 class="gsearch-item-sub">Library</div></div></div>`).join('');
h += '</div>';
}
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>';
}
const activeSrc = _gsState.activeSource || 'spotify';
if (albums.length) {
h += `<div class="gsearch-section-header">💿 Albums <span class="gsearch-source-badge">${srcLabel}</span></div><div class="gsearch-grid">`;
h += albums.map(a => {
const ar = a.artist || (a.artists ? a.artists.join(', ') : '');
const yr = a.release_date ? a.release_date.substring(0, 4) : '';
const img = (a.image_url || '').replace(/'/g, "\\'");
return `<div class="gsearch-item" onclick="_gsClickAlbum('${a.id}', '${_escToast(a.name).replace(/'/g, "\\'")}', '${_escToast(ar).replace(/'/g, "\\'")}', '${img}', '${activeSrc}')"><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 class="gsearch-item-sub">${_escToast(ar)}${yr ? ` · ${yr}` : ''}</div></div></div>`;
}).join('');
h += '</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 => {
const ar = a.artist || (a.artists ? a.artists.join(', ') : '');
const img = (a.image_url || '').replace(/'/g, "\\'");
return `<div class="gsearch-item" onclick="_gsClickAlbum('${a.id}', '${_escToast(a.name).replace(/'/g, "\\'")}', '${_escToast(ar).replace(/'/g, "\\'")}', '${img}', '${activeSrc}')"><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 class="gsearch-item-sub">${_escToast(ar)}</div></div></div>`;
}).join('');
h += '</div>';
}
if (tracks.length) {
h += `<div class="gsearch-section-header">🎵 Tracks <span class="gsearch-source-badge">${srcLabel}</span></div><div class="gsearch-track-list">`;
h += tracks.map(t => {
const ar = t.artist || (t.artists ? t.artists.join(', ') : '');
const dur = t.duration_ms ? `${Math.floor(t.duration_ms / 60000)}:${String(Math.floor((t.duration_ms % 60000) / 1000)).padStart(2, '0')}` : '';
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>';
}
h += '</div>';
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 `<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.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;

@ -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 {

Loading…
Cancel
Save