import { search } from '@/api/subsonicSearch'; import type { SearchResults, SubsonicArtist } from '@/api/subsonicTypes'; import { songToTrack } from '@/utils/playback/songToTrack'; import { useLiveSearchScopeStore } from '@/store/liveSearchScopeStore'; import React, { useState, useEffect, useRef, useCallback, useMemo } from 'react'; import { createPortal } from 'react-dom'; import { useLocation, useNavigate } from 'react-router-dom'; import { navigatePathWithAlbumReturnTo } from '@/utils/navigation/albumDetailNavigation'; import { X, Search, Disc3, Users, Music, Music2, Clock, ChevronRight } from 'lucide-react'; import { usePlayerStore } from '@/store/playerStore'; import { useAuthStore } from '@/store/authStore'; import { useTranslation } from 'react-i18next'; import { FETCH_QUEUE_BIAS_SEARCH_ARTIST_OVER_ALBUM } from '@/ui/CachedImage'; import { AlbumCoverArtImage } from '@/cover/AlbumCoverArtImage'; import { ArtistCoverArtImage } from '@/cover/ArtistCoverArtImage'; import { CoverArtImage } from '@/cover/CoverArtImage'; import { albumCoverRefForSong } from '@/cover/ref'; import { showToast } from '@/utils/ui/toast'; import { albumArtistDisplayName } from '@/features/album'; import { useShareSearch } from '@/features/search/hooks/useShareSearch'; import ShareSearchResults from '@/features/search/components/ShareSearchResults'; import { LiveSearchScopeBadge, LiveSearchScopeGhostBadge, } from '@/features/search/components/liveSearchScopeUi'; import { createLiveSearchScopeBackspaceState, handleLiveSearchScopeBackspace, handleLiveSearchScopeUndo, isLiveSearchDropdownBlocked, liveSearchScopePlaceholderKey, noteLiveSearchScopeQueryInput, resetLiveSearchScopeBackspaceState, resolveLiveSearchScopeGhost, } from '@/features/search/components/liveSearchScope'; const STORAGE_KEY = 'psysonic_recent_searches'; const MAX_RECENT = 6; function loadRecent(): string[] { try { return JSON.parse(localStorage.getItem(STORAGE_KEY) || '[]'); } catch { return []; } } function saveRecent(q: string, prev: string[]): string[] { const updated = [q.trim(), ...prev.filter(s => s !== q.trim())].slice(0, MAX_RECENT); localStorage.setItem(STORAGE_KEY, JSON.stringify(updated)); return updated; } function debounce( fn: (...args: A) => void, ms: number, ): (...args: A) => void { let timer: ReturnType; return (...args: A) => { clearTimeout(timer); timer = setTimeout(() => fn(...args), ms); }; } /** Mobile search row thumb — larger than desktop live search (32px). */ const MOBILE_SEARCH_THUMB_CSS_PX = 80; function MobileSearchSongThumb({ song, }: { song: Pick; }) { const coverRef = useMemo( () => (song.albumId?.trim() ? albumCoverRefForSong(song) : undefined), // Keyed on song's identity fields; depending on the `song` object would // recompute the ref on every render. // eslint-disable-next-line react-hooks/exhaustive-deps [song.id, song.albumId, song.coverArt, song.discNumber], ); if (!coverRef) return null; return ( ); } function MobileSearchArtistThumb({ artist }: { artist: Pick }) { const [failed, setFailed] = useState(false); // 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 useEffect(() => { setFailed(false); }, [artist.id, artist.coverArt]); if (failed) { return (
); } return ( setFailed(true)} /> ); } export default function MobileSearchOverlay({ onClose }: { onClose: () => void }) { const { t } = useTranslation(); const navigate = useNavigate(); const location = useLocation(); const enqueue = usePlayerStore(s => s.enqueue); 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 ghostScope = resolveLiveSearchScopeGhost(location.pathname, scope); const [results, setResults] = useState(null); const [loading, setLoading] = useState(false); const [recentSearches, setRecentSearches] = useState(loadRecent); const inputRef = useRef(null); const musicLibraryFilterVersion = useAuthStore(s => s.musicLibraryFilterVersion); const share = useShareSearch(query, onClose); useEffect(() => { inputRef.current?.focus(); }, []); useEffect(() => { resetLiveSearchScopeBackspaceState(scopeBackspaceRef.current); }, [scope]); useEffect(() => { noteLiveSearchScopeQueryInput(scopeBackspaceRef.current, query); }, [query]); useEffect(() => { const prev = document.body.style.overflow; document.body.style.overflow = 'hidden'; return () => { document.body.style.overflow = prev; }; }, []); // doSearch wraps a debounce() result, so the useCallback argument is not an // inline function and its deps can't be statically analysed. It is recreated // only on musicLibraryFilterVersion (search() reads the active filter state). // eslint-disable-next-line react-hooks/exhaustive-deps const doSearch = useCallback( // React Compiler rule: memoization shape is intentional here. // eslint-disable-next-line react-hooks/use-memo debounce(async (q: string) => { if (!q.trim()) { setResults(null); setLoading(false); return; } setLoading(true); try { setResults(await search(q)); } finally { setLoading(false); } }, 300), [musicLibraryFilterVersion], ); useEffect(() => { if (isLiveSearchDropdownBlocked(scope)) { // React Compiler set-state-in-effect rule: state set from an async result resolved in this effect. // eslint-disable-next-line react-hooks/set-state-in-effect setResults(null); setLoading(false); return; } if (share.shareMatch) { setResults(null); setLoading(false); return; } doSearch(query); }, [query, scope, doSearch, share.shareMatch]); const commit = (q: string) => { if (q.trim()) setRecentSearches(prev => saveRecent(q, prev)); }; const goTo = (path: string) => { commit(query); navigatePathWithAlbumReturnTo(navigate, location, path); onClose(); }; const goCategory = (path: string) => { navigate(path); onClose(); }; const enqueueSong = (song: SearchResults['songs'][number]) => { commit(query); const track = songToTrack(song); enqueue([track]); showToast(t('search.addedToQueueToast', { title: track.title }), 2200, 'info'); onClose(); }; const applyRecentSearch = (term: string) => { setQuery(term, { recordUndo: true }); inputRef.current?.focus(); }; const removeRecent = (term: string, e: React.MouseEvent) => { e.stopPropagation(); setRecentSearches(prev => { const updated = prev.filter(s => s !== term); localStorage.setItem(STORAGE_KEY, JSON.stringify(updated)); return updated; }); }; const hasResults = !isLiveSearchDropdownBlocked(scope) && ( !!share.shareMatch || (results && (results.artists.length || results.albums.length || results.songs.length)) ); const showEmpty = !query && !scope; return createPortal(
{/* ── Search bar ── */}
{loading ? (
) : ( )} {scope && ( )} {ghostScope && ( )} setQuery(e.target.value, { recordUndo: true })} onKeyDown={(e) => { if (handleLiveSearchScopeUndo(e, undoLiveSearch)) return; if (handleLiveSearchScopeBackspace(e, query, scope, clearScope, scopeBackspaceRef.current)) return; }} autoComplete="off" autoCorrect="off" autoCapitalize="off" /> {query && ( )}
{/* ── Empty state ── */} {showEmpty && (
{recentSearches.length > 0 && (
{t('search.recentSearches')}
{recentSearches.map(term => ( ))}
)}
{t('search.browse')}
{t('search.emptyHint')}
)} {/* ── No results ── */} {!loading && query && !hasResults && !isLiveSearchDropdownBlocked(scope) && (
{t('search.noResults', { query: query.trim() })}
)} {share.shareMatch && ( void share.enqueueShareMatch()} onOpenAlbum={share.openShareAlbum} onOpenArtist={share.openShareArtist} onOpenComposer={share.openShareComposer} shareTrackSong={share.shareTrackSong} shareTrackResolving={share.shareTrackResolving} shareTrackUnavailable={share.shareTrackUnavailable} shareAlbum={share.shareAlbum} shareAlbumResolving={share.shareAlbumResolving} shareAlbumUnavailable={share.shareAlbumUnavailable} shareArtist={share.shareArtist} shareArtistResolving={share.shareArtistResolving} shareArtistUnavailable={share.shareArtistUnavailable} shareComposer={share.shareComposer} shareComposerResolving={share.shareComposerResolving} shareComposerUnavailable={share.shareComposerUnavailable} /> )} {/* ── Results ── */} {hasResults && !share.shareMatch && ( <> {results!.artists.length > 0 && (
{t('search.artists')}
{results!.artists.map(a => ( ))}
)} {results!.albums.length > 0 && (
{t('search.albums')}
{results!.albums.map(a => ( ))}
)} {results!.songs.length > 0 && (
{t('search.songs')}
{results!.songs.map(s => ( ))}
)} )}
, document.body ); }