import React, { useState, useEffect, useRef, useCallback } from 'react'; import { useNavigate } from 'react-router-dom'; import { Search, Disc3, Users, Music, SlidersVertical } from 'lucide-react'; import { search, SearchResults, buildCoverArtUrl } from '../api/subsonic'; import { usePlayerStore, songToTrack } from '../store/playerStore'; import { useAuthStore } from '../store/authStore'; import { useTranslation } from 'react-i18next'; function debounce(fn: (q: string) => void, ms: number): (q: string) => void { let timer: ReturnType; return (q: string) => { clearTimeout(timer); timer = setTimeout(() => fn(q), ms); }; } export default function LiveSearch() { const { t } = useTranslation(); const [query, setQuery] = useState(''); const [results, setResults] = useState(null); const [open, setOpen] = useState(false); const [loading, setLoading] = useState(false); const [activeIndex, setActiveIndex] = useState(-1); const navigate = useNavigate(); const playTrack = usePlayerStore(state => state.playTrack); const ref = useRef(null); const dropdownRef = useRef(null); const musicLibraryFilterVersion = useAuthStore(s => s.musicLibraryFilterVersion); const doSearch = useCallback( debounce(async (q: string) => { if (!q.trim()) { setResults(null); setOpen(false); return; } setLoading(true); try { const r = await search(q); setResults(r); setOpen(true); } finally { setLoading(false); } }, 300), [musicLibraryFilterVersion] ); useEffect(() => { doSearch(query); setActiveIndex(-1); }, [query, doSearch]); // Close on click outside useEffect(() => { const handler = (e: MouseEvent) => { if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false); }; document.addEventListener('mousedown', handler); return () => document.removeEventListener('mousedown', handler); }, []); const hasResults = results && (results.artists.length || results.albums.length || results.songs.length); // Flat list of all navigable items for keyboard nav const flatItems = results ? [ ...(results.artists.map(a => ({ id: a.id, action: () => { navigate(`/artist/${a.id}`); setOpen(false); setQuery(''); } }))), ...(results.albums.map(a => ({ id: a.id, action: () => { navigate(`/album/${a.id}`); setOpen(false); setQuery(''); } }))), ...(results.songs.map(s => ({ id: s.id, action: () => { playTrack(songToTrack(s)); setOpen(false); setQuery(''); }}))), ] : []; const handleKeyDown = (e: React.KeyboardEvent) => { if (!open || !flatItems.length) { if (e.key === 'Enter' && query.trim()) { setOpen(false); navigate(`/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()) { setOpen(false); navigate(`/search?q=${encodeURIComponent(query.trim())}`); } } else if (e.key === 'Escape') { setOpen(false); setActiveIndex(-1); } }; return (
{loading ? (
) : ( )} setQuery(e.target.value)} onFocus={() => results && setOpen(true)} onKeyDown={handleKeyDown} aria-autocomplete="list" aria-controls="search-results" aria-expanded={open} autoComplete="off" /> {query && ( )}
{open && (
{!hasResults && !loading && (
{t('search.noResults', { query })}
)} {(() => { let idx = 0; return <> {results?.artists.length ? (
{t('search.artists')}
{results.artists.map(a => { const i = idx++; return ( ); })}
) : null} {results?.albums.length ? (
{t('search.albums')}
{results.albums.map(a => { const i = idx++; return ( ); })}
) : null} {results?.songs.length ? (
{t('search.songs')}
{results.songs.map(s => { const i = idx++; return ( ); })}
) : null} ; })()}
)}
); }