Files
Psychotoxical-psysonic/src/components/LiveSearch.tsx
T
Frank Stellmacher 9da1d643f7 perf(search): use CachedImage for result thumbs to stop re-render download storms (#245)
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>
2026-04-21 21:37:47 +02:00

218 lines
9.0 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, 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>
);
}