Files
Psychotoxical-psysonic/src/components/LiveSearch.tsx
T
Psychotoxical 15dc970f53 fix(audio): restore msg binding in DecodeError arm, use SlidersVertical for EQ icon
- Revert _msg → msg in DecodeError match arm; the variable is used in the
  debug_assertions eprintln! block so the underscore prefix caused a compile error
- Replace SlidersHorizontal with SlidersVertical (lucide-react) in PlayerBar,
  LiveSearch, and AdvancedSearch so the EQ/filter icon shows vertical bars

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-10 00:28:12 +02:00

212 lines
8.8 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<typeof setTimeout>;
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<SearchResults | null>(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<HTMLDivElement>(null);
const dropdownRef = useRef<HTMLDivElement>(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<HTMLInputElement>) => {
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<HTMLElement>('.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<HTMLElement>('.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 (
<div className="live-search" ref={ref} role="search">
<div className="live-search-input-wrap">
{loading ? (
<span className="live-search-icon animate-spin" style={{ opacity: 0.6 }}>
<div style={{ width: 16, height: 16, border: '2px solid var(--border)', borderTopColor: 'var(--accent)', borderRadius: '50%' }} />
</span>
) : (
<Search size={16} className="live-search-icon" />
)}
<input
id="live-search-input"
className="input live-search-field"
type="search"
placeholder={t('search.placeholder')}
value={query}
onChange={e => setQuery(e.target.value)}
onFocus={() => results && setOpen(true)}
onKeyDown={handleKeyDown}
aria-autocomplete="list"
aria-controls="search-results"
aria-expanded={open}
autoComplete="off"
/>
{query && (
<button className="live-search-clear" onClick={() => { setQuery(''); setResults(null); setOpen(false); }} aria-label={t('search.clearLabel')}>
×
</button>
)}
<button
className="live-search-adv-btn"
type="button"
onClick={() => navigate(query.trim() ? `/search/advanced?q=${encodeURIComponent(query.trim())}` : '/search/advanced')}
data-tooltip={t('search.advanced')}
data-tooltip-pos="bottom"
aria-label={t('search.advanced')}
>
<SlidersVertical size={14} />
</button>
</div>
{open && (
<div className="live-search-dropdown" id="search-results" role="listbox" ref={dropdownRef}>
{!hasResults && !loading && (
<div className="search-empty">{t('search.noResults', { query })}</div>
)}
{(() => {
let idx = 0;
return <>
{results?.artists.length ? (
<div className="search-section">
<div className="search-section-label"><Users size={12} /> {t('search.artists')}</div>
{results.artists.map(a => {
const i = idx++;
return (
<button key={a.id} className={`search-result-item${activeIndex === i ? ' active' : ''}`}
onClick={() => { navigate(`/artist/${a.id}`); setOpen(false); setQuery(''); }}
role="option" aria-selected={activeIndex === i}>
<div className="search-result-icon"><Users size={14} /></div>
<span>{a.name}</span>
</button>
);
})}
</div>
) : null}
{results?.albums.length ? (
<div className="search-section">
<div className="search-section-label"><Disc3 size={12} /> {t('search.albums')}</div>
{results.albums.map(a => {
const i = idx++;
return (
<button key={a.id} className={`search-result-item${activeIndex === i ? ' active' : ''}`}
onClick={() => { navigate(`/album/${a.id}`); setOpen(false); setQuery(''); }}
role="option" aria-selected={activeIndex === i}>
{a.coverArt ? (
<img className="search-result-thumb" src={buildCoverArtUrl(a.coverArt, 40)} alt="" loading="lazy" />
) : (
<div className="search-result-icon"><Disc3 size={14} /></div>
)}
<div>
<div className="search-result-name">{a.name}</div>
<div className="search-result-sub">{a.artist}</div>
</div>
</button>
);
})}
</div>
) : null}
{results?.songs.length ? (
<div className="search-section">
<div className="search-section-label"><Music size={12} /> {t('search.songs')}</div>
{results.songs.map(s => {
const i = idx++;
return (
<button key={s.id} className={`search-result-item${activeIndex === i ? ' active' : ''}`}
onClick={() => {
playTrack(songToTrack(s));
setOpen(false); setQuery('');
}}
role="option" aria-selected={activeIndex === i}>
<div className="search-result-icon"><Music size={14} /></div>
<div>
<div className="search-result-name">{s.title}</div>
<div className="search-result-sub">{s.artist} · {s.album}</div>
</div>
</button>
);
})}
</div>
) : null}
</>;
})()}
</div>
)}
</div>
);
}