import { subscribeLibrarySyncIdle, subscribeLibrarySyncProgress } from '@/lib/api/library'; import type { SearchResults } from '@/lib/api/subsonicTypes'; import { songToTrack } from '@/lib/media/songToTrack'; import { LIVE_SEARCH_DEBOUNCE_NETWORK_MS, LIVE_SEARCH_DEBOUNCE_RACE_MS, EMPTY_SEARCH_RESULTS, liveSearchQueryRejected, mergeLiveSearchResults, runLocalLiveSearch, runNetworkLiveSearch, } from '@/lib/library/liveSearchLocal'; import { raceLiveSearch } from '@/lib/library/searchRace'; import { libraryIsReady } from '@/lib/library/libraryReady'; import { emitLiveSearchDebug, searchHitCounts, searchResultSamples, } from '@/lib/library/liveSearchDebug'; import { logLibrarySearch, } from '@/lib/library/libraryDevLog'; import React, { useState, useEffect, useRef, useCallback } from 'react'; import { useNavigate, useLocation } from 'react-router-dom'; import { useNavigateToAlbum } from '@/features/album'; import { Search, TextSearch } from 'lucide-react'; import { usePlayerStore } from '@/features/playback/store/playerStore'; import { useAuthStore } from '@/store/authStore'; import { useLibraryIndexStore } from '@/store/libraryIndexStore'; import { useTranslation } from 'react-i18next'; import { useLiveSearchHeaderCollapse } from '@/features/search/hooks/useLiveSearchHeaderCollapse'; import LiveSearchDropdown, { type LiveSearchSource } from '@/features/search/components/LiveSearchDropdown'; import { showToast } from '@/lib/dom/toast'; import { useShareSearch } from '@/features/search/hooks/useShareSearch'; import { LiveSearchScopeBadge, LiveSearchScopeGhostBadge, } from '@/features/search/components/liveSearchScopeUi'; import { createLiveSearchScopeBackspaceState, handleLiveSearchScopeBackspace, handleLiveSearchScopeUndo, isLiveSearchDropdownBlocked, liveSearchScopePlaceholderKey, noteLiveSearchScopeQueryInput, resetLiveSearchScopeBackspaceState, resolveLiveSearchScopeGhost, } from '@/features/search/components/liveSearchScope'; import { useLiveSearchScopeStore } from '@/store/liveSearchScopeStore'; import { resolveIndexKey } from '@/lib/server/serverIndexKey'; export default function LiveSearch() { const { t } = useTranslation(); const query = useLiveSearchScopeStore(s => s.query); const setQuery = useLiveSearchScopeStore(s => s.setQuery); const scope = useLiveSearchScopeStore(s => s.scope); const setScope = useLiveSearchScopeStore(s => s.setScope); const clearScope = useLiveSearchScopeStore(s => s.clearScope); const undoLiveSearch = useLiveSearchScopeStore(s => s.undo); const scopeBackspaceRef = useRef(createLiveSearchScopeBackspaceState()); const location = useLocation(); const ghostScope = resolveLiveSearchScopeGhost(location.pathname, scope); const [results, setResults] = useState(null); const [open, setOpen] = useState(false); const [loading, setLoading] = useState(false); const [activeIndex, setActiveIndex] = useState(-1); const [isFocused, setIsFocused] = useState(false); const [searchSource, setSearchSource] = useState(null); const localReadyRef = useRef(false); const liveSearchGenRef = useRef(0); const navigate = useNavigate(); const navigateToAlbum = useNavigateToAlbum(); const enqueue = usePlayerStore(state => state.enqueue); const ctxIsOpen = usePlayerStore(state => state.contextMenu.isOpen); const ref = useRef(null); const dropdownRef = useRef(null); const inputRef = useRef(null); const isCollapsed = useLiveSearchHeaderCollapse(ref); const serverId = useAuthStore(s => s.activeServerId); const musicLibraryFilterVersion = useAuthStore(s => s.musicLibraryFilterVersion); const indexEnabled = useLibraryIndexStore(s => s.isIndexEnabled(serverId)); const refreshLocalReady = useCallback(async () => { if (!serverId || !indexEnabled) { localReadyRef.current = false; return; } localReadyRef.current = await libraryIsReady(serverId); }, [serverId, indexEnabled]); useEffect(() => { void refreshLocalReady(); }, [refreshLocalReady, musicLibraryFilterVersion]); useEffect(() => { resetLiveSearchScopeBackspaceState(scopeBackspaceRef.current); }, [scope]); useEffect(() => { noteLiveSearchScopeQueryInput(scopeBackspaceRef.current, query); }, [query]); useEffect(() => { if (!indexEnabled || !serverId) return; let unlistenProgress: (() => void) | undefined; let unlistenIdle: (() => void) | undefined; const indexKey = resolveIndexKey(serverId); void subscribeLibrarySyncIdle(payload => { if (payload.serverId === indexKey) void refreshLocalReady(); }).then(fn => { unlistenIdle = fn; }); void subscribeLibrarySyncProgress(p => { if (p.serverId === indexKey && p.kind === 'phase_changed') void refreshLocalReady(); }).then(fn => { unlistenProgress = fn; }); return () => { unlistenIdle?.(); unlistenProgress?.(); }; }, [indexEnabled, serverId, refreshLocalReady]); const closeSearch = useCallback(() => { setOpen(false); setQuery(''); setSearchSource(null); }, [setQuery]); const handleQueryChange = useCallback((value: string) => { setQuery(value, { recordUndo: true }); if (!value) { setResults(null); setOpen(false); setSearchSource(null); } }, [setQuery]); /** Leave live search for a full-page route — cancel in-flight queries and reset overlay state. */ const leaveLiveSearchFor = useCallback((path: string) => { liveSearchGenRef.current += 1; setOpen(false); setQuery(''); setResults(null); setSearchSource(null); setActiveIndex(-1); setLoading(false); setIsFocused(false); inputRef.current?.blur(); navigate(path); }, [navigate, setQuery]); const share = useShareSearch(query, closeSearch); useEffect(() => { if (isLiveSearchDropdownBlocked(scope)) { // React Compiler set-state-in-effect rule: local state synced with store/prop inputs when the effect’s dependencies change. // eslint-disable-next-line react-hooks/set-state-in-effect setResults(null); setOpen(false); setSearchSource(null); setLoading(false); return; } if (share.shareMatch) { setResults(null); setLoading(false); setSearchSource(null); setOpen(true); setActiveIndex(-1); return; } const q = query.trim(); if (!q) { setResults(null); setOpen(false); setSearchSource(null); setLoading(false); return; } setSearchSource(null); setActiveIndex(-1); const abort = new AbortController(); const debounceMs = indexEnabled ? LIVE_SEARCH_DEBOUNCE_RACE_MS : LIVE_SEARCH_DEBOUNCE_NETWORK_MS; const timer = window.setTimeout(() => { void (async () => { const gen = liveSearchGenRef.current; const isStale = () => gen !== liveSearchGenRef.current || abort.signal.aborted; if (isStale()) return; setLoading(true); const searchT0 = performance.now(); try { if (liveSearchQueryRejected(q)) { if (!isStale()) { setResults(EMPTY_SEARCH_RESULTS); setSearchSource(null); setOpen(true); } return; } const raceCtx = { epoch: gen, isStale, suppressLog: indexEnabled && !!serverId }; if (indexEnabled && serverId) { const winner = await raceLiveSearch( () => runLocalLiveSearch(serverId, q, raceCtx), () => runNetworkLiveSearch(q, abort.signal), isStale, meta => { emitLiveSearchDebug('race_settled', { query: q, winner: meta.winner, localMs: meta.localMs, networkMs: meta.networkMs, localHits: meta.localHits, networkHits: meta.networkHits, }); if (isStale()) return; if (meta.localResult && meta.networkResult) { const primary = meta.winner === 'local' ? meta.localResult : meta.networkResult; const supplement = meta.winner === 'local' ? meta.networkResult : meta.localResult; const merged = mergeLiveSearchResults(primary, supplement); const primaryHits = searchHitCounts(primary); const mergedHits = searchHitCounts(merged); if (mergedHits !== primaryHits) { setResults(merged); setSearchSource(meta.winner); emitLiveSearchDebug('race_merged', { query: q, winner: meta.winner, before: primaryHits, after: mergedHits, samples: searchResultSamples(merged), }); } } }, ); if (isStale()) return; if (winner) { setResults(winner.result); setSearchSource(winner.source); setOpen(true); const samples = searchResultSamples(winner.result); emitLiveSearchDebug('race_winner', { query: q, winner: winner.source, raceMs: winner.durationMs, hits: searchHitCounts(winner.result), samples, path: 'search_race', localReady: localReadyRef.current, }); logLibrarySearch({ at: new Date().toISOString(), query: q, path: 'search_race', surface: 'live_search', durationMs: Math.round(performance.now() - searchT0), debounceMs, indexEnabled, localReadyCached: localReadyRef.current, raceWinner: winner.source, raceWinnerMs: winner.durationMs, counts: { artists: winner.result.artists.length, albums: winner.result.albums.length, songs: winner.result.songs.length, }, }); return; } showToast(t('search.liveSearchFailed'), 3200, 'error'); } else if (serverId) { const network = await runNetworkLiveSearch(q, abort.signal); if (isStale()) return; if (network) { setResults(network); setSearchSource('network'); setOpen(true); logLibrarySearch({ at: new Date().toISOString(), query: q, path: 'search3', surface: 'live_search', source: 'network', durationMs: Math.round(performance.now() - searchT0), debounceMs, indexEnabled, counts: { artists: network.artists.length, albums: network.albums.length, songs: network.songs.length, }, }); } } } catch (err) { if (isStale()) return; const name = err instanceof Error ? err.name : ''; if (name === 'CanceledError' || name === 'AbortError') return; showToast(t('search.liveSearchFailed'), 3200, 'error'); } finally { if (!isStale()) setLoading(false); } })(); }, debounceMs); return () => { window.clearTimeout(timer); abort.abort(); liveSearchGenRef.current += 1; }; }, [query, scope, share.shareMatch, serverId, indexEnabled, musicLibraryFilterVersion, t]); const isSearchActive = isFocused || open || query.trim().length > 0 || scope != null; useEffect(() => { const root = ref.current; if (!root) return; const header = root.closest('.content-header') as HTMLElement | null; if (!header) return; const overlayActive = isCollapsed && isSearchActive; if (overlayActive) { header.dataset.liveSearchOverlay = 'true'; } else { delete header.dataset.liveSearchOverlay; } return () => { delete header.dataset.liveSearchOverlay; }; }, [isCollapsed, isSearchActive]); // Close on click outside — but stay open while a song context menu is up. // The CM renders a fullscreen transparent backdrop (z-index 998) above the // dropdown, so any mousedown — including a second right-click on another // row — would otherwise hit the backdrop and trip this handler, yanking the // dropdown closed mid-interaction. useEffect(() => { const handler = (e: MouseEvent) => { if (ctxIsOpen) return; if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false); }; document.addEventListener('mousedown', handler); return () => document.removeEventListener('mousedown', handler); }, [ctxIsOpen]); // Flat list of all navigable items for keyboard nav const flatItems = share.shareMatch && share.hasShareKeyboardTarget ? [ { id: 'share-link', action: () => { if (share.canQueueShareMatch) void share.enqueueShareMatch(); else if (share.canOpenShareAlbum) share.openShareAlbum(); else if (share.canOpenShareArtist) share.openShareArtist(); else if (share.canOpenShareComposer) share.openShareComposer(); }, }, ] : results ? [ ...(results.artists.map(a => ({ id: a.id, action: () => { navigate(`/artist/${a.id}`); setOpen(false); setQuery(''); } }))), ...(results.albums.map(a => ({ id: a.id, action: () => { navigateToAlbum(a.id); setOpen(false); setQuery(''); } }))), ...(results.songs.map(s => ({ id: s.id, action: () => { const track = songToTrack(s); enqueue([track]); showToast(t('search.addedToQueueToast', { title: track.title }), 2200, 'info'); setOpen(false); setQuery(''); }}))), ] : []; const handleKeyDown = (e: React.KeyboardEvent) => { if (handleLiveSearchScopeUndo(e, undoLiveSearch)) return; if (handleLiveSearchScopeBackspace(e, query, scope, clearScope, scopeBackspaceRef.current)) return; if (isLiveSearchDropdownBlocked(scope)) return; if (share.shareMatch) { if (e.key === 'Enter') { e.preventDefault(); if (share.canQueueShareMatch) void share.enqueueShareMatch(); else if (share.canOpenShareAlbum) share.openShareAlbum(); else if (share.canOpenShareArtist) share.openShareArtist(); else if (share.canOpenShareComposer) share.openShareComposer(); } else if (e.key === 'ArrowDown' || e.key === 'ArrowUp') { e.preventDefault(); setActiveIndex(share.hasShareKeyboardTarget ? 0 : -1); } else if (e.key === 'Escape') { setOpen(false); setActiveIndex(-1); } return; } if (!open || !flatItems.length) { if (e.key === 'Enter' && query.trim()) { e.preventDefault(); leaveLiveSearchFor(`/search?q=${encodeURIComponent(query.trim())}`); } return; } if (e.key === 'ArrowDown') { e.preventDefault(); const next = Math.min(activeIndex + 1, flatItems.length - 1); setActiveIndex(next); dropdownRef.current?.querySelectorAll('.search-result-item')[next]?.scrollIntoView({ block: 'nearest' }); } else if (e.key === 'ArrowUp') { e.preventDefault(); const next = Math.max(activeIndex - 1, -1); setActiveIndex(next); if (next >= 0) dropdownRef.current?.querySelectorAll('.search-result-item')[next]?.scrollIntoView({ block: 'nearest' }); } else if (e.key === 'Enter') { e.preventDefault(); if (activeIndex >= 0) { flatItems[activeIndex].action(); setActiveIndex(-1); } else if (query.trim()) { leaveLiveSearchFor(`/search?q=${encodeURIComponent(query.trim())}`); } } else if (e.key === 'Escape') { setOpen(false); setActiveIndex(-1); } }; return (
{ if (isSearchActive) return; if (!isCollapsed) return; e.preventDefault(); setIsFocused(true); requestAnimationFrame(() => inputRef.current?.focus()); }} >
{loading ? (
) : ( )} {scope && ( )} {ghostScope && ( )} handleQueryChange(e.target.value)} onFocus={() => { setIsFocused(true); if (!isLiveSearchDropdownBlocked(scope) && results) setOpen(true); }} onBlur={() => setIsFocused(false)} onKeyDown={handleKeyDown} aria-autocomplete="list" aria-controls="search-results" aria-expanded={open && !isLiveSearchDropdownBlocked(scope)} autoComplete="off" />
{open && !isLiveSearchDropdownBlocked(scope) && ( )}
); }