mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-22 07:15:47 +00:00
9da1d643f7
The sidebar LiveSearch and mobile search overlay rendered cover thumbs via
raw <img src={buildCoverArtUrl(...)}> instead of going through the
IndexedDB-backed CachedImage path used everywhere else in the app.
buildCoverArtUrl() mints a fresh URL on every call (random salt + a new
MD5 token per Subsonic spec), so the browser cache was permanently
defeated for these thumbs — every re-render of the dropdown produced
new URLs and the browser dispatched HTTP requests for every "new"
image.
While typing in the search field this multiplied: each keystroke
triggered a re-render with fresh URLs for every visible cover, and
after the 300 ms debounce a fresh search() result triggered another
wave. Five visible album thumbs × five keystrokes ≈ 50 redundant cover
fetches in 1.5 s, manifesting as periodic typing delays.
Switch all three call sites to CachedImage with the stable
coverArtCacheKey() — same pattern CLAUDE.md explicitly recommends:
buildCoverArtUrl() generates a new ephemeral URL every call —
browser cache is useless. Use coverArtCacheKey(id, size) +
CachedImage for <img> tags.
Confirmed locally: typing in the search field is noticeably smoother.
Co-authored-by: Psychotoxical <dev@psysonic.app>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
218 lines
9.0 KiB
TypeScript
218 lines
9.0 KiB
TypeScript
import React, { useState, useEffect, useRef, useCallback } from 'react';
|
||
import { useNavigate } from 'react-router-dom';
|
||
import { Search, Disc3, Users, Music, SlidersVertical, TextSearch } from 'lucide-react';
|
||
import { search, SearchResults, buildCoverArtUrl, coverArtCacheKey } from '../api/subsonic';
|
||
import { usePlayerStore, songToTrack } from '../store/playerStore';
|
||
import { useAuthStore } from '../store/authStore';
|
||
import { useTranslation } from 'react-i18next';
|
||
import CachedImage from './CachedImage';
|
||
|
||
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')}
|
||
>
|
||
<TextSearch 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 ? (
|
||
<CachedImage
|
||
className="search-result-thumb"
|
||
src={buildCoverArtUrl(a.coverArt, 40)}
|
||
cacheKey={coverArtCacheKey(a.coverArt, 40)}
|
||
alt=""
|
||
/>
|
||
) : (
|
||
<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>
|
||
);
|
||
}
|