import { getGenres, getAlbumsByGenre } from '../api/subsonicGenres'; import { search, searchSongsPaged } from '../api/subsonicSearch'; import { getRandomSongs } from '../api/subsonicLibrary'; import type { SubsonicGenre, SubsonicArtist, SubsonicAlbum, SubsonicSong } from '../api/subsonicTypes'; import React, { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'; import { useLocation, useNavigate, useNavigationType, useSearchParams } from 'react-router-dom'; import { SlidersVertical, Search, X } from 'lucide-react'; import { useTranslation } from 'react-i18next'; import AlbumRow from '../components/AlbumRow'; import ArtistRow from '../components/ArtistRow'; import PagedSongList from '../components/PagedSongList'; import CustomSelect from '../components/CustomSelect'; import StarFilterButton from '../components/StarFilterButton'; import { APP_MAIN_SCROLL_VIEWPORT_ID } from '../constants/appScroll'; import { useAuthStore } from '../store/authStore'; import { usePlayerStore } from '../store/playerStore'; import { isAdvancedSearchLeaveTargetPath } from '../store/albumBrowseSessionStore'; import { isAdvancedSearchPath, isAdvancedSearchPanelPath, isTracksBrowsePath, useAdvancedSearchSessionStore, type AdvancedSearchSessionStash, } from '../store/advancedSearchSessionStore'; import { readAdvancedSearchRestore, shouldRestoreAdvancedSearchSession, } from '../utils/navigation/albumDetailNavigation'; import { clearAdvancedSearchLeaveSnapshots, consumeAdvancedSearchLeavingForDetail, readAdvancedSearchLeaveSnapshot, registerAdvancedSearchLeaveScrollProvider, registerAdvancedSearchSessionProvider, resolveAdvancedSearchLeaveSnapshot, type AdvancedSearchLeaveSnapshot, } from '../utils/navigation/advancedSearchScrollSnapshot'; import { restoreMainViewportScroll } from '../utils/navigation/restoreMainViewportScroll'; import { loadMoreLocalSongs, runNetworkAdvancedTextSearch, runNetworkAdvancedYearAlbums, tryRunLocalAdvancedSearch, } from '../utils/library/advancedSearchLocal'; import { isLosslessSuffix } from '../utils/library/losslessFormats'; import { LOSSLESS_MODE_QUERY } from '../utils/library/losslessMode'; import { OXIMEDIA_MOOD_SEARCH_ENABLED } from '../utils/library/trackEnrichment'; import { raceSearchSources } from '../utils/library/searchRace'; import { logLibrarySearch } from '../utils/library/libraryDevLog'; import { browseRaceCountsFullSearch, loadMoreLocalBrowseSongs, raceBrowseWithLocalFallback, runLocalBrowseFullSearch, runNetworkBrowseFullSearch, } from '../utils/library/browseTextSearch'; import { useLibraryIndexStore } from '../store/libraryIndexStore'; import { MOOD_GROUP_IDS } from '../config/moodGroups'; import { usePerfProbeFlags } from '../utils/perf/perfFlags'; import { useSongBrowseList, type SongBrowseListRestore } from '../hooks/useSongBrowseList'; import TracksPageChrome from '../components/tracks/TracksPageChrome'; import SongBrowseSection from '../components/tracks/SongBrowseSection'; import { useLiveSearchScopeStore, useScopedBrowseSearchQuery, } from '../store/liveSearchScopeStore'; const MOOD_UI_ENABLED = OXIMEDIA_MOOD_SEARCH_ENABLED; type ResultType = 'all' | 'artists' | 'albums' | 'songs'; interface SearchOpts { query: string; genre: string; yearFrom: string; yearTo: string; bpmFrom: string; bpmTo: string; moodGroup: string; losslessOnly: boolean; resultType: ResultType; } interface Results { artists: SubsonicArtist[]; albums: SubsonicAlbum[]; songs: SubsonicSong[]; } function parseBpmInput(raw: string): number | null { const trimmed = raw.trim(); if (!trimmed) return null; const n = parseInt(trimmed, 10); return Number.isFinite(n) ? n : null; } function peekAdvancedSearchRestoreStash( navigationType: ReturnType, locationState: unknown, ): AdvancedSearchSessionStash | null { if (!shouldRestoreAdvancedSearchSession(navigationType, locationState)) return null; return useAdvancedSearchSessionStore.getState().peekReturnStash(); } /** Shared shell for `/search`, `/search/advanced`, and `/tracks` (pathname picks chrome). */ export default function SearchBrowsePage() { const perfFlags = usePerfProbeFlags(); const { t } = useTranslation(); const navigationType = useNavigationType(); const location = useLocation(); const navigate = useNavigate(); const [params] = useSearchParams(); const qFromUrl = params.get('q') ?? ''; const showTracksChrome = isTracksBrowsePath(location.pathname); const showAdvancedPanel = isAdvancedSearchPanelPath(location.pathname); const restoreStash = peekAdvancedSearchRestoreStash(navigationType, location.state); const hadRestoreOnMountRef = useRef(restoreStash != null); const restoredFromStashRef = useRef(restoreStash != null); const [query, setQuery] = useState(() => restoreStash?.query ?? qFromUrl); const [genre, setGenre] = useState(() => restoreStash?.genre ?? ''); const [yearFrom, setYearFrom] = useState(() => restoreStash?.yearFrom ?? ''); const [yearTo, setYearTo] = useState(() => restoreStash?.yearTo ?? ''); const [bpmFrom, setBpmFrom] = useState(() => restoreStash?.bpmFrom ?? ''); const [bpmTo, setBpmTo] = useState(() => restoreStash?.bpmTo ?? ''); const [moodGroup, setMoodGroup] = useState(() => restoreStash?.moodGroup ?? ''); const [losslessOnly, setLosslessOnly] = useState(() => restoreStash?.losslessOnly ?? false); const [resultType, setResultType] = useState(() => restoreStash?.resultType ?? 'all'); const [starredOnly, setStarredOnly] = useState(() => restoreStash?.starredOnly ?? false); const [genres, setGenres] = useState([]); const [results, setResults] = useState(() => restoreStash?.results ?? null); const starredOverrides = usePlayerStore(s => s.starredOverrides); const filteredResults = useMemo(() => { if (!results) return null; if (!starredOnly) return results; const isFav = (id: string, base: boolean | string | undefined) => id in starredOverrides ? !!starredOverrides[id] : !!base; return { artists: results.artists.filter(a => isFav(a.id, a.starred)), albums: results.albums.filter(a => isFav(a.id, a.starred)), songs: results.songs.filter(s => isFav(s.id, s.starred)), }; }, [results, starredOnly, starredOverrides]); const total = filteredResults ? filteredResults.artists.length + filteredResults.albums.length + filteredResults.songs.length : 0; const [loading, setLoading] = useState(false); const [hasSearched, setHasSearched] = useState(() => restoreStash?.hasSearched ?? false); const [genreNote, setGenreNote] = useState(() => restoreStash?.genreNote ?? false); // True while the current results came from the local index (drives the // pagination branch — local pages every result type, network only free-text). const [localMode, setLocalMode] = useState(() => restoreStash?.localMode ?? false); const [basicSearchMode, setBasicSearchMode] = useState( () => restoreStash?.basicSearchMode ?? (!showAdvancedPanel && !showTracksChrome), ); const musicLibraryFilterVersion = useAuthStore(s => s.musicLibraryFilterVersion); const serverId = useAuthStore(s => s.activeServerId); const indexEnabled = useLibraryIndexStore(s => s.isIndexEnabled(serverId)); const searchRunRef = useRef(0); // Pagination — basic quick search uses smaller pages than advanced form search. const BASIC_SONGS_INITIAL = 50; const BASIC_SONGS_PAGE_SIZE = 50; const SONGS_INITIAL = 100; const SONGS_PAGE_SIZE = 50; const [activeSearch, setActiveSearch] = useState(() => restoreStash?.activeSearch ?? null); const [songsServerOffset, setSongsServerOffset] = useState(() => restoreStash?.songsServerOffset ?? 0); const [songsHasMore, setSongsHasMore] = useState(() => restoreStash?.songsHasMore ?? false); const [loadingMoreSongs, setLoadingMoreSongs] = useState(false); const songBrowseInitialRestore: SongBrowseListRestore | null = restoreStash && showTracksChrome ? { query: restoreStash.query, songs: restoreStash.results?.songs ?? [], offset: restoreStash.songsServerOffset, hasMore: restoreStash.songsHasMore, localSearchMode: restoreStash.localMode, browseUnsupported: restoreStash.tracksBrowseUnsupported ?? false, hasSearched: restoreStash.hasSearched, } : null; const tracksLiveSearchInitRef = useRef(false); if (!tracksLiveSearchInitRef.current && restoreStash && showTracksChrome) { tracksLiveSearchInitRef.current = true; const store = useLiveSearchScopeStore.getState(); store.setScope('tracks'); if (restoreStash.query) store.setQuery(restoreStash.query); } const tracksSearchQuery = useScopedBrowseSearchQuery('tracks'); const liveSearchQuery = useLiveSearchScopeStore(s => s.query); const tracksSearchActive = tracksSearchQuery.trim().length > 0 || liveSearchQuery.trim().length > 0; const songBrowse = useSongBrowseList({ enabled: showTracksChrome, searchQuery: tracksSearchQuery, initialRestore: songBrowseInitialRestore, }); const restoringSession = shouldRestoreAdvancedSearchSession(navigationType, location.state) || restoreStash != null; const leaveSnapshotRef = useRef( restoringSession ? resolveAdvancedSearchLeaveSnapshot(restoreStash) : null, ); const scrollTopRestoreTargetRef = useRef(leaveSnapshotRef.current?.scrollTop ?? 0); const tracksSearchRestorePendingRef = useRef( !!(songBrowseInitialRestore?.query.trim()), ); const albumRowScrollLeftRestoreRef = useRef(leaveSnapshotRef.current?.albumRowScrollLeft ?? 0); const artistRowScrollLeftRestoreRef = useRef(leaveSnapshotRef.current?.artistRowScrollLeft ?? 0); const mainScrollTopRef = useRef(0); const albumRowScrollLeftRef = useRef(0); const artistRowScrollLeftRef = useRef(0); const skipSearchAutoFocusRef = useRef(restoreStash != null); const skipEnterAnimationRef = useRef(restoreStash != null || leaveSnapshotRef.current != null); const leaveRestoreUiFinishedRef = useRef(leaveSnapshotRef.current == null); const restoringTracksSearch = !!(restoreStash?.query.trim() && showTracksChrome); const [tracksChromeLayoutReady, setTracksChromeLayoutReady] = useState( () => !showTracksChrome || leaveSnapshotRef.current == null || restoringTracksSearch, ); const [isLeaveRestorePending, setIsLeaveRestorePending] = useState( () => leaveSnapshotRef.current != null, ); const tracksDiscoveryHidden = tracksSearchActive || (isLeaveRestorePending && !!(restoreStash?.query.trim() || songBrowseInitialRestore?.query.trim())); const handleTracksChromeLayoutReady = useCallback(() => { setTracksChromeLayoutReady(true); }, []); const finishLeaveRestoreUi = useCallback(() => { if (leaveRestoreUiFinishedRef.current) return; leaveRestoreUiFinishedRef.current = true; leaveSnapshotRef.current = null; setIsLeaveRestorePending(false); // Defer stash teardown until after AppShell's route-change scroll reset effect. window.setTimeout(() => { clearAdvancedSearchLeaveSnapshots(); if (hadRestoreOnMountRef.current) { useAdvancedSearchSessionStore.getState().clearReturnStash(); } }, 0); }, []); const sessionRef = useRef({ query: '', genre: '', yearFrom: '', yearTo: '', bpmFrom: '', bpmTo: '', moodGroup: '', losslessOnly: false, resultType: 'all', starredOnly: false, results: null, hasSearched: false, activeSearch: null, localMode: false, songsServerOffset: 0, songsHasMore: false, genreNote: false, basicSearchMode: false, tracksBrowseMode: false, tracksBrowseUnsupported: false, }); sessionRef.current = { query: showTracksChrome ? liveSearchQuery : query, genre, yearFrom, yearTo, bpmFrom, bpmTo, moodGroup, losslessOnly, resultType, starredOnly, results: showTracksChrome ? { artists: [], albums: [], songs: songBrowse.songs } : results, hasSearched: showTracksChrome ? songBrowse.hasSearched : hasSearched, activeSearch, localMode: showTracksChrome ? songBrowse.localSearchMode : localMode, songsServerOffset: showTracksChrome ? songBrowse.offset : songsServerOffset, songsHasMore: showTracksChrome ? songBrowse.hasMore : songsHasMore, genreNote, basicSearchMode: showTracksChrome ? false : basicSearchMode, tracksBrowseMode: showTracksChrome, tracksBrowseUnsupported: showTracksChrome ? songBrowse.browseUnsupported : false, }; useEffect(() => { const unregisterScroll = registerAdvancedSearchLeaveScrollProvider(() => ({ scrollTop: mainScrollTopRef.current, albumRowScrollLeft: albumRowScrollLeftRef.current, artistRowScrollLeft: artistRowScrollLeftRef.current, })); const unregisterSession = registerAdvancedSearchSessionProvider(() => sessionRef.current); return () => { unregisterScroll(); unregisterSession(); }; }, []); useEffect(() => { const el = document.getElementById(APP_MAIN_SCROLL_VIEWPORT_ID); if (!el) return; const syncScroll = () => { mainScrollTopRef.current = el.scrollTop; }; syncScroll(); el.addEventListener('scroll', syncScroll, { passive: true }); return () => el.removeEventListener('scroll', syncScroll); }, []); const applySongFilters = ( list: SubsonicSong[], g: string, from: number | null, to: number | null, bpmLo: number | null, bpmHi: number | null, lossless = false, ): SubsonicSong[] => { let r = list; if (g) r = r.filter(s => s.genre?.toLowerCase() === g.toLowerCase()); if (from !== null) r = r.filter(s => !s.year || s.year >= from); if (to !== null) r = r.filter(s => !s.year || s.year <= to); if (bpmLo !== null) r = r.filter(s => s.bpm != null && s.bpm > 0 && s.bpm >= bpmLo); if (bpmHi !== null) r = r.filter(s => s.bpm != null && s.bpm > 0 && s.bpm <= bpmHi); if (lossless) r = r.filter(s => isLosslessSuffix(s.suffix)); return r; }; const runBasicSearch = async (rawQuery: string) => { const q = rawQuery.trim(); const runId = ++searchRunRef.current; const isStale = () => runId !== searchRunRef.current; setLoading(true); setHasSearched(true); setGenreNote(false); setBasicSearchMode(true); setQuery(q); setActiveSearch({ query: q, genre: '', yearFrom: '', yearTo: '', bpmFrom: '', bpmTo: '', moodGroup: '', losslessOnly: false, resultType: 'all', }); setSongsServerOffset(0); setSongsHasMore(false); setLocalMode(false); if (!q) { setResults(null); setLoading(false); return; } try { if (serverId && indexEnabled) { const outcome = await raceBrowseWithLocalFallback( isStale, () => runLocalBrowseFullSearch(serverId, q, BASIC_SONGS_INITIAL), () => runNetworkBrowseFullSearch(q, BASIC_SONGS_INITIAL), { surface: 'search_results', query: q, indexEnabled, counts: browseRaceCountsFullSearch, }, ); if (isStale()) return; if (outcome) { setResults(outcome.result); setSongsServerOffset(outcome.result.songs.length); setSongsHasMore(outcome.result.songs.length >= BASIC_SONGS_INITIAL); setLocalMode(outcome.source === 'local'); return; } } const network = await runNetworkBrowseFullSearch(q, BASIC_SONGS_INITIAL); if (isStale()) return; if (network) { setResults(network); setSongsServerOffset(network.songs.length); setSongsHasMore(network.songs.length >= BASIC_SONGS_INITIAL); } else { setResults({ artists: [], albums: [], songs: [] }); } } catch { if (!isStale()) setResults(null); } finally { if (!isStale()) setLoading(false); } }; const runSearch = async (opts: SearchOpts) => { const runId = ++searchRunRef.current; const isStale = () => runId !== searchRunRef.current; setLoading(true); setHasSearched(true); setGenreNote(false); setBasicSearchMode(false); setActiveSearch(opts); setSongsServerOffset(0); setSongsHasMore(false); const q = opts.query.trim(); const searchT0 = performance.now(); const moodFilterActive = MOOD_UI_ENABLED && !!opts.moodGroup; const bpmFilterActive = !!(opts.bpmFrom || opts.bpmTo); const losslessFilterActive = opts.losslessOnly; const trackOnlyFilterActive = moodFilterActive || bpmFilterActive; // Track-only filters (BPM dual-storage, mood) need the local index for full coverage. // Lossless skips the race — network search3 cannot filter albums by format reliably. if (q && serverId && indexEnabled && !trackOnlyFilterActive && !losslessFilterActive) { try { const winner = await raceSearchSources( [ { source: 'local', run: () => tryRunLocalAdvancedSearch(serverId, opts, SONGS_INITIAL, true), }, { source: 'network', run: () => runNetworkAdvancedTextSearch(opts, SONGS_INITIAL), }, ], isStale, ); if (isStale()) return; if (winner) { setResults({ artists: winner.result.artists, albums: winner.result.albums, songs: winner.result.songs, }); setSongsServerOffset(winner.result.songs.length); setSongsHasMore(winner.result.songs.length >= SONGS_INITIAL); setLocalMode(winner.source === 'local'); logLibrarySearch({ at: new Date().toISOString(), query: q, path: 'search_race', surface: 'advanced_search', durationMs: Math.round(performance.now() - searchT0), indexEnabled, raceWinner: winner.source, raceWinnerMs: winner.durationMs, counts: { artists: winner.result.artists.length, albums: winner.result.albums.length, songs: winner.result.songs.length, }, }); setLoading(false); return; } } catch { if (isStale()) return; } setLocalMode(false); } else if (serverId && indexEnabled) { const localPage = await tryRunLocalAdvancedSearch(serverId, opts, SONGS_INITIAL); if (isStale()) return; if (localPage) { setResults({ artists: localPage.artists, albums: localPage.albums, songs: localPage.songs, }); setSongsServerOffset(localPage.songs.length); setSongsHasMore(localPage.songs.length >= SONGS_INITIAL); setLocalMode(true); setLoading(false); return; } if (trackOnlyFilterActive) { setResults({ artists: [], albums: [], songs: [] }); setLoading(false); return; } setLocalMode(false); } else { setLocalMode(false); } if ((trackOnlyFilterActive || losslessFilterActive) && !indexEnabled) { setResults({ artists: [], albums: [], songs: [] }); setLoading(false); return; } const { genre: g, yearFrom: yf, yearTo: yt, bpmFrom: bf, bpmTo: bt, losslessOnly: lossless, resultType: rt } = opts; const from = yf ? parseInt(yf) : null; const to = yt ? parseInt(yt) : null; const bpmLo = bf ? parseInt(bf) : null; const bpmHi = bt ? parseInt(bt) : null; let artists: SubsonicArtist[] = []; let albums: SubsonicAlbum[] = []; let songs: SubsonicSong[] = []; try { if (q.trim()) { const r = await search(q.trim(), { artistCount: 30, albumCount: 50, songCount: SONGS_INITIAL }); artists = r.artists; albums = r.albums; songs = applySongFilters(r.songs, g, from, to, bpmLo, bpmHi, lossless); if (g) { albums = albums.filter(a => a.genre?.toLowerCase() === g.toLowerCase()); } if (from !== null) { albums = albums.filter(a => !a.year || a.year >= from); } if (to !== null) { albums = albums.filter(a => !a.year || a.year <= to); } if (lossless) { const albumIds = new Set(songs.map(s => s.albumId).filter(Boolean)); albums = albums.filter(a => albumIds.has(a.id)); const artistIds = new Set(songs.map(s => s.artistId).filter(Boolean)); artists = artists.filter(a => artistIds.has(a.id)); } // Only the free-text branch supports server-side pagination via search3 offset. // If the server returned a full page, more probably exist. setSongsServerOffset(r.songs.length); setSongsHasMore(r.songs.length === SONGS_INITIAL); } else if (g) { const [albumRes, songRes] = await Promise.all([ rt === 'songs' || rt === 'artists' ? Promise.resolve([]) : getAlbumsByGenre(g, 50), rt === 'albums' || rt === 'artists' ? Promise.resolve([]) : getRandomSongs(100, g), ]); albums = albumRes as SubsonicAlbum[]; songs = songRes as SubsonicSong[]; songs = applySongFilters(songs, g, from, to, bpmLo, bpmHi, lossless); if (from !== null) albums = albums.filter(a => !a.year || a.year >= from); if (to !== null) albums = albums.filter(a => !a.year || a.year <= to); if (songs.length > 0) setGenreNote(true); } else if (from !== null || to !== null) { if (rt !== 'artists' && rt !== 'songs') { albums = await runNetworkAdvancedYearAlbums(opts, 100); } } const finalResults = { artists: rt === 'albums' || rt === 'songs' ? [] : artists, albums: rt === 'artists' || rt === 'songs' ? [] : albums, songs: rt === 'artists' || rt === 'albums' ? [] : songs, }; setResults(finalResults); if (q.trim()) { logLibrarySearch({ at: new Date().toISOString(), query: q, path: 'search3', surface: 'advanced_search', source: 'network', durationMs: Math.round(performance.now() - searchT0), indexEnabled, counts: { artists: finalResults.artists.length, albums: finalResults.albums.length, songs: finalResults.songs.length, }, }); } } catch { setResults({ artists: [], albums: [], songs: [] }); } setLoading(false); }; useEffect(() => { return () => { const path = window.location.pathname; const leaving = consumeAdvancedSearchLeavingForDetail(); const existingLeave = useAdvancedSearchSessionStore.getState().peekLeaveScrollSnapshot(); if (isAdvancedSearchLeaveTargetPath(path) || leaving || existingLeave) { const snapshot = existingLeave ?? readAdvancedSearchLeaveSnapshot(); useAdvancedSearchSessionStore.getState().setLeaveScrollSnapshot(snapshot); useAdvancedSearchSessionStore.getState().stashReturnSession({ ...sessionRef.current, scrollTop: snapshot.scrollTop, albumRowScrollLeft: snapshot.albumRowScrollLeft, artistRowScrollLeft: snapshot.artistRowScrollLeft, }); } else if (!isAdvancedSearchPath(path)) { useAdvancedSearchSessionStore.getState().clearReturnStash(); clearAdvancedSearchLeaveSnapshots(); } }; }, []); useEffect(() => { if (shouldRestoreAdvancedSearchSession(navigationType, location.state)) { restoredFromStashRef.current = true; const stash = useAdvancedSearchSessionStore.getState().peekReturnStash(); if (stash) { setQuery(stash.query); if (showTracksChrome) { const store = useLiveSearchScopeStore.getState(); store.setScope('tracks'); store.setQuery(stash.query); } setGenre(stash.genre); setYearFrom(stash.yearFrom); setYearTo(stash.yearTo); setBpmFrom(stash.bpmFrom); setBpmTo(stash.bpmTo); setMoodGroup(stash.moodGroup); setLosslessOnly(stash.losslessOnly); setResultType(stash.resultType); setStarredOnly(stash.starredOnly); setResults(stash.results); setHasSearched(stash.hasSearched); setActiveSearch(stash.activeSearch); setLocalMode(stash.localMode); setSongsServerOffset(stash.songsServerOffset); setSongsHasMore(stash.songsHasMore); setGenreNote(stash.genreNote); setBasicSearchMode(stash.basicSearchMode); } if (!leaveSnapshotRef.current) { useAdvancedSearchSessionStore.getState().clearReturnStash(); } return; } if (restoredFromStashRef.current) return; useAdvancedSearchSessionStore.getState().clearReturnStash(); }, [navigationType, location.state]); const tracksSearchRestoreSynced = !tracksSearchRestorePendingRef.current || tracksSearchQuery.trim() === (songBrowseInitialRestore?.query.trim() ?? ''); const leaveRestoreContentReady = showTracksChrome ? tracksChromeLayoutReady && tracksSearchRestoreSynced && ( (hadRestoreOnMountRef.current && songBrowseInitialRestore != null) || (songBrowse.hasSearched && !songBrowse.loading) ) : ((hadRestoreOnMountRef.current && results !== null) || (hasSearched && !loading)); useLayoutEffect(() => { if (!leaveRestoreContentReady || leaveRestoreUiFinishedRef.current) return; if (showTracksChrome) return; const target = scrollTopRestoreTargetRef.current; if (target <= 0) { finishLeaveRestoreUi(); return; } return restoreMainViewportScroll(target, finishLeaveRestoreUi); }, [leaveRestoreContentReady, finishLeaveRestoreUi, showTracksChrome]); useEffect(() => { if (!showTracksChrome || leaveRestoreUiFinishedRef.current) return; if (!leaveRestoreContentReady) return; const target = scrollTopRestoreTargetRef.current; if (target <= 0) { finishLeaveRestoreUi(); return; } if (songBrowse.songs.length === 0) return; return restoreMainViewportScroll(target, finishLeaveRestoreUi); }, [ showTracksChrome, leaveRestoreContentReady, finishLeaveRestoreUi, songBrowse.songs.length, tracksSearchRestoreSynced, ]); useEffect(() => { if (isLeaveRestorePending || !readAdvancedSearchRestore(location.state)) return; navigate(`${location.pathname}${location.search}${location.hash}`, { replace: true, state: null }); }, [isLeaveRestorePending, location.pathname, location.search, location.hash, location.state, navigate]); useEffect(() => { getGenres().then(data => setGenres(data.sort((a, b) => a.value.localeCompare(b.value))) ).catch(() => {}); }, []); useEffect(() => { if (hadRestoreOnMountRef.current) return; if (showTracksChrome) return; const q = qFromUrl.trim(); if (!q) { if (!showAdvancedPanel) { setResults(null); setHasSearched(false); } return; } if (showAdvancedPanel) { runSearch({ query: q, genre: '', yearFrom: '', yearTo: '', bpmFrom: '', bpmTo: '', moodGroup: '', losslessOnly: false, resultType: 'all', }); } else { void runBasicSearch(q); } }, [musicLibraryFilterVersion, qFromUrl, showAdvancedPanel, showTracksChrome, serverId, indexEnabled]); const loadMoreSongs = useCallback(async () => { if (loadingMoreSongs || !songsHasMore || !activeSearch) return; if (basicSearchMode) { const q = activeSearch.query.trim(); if (!q) return; setLoadingMoreSongs(true); try { const page = localMode && serverId ? await loadMoreLocalBrowseSongs(serverId, q, songsServerOffset, BASIC_SONGS_PAGE_SIZE) : await searchSongsPaged(q, BASIC_SONGS_PAGE_SIZE, songsServerOffset); setResults(prev => prev ? { ...prev, songs: [...prev.songs, ...page] } : prev); setSongsServerOffset(o => o + page.length); if (page.length < BASIC_SONGS_PAGE_SIZE) setSongsHasMore(false); } catch { setSongsHasMore(false); } finally { setLoadingMoreSongs(false); } return; } // Local mode pages every result type (genre/year too), not just free-text. if (localMode) { if (!serverId) return; setLoadingMoreSongs(true); try { const more = await loadMoreLocalSongs(serverId, activeSearch, songsServerOffset, SONGS_PAGE_SIZE); setResults(prev => (prev ? { ...prev, songs: [...prev.songs, ...more] } : prev)); setSongsServerOffset(o => o + more.length); if (more.length < SONGS_PAGE_SIZE) setSongsHasMore(false); } catch { setSongsHasMore(false); } finally { setLoadingMoreSongs(false); } return; } if (!activeSearch.query.trim()) return; setLoadingMoreSongs(true); try { const q = activeSearch.query.trim(); const g = activeSearch.genre; const from = activeSearch.yearFrom ? parseInt(activeSearch.yearFrom) : null; const to = activeSearch.yearTo ? parseInt(activeSearch.yearTo) : null; const bpmLo = activeSearch.bpmFrom ? parseInt(activeSearch.bpmFrom) : null; const bpmHi = activeSearch.bpmTo ? parseInt(activeSearch.bpmTo) : null; const page = await searchSongsPaged(q, SONGS_PAGE_SIZE, songsServerOffset); const filtered = applySongFilters( page, g, from, to, bpmLo, bpmHi, activeSearch.losslessOnly, ); setResults(prev => prev ? { ...prev, songs: [...prev.songs, ...filtered] } : prev); setSongsServerOffset(o => o + page.length); // No more pages when the server returned a non-full page (regardless of how many survived filtering). if (page.length < SONGS_PAGE_SIZE) setSongsHasMore(false); } catch { setSongsHasMore(false); } finally { setLoadingMoreSongs(false); } }, [loadingMoreSongs, songsHasMore, activeSearch, songsServerOffset, localMode, serverId, basicSearchMode]); const trackFilterActive = (MOOD_UI_ENABLED && !!moodGroup) || !!(bpmFrom || bpmTo); const bpmFilterDraftActive = !!(bpmFrom || bpmTo); const clearBpmFilter = () => { setBpmFrom(''); setBpmTo(''); }; const handleSubmit = (e?: React.FormEvent) => { e?.preventDefault(); const effectiveType = trackFilterActive ? 'songs' : resultType; runSearch({ query, genre, yearFrom, yearTo, bpmFrom, bpmTo, moodGroup, losslessOnly, resultType: effectiveType, }); }; const typeOptions: { id: ResultType; label: string }[] = [ { id: 'all', label: t('search.advancedAll') }, { id: 'artists', label: t('search.artists') }, { id: 'albums', label: t('search.albums') }, { id: 'songs', label: t('search.songs') }, ]; const genreSelectOptions = [ { value: '', label: t('search.advancedAllGenres') }, ...genres.map(g => ({ value: g.value, label: g.value })), ]; const moodSelectOptions = useMemo( () => [ { value: '', label: t('search.advancedAllMoods') }, ...MOOD_GROUP_IDS.map(id => ({ value: id, label: t(`search.moodGroups.${id}`), })), ], [t], ); return (
{showTracksChrome ? ( <> {!perfFlags.disableMainstageVirtualLists && ( { void songBrowse.loadMore(); }} /> )} ) : ( <>

{showAdvancedPanel ? ( <> {t('search.advanced')} ) : ( <> {query.trim() ? t('search.resultsFor', { query }) : t('search.title')} )}

{showAdvancedPanel && ( <> {/* ── Filter panel ──────────────────────────────────────── */}
{/* Row 1: Search term */}
{t('search.advancedSearchTerm')} setQuery(e.target.value)} placeholder={t('search.advancedSearchPlaceholder')} style={{ flex: 1 }} autoFocus={!skipSearchAutoFocusRef.current} />
{/* Row 2: Genre + Year */}
{t('search.advancedGenre')}
{t('search.advancedYear')} setYearFrom(e.target.value)} placeholder={t('search.advancedYearFrom')} style={{ width: 96 }} /> setYearTo(e.target.value)} placeholder={t('search.advancedYearTo')} style={{ width: 96 }} />
{/* Row 3: BPM (tag + measured enrichment) */} {indexEnabled && (
{t('search.advancedBpm')} setBpmFrom(e.target.value)} onBlur={e => { const from = parseBpmInput(e.target.value); const to = parseBpmInput(bpmTo); if (from != null && to != null && from > to) setBpmTo(''); }} placeholder={t('search.advancedYearFrom')} style={{ width: 96 }} /> setBpmTo(e.target.value)} onBlur={e => { const to = parseBpmInput(e.target.value); const from = parseBpmInput(bpmFrom); if (from != null && to != null && to < from) setBpmFrom(''); }} placeholder={t('search.advancedYearTo')} style={{ width: 96 }} /> {bpmFilterDraftActive && ( )}
)} {/* Lossless — suffix allowlist (FLAC, WAV, …) */} {indexEnabled && (
{t('search.advancedLossless')}
)} {/* Mood — hidden while oximedia mood analysis is disabled */} {indexEnabled && MOOD_UI_ENABLED && (
{t('search.advancedMoodGroup')}
{t('search.advancedMoodLocalNote')}
)} {/* Row 4: Result type + Search button */}
{!trackFilterActive && typeOptions.map(opt => ( ))}
)} {/* ── Results ───────────────────────────────────────────── */} {showAdvancedPanel && !hasSearched ? (
{t('search.advancedEmpty')}
) : loading ? (
) : total === 0 ? (
{basicSearchMode && query.trim() ? t('search.noResults', { query }) : t('search.advancedNoResults')}
) : (
{filteredResults && filteredResults.artists.length > 0 && (
0 ? artistRowScrollLeftRestoreRef.current : undefined } onScrollLeftSnapshot={(left) => { artistRowScrollLeftRef.current = left; }} />
)} {filteredResults && filteredResults.albums.length > 0 && (
0 ? albumRowScrollLeftRestoreRef.current : undefined } onScrollLeftSnapshot={(left) => { albumRowScrollLeftRef.current = left; }} />
)} {filteredResults && filteredResults.songs.length > 0 && (

{t('search.songs')} {genreNote && ( — {t('search.advancedGenreNote')} )}

)}
)} )}
{isLeaveRestorePending && (
)}
); }