diff --git a/src/features/search/components/LiveSearch.tsx b/src/features/search/components/LiveSearch.tsx index dbda5cd1..a071cdaf 100644 --- a/src/features/search/components/LiveSearch.tsx +++ b/src/features/search/components/LiveSearch.tsx @@ -1,34 +1,13 @@ -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 { useLiveSearchQuery } from '@/features/search/hooks/useLiveSearchQuery'; import LiveSearchDropdown, { type LiveSearchSource } from '@/features/search/components/LiveSearchDropdown'; import { showToast } from '@/lib/dom/toast'; import { useShareSearch } from '@/features/search/hooks/useShareSearch'; @@ -47,7 +26,6 @@ import { resolveLiveSearchScopeGhost, } from '@/features/search/components/liveSearchScope'; import { useLiveSearchScopeStore } from '@/store/liveSearchScopeStore'; -import { resolveIndexKey } from '@/lib/server/serverIndexKey'; export default function LiveSearch() { const { t } = useTranslation(); @@ -66,7 +44,6 @@ export default function LiveSearch() { 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(); @@ -76,21 +53,6 @@ export default function LiveSearch() { 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); @@ -100,27 +62,6 @@ export default function LiveSearch() { 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(''); @@ -152,176 +93,17 @@ export default function LiveSearch() { 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]); + useLiveSearchQuery({ + query, + scope, + shareMatch: share.shareMatch, + liveSearchGenRef, + setResults, + setOpen, + setLoading, + setSearchSource, + setActiveIndex, + }); const isSearchActive = isFocused || open || query.trim().length > 0 || scope != null; diff --git a/src/features/search/hooks/useLiveSearchQuery.ts b/src/features/search/hooks/useLiveSearchQuery.ts new file mode 100644 index 00000000..a9dadf6a --- /dev/null +++ b/src/features/search/hooks/useLiveSearchQuery.ts @@ -0,0 +1,270 @@ +import { useRef, useEffect, useCallback, type Dispatch, type SetStateAction, type MutableRefObject } from 'react'; +import { useTranslation } from 'react-i18next'; +import { subscribeLibrarySyncIdle, subscribeLibrarySyncProgress } from '@/lib/api/library'; +import type { SearchResults } from '@/lib/api/subsonicTypes'; +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 { resolveIndexKey } from '@/lib/server/serverIndexKey'; +import { showToast } from '@/lib/dom/toast'; +import { useAuthStore } from '@/store/authStore'; +import { useLibraryIndexStore } from '@/store/libraryIndexStore'; +import type { LiveSearchScope } from '@/store/liveSearchScopeStore'; +import { isLiveSearchDropdownBlocked } from '@/features/search/components/liveSearchScope'; +import type { useShareSearch } from '@/features/search/hooks/useShareSearch'; +import type { LiveSearchSource } from '@/features/search/components/LiveSearchDropdown'; + +interface UseLiveSearchQueryParams { + query: string; + scope: LiveSearchScope | null; + shareMatch: ReturnType['shareMatch']; + /** Generation counter for in-flight cancellation; the component also bumps it on route leave. */ + liveSearchGenRef: MutableRefObject; + setResults: Dispatch>; + setOpen: Dispatch>; + setLoading: Dispatch>; + setSearchSource: Dispatch>; + setActiveIndex: Dispatch>; +} + +/** + * Runs the debounced live-search query (local-index/network race, merge, logging) and + * pushes the results into the caller's state setters. Owns the local-ready cache that the + * race logging reads; the overlay/keyboard/render state stays in the component. + */ +export function useLiveSearchQuery({ + query, + scope, + shareMatch, + liveSearchGenRef, + setResults, + setOpen, + setLoading, + setSearchSource, + setActiveIndex, +}: UseLiveSearchQueryParams) { + const { t } = useTranslation(); + const serverId = useAuthStore(s => s.activeServerId); + const musicLibraryFilterVersion = useAuthStore(s => s.musicLibraryFilterVersion); + const indexEnabled = useLibraryIndexStore(s => s.isIndexEnabled(serverId)); + const localReadyRef = useRef(false); + + const refreshLocalReady = useCallback(async () => { + if (!serverId || !indexEnabled) { + localReadyRef.current = false; + return; + } + localReadyRef.current = await libraryIsReady(serverId); + }, [serverId, indexEnabled]); + + useEffect(() => { + void refreshLocalReady(); + }, [refreshLocalReady, musicLibraryFilterVersion]); + + 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]); + + useEffect(() => { + if (isLiveSearchDropdownBlocked(scope)) { + setResults(null); + setOpen(false); + setSearchSource(null); + setLoading(false); + return; + } + + if (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, shareMatch, serverId, indexEnabled, musicLibraryFilterVersion, t, + liveSearchGenRef, setResults, setOpen, setLoading, setSearchSource, setActiveIndex, + ]); +}