mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-21 23:05:46 +00:00
c3d37546cf
* feat(covers): race sibling downscale vs fetch, search thumb priorities Run getCoverArt and client downscale in parallel when another size of the same cover is cached; first successful result wins and aborts the other path. Await both branches so inflight bookkeeping does not detach early. Extend the cover cache size roster so provisional siblings resolve for sizes used in the UI (e.g. 400/600/800, 48/96). CachedImage: fetchQueueBias for live/mobile search (artist thumbnails ahead of albums in fetch-slot ordering); configurable observeRootMargin with a wider default to prepare priority slightly before elements enter view. Mobile search adds round artist-thumb styling; add shared cover blob downscale helper. * perf(image-cache): batch sibling IDB reads and guard cover size registry Use one read transaction when probing IndexedDB for sibling cover keys. Extract COVER_ART_REGISTERED_SIZES and add Vitest coverage so every literal coverArtCacheKey(_, size) in src stays aligned with sibling invalidation. Honor AbortSignal during JPEG encode in downscaleCoverBlob.
287 lines
11 KiB
TypeScript
287 lines
11 KiB
TypeScript
import React, { useState, useEffect, useRef, useCallback, useMemo } from 'react';
|
|
import { createPortal } from 'react-dom';
|
|
import { useNavigate } from 'react-router-dom';
|
|
import { X, Search, Disc3, Users, Music, Music2, Clock, ChevronRight } from 'lucide-react';
|
|
import { search, SearchResults, buildCoverArtUrl, coverArtCacheKey, type SubsonicArtist } from '../api/subsonic';
|
|
import { usePlayerStore, songToTrack } from '../store/playerStore';
|
|
import { useAuthStore } from '../store/authStore';
|
|
import { useTranslation } from 'react-i18next';
|
|
import CachedImage, { FETCH_QUEUE_BIAS_SEARCH_ARTIST_OVER_ALBUM } from './CachedImage';
|
|
import { showToast } from '../utils/toast';
|
|
|
|
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: (q: string) => void, ms: number): (q: string) => void {
|
|
let timer: ReturnType<typeof setTimeout>;
|
|
return (q: string) => { clearTimeout(timer); timer = setTimeout(() => fn(q), ms); };
|
|
}
|
|
|
|
function MobileSearchArtistThumb({ artist }: { artist: Pick<SubsonicArtist, 'id' | 'coverArt'> }) {
|
|
const [failed, setFailed] = useState(false);
|
|
const coverId = artist.coverArt || artist.id;
|
|
const src = useMemo(() => buildCoverArtUrl(coverId, 80), [coverId]);
|
|
const ck = useMemo(() => coverArtCacheKey(coverId, 80), [coverId]);
|
|
useEffect(() => { setFailed(false); }, [coverId]);
|
|
if (failed) {
|
|
return (
|
|
<div className="mobile-search-avatar mobile-search-avatar--circle">
|
|
<Users size={20} />
|
|
</div>
|
|
);
|
|
}
|
|
return (
|
|
<CachedImage
|
|
className="mobile-search-thumb mobile-search-thumb--artist-round"
|
|
src={src}
|
|
cacheKey={ck}
|
|
alt=""
|
|
loading="eager"
|
|
fetchQueueBias={FETCH_QUEUE_BIAS_SEARCH_ARTIST_OVER_ALBUM}
|
|
onError={() => setFailed(true)}
|
|
/>
|
|
);
|
|
}
|
|
|
|
export default function MobileSearchOverlay({ onClose }: { onClose: () => void }) {
|
|
const { t } = useTranslation();
|
|
const navigate = useNavigate();
|
|
const enqueue = usePlayerStore(s => s.enqueue);
|
|
|
|
const [query, setQuery] = useState('');
|
|
const [results, setResults] = useState<SearchResults | null>(null);
|
|
const [loading, setLoading] = useState(false);
|
|
const [recentSearches, setRecentSearches] = useState<string[]>(loadRecent);
|
|
const inputRef = useRef<HTMLInputElement>(null);
|
|
const musicLibraryFilterVersion = useAuthStore(s => s.musicLibraryFilterVersion);
|
|
|
|
useEffect(() => { inputRef.current?.focus(); }, []);
|
|
|
|
useEffect(() => {
|
|
const prev = document.body.style.overflow;
|
|
document.body.style.overflow = 'hidden';
|
|
return () => { document.body.style.overflow = prev; };
|
|
}, []);
|
|
|
|
const doSearch = useCallback(
|
|
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(() => { doSearch(query); }, [query, doSearch]);
|
|
|
|
const commit = (q: string) => {
|
|
if (q.trim()) setRecentSearches(prev => saveRecent(q, prev));
|
|
};
|
|
|
|
const goTo = (path: string) => { commit(query); navigate(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 useRecent = (term: string) => {
|
|
setQuery(term);
|
|
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 = results && (results.artists.length || results.albums.length || results.songs.length);
|
|
const showEmpty = !query;
|
|
|
|
return createPortal(
|
|
<div className="mobile-search-overlay">
|
|
{/* ── Search bar ── */}
|
|
<div className="mobile-search-bar">
|
|
<div className="mobile-search-field">
|
|
{loading ? (
|
|
<div className="mobile-search-spinner" />
|
|
) : (
|
|
<Search size={16} className="mobile-search-icon" />
|
|
)}
|
|
<input
|
|
ref={inputRef}
|
|
className="mobile-search-input"
|
|
type="search"
|
|
placeholder={t('search.placeholder')}
|
|
value={query}
|
|
onChange={e => setQuery(e.target.value)}
|
|
autoComplete="off"
|
|
autoCorrect="off"
|
|
autoCapitalize="off"
|
|
/>
|
|
{query && (
|
|
<button
|
|
className="mobile-search-clear"
|
|
onClick={() => { setQuery(''); setResults(null); inputRef.current?.focus(); }}
|
|
aria-label={t('search.clearLabel')}
|
|
>
|
|
<X size={15} />
|
|
</button>
|
|
)}
|
|
</div>
|
|
<button className="mobile-search-cancel" onClick={onClose}>
|
|
{t('common.cancel')}
|
|
</button>
|
|
</div>
|
|
|
|
<div className="mobile-search-results">
|
|
{/* ── Empty state ── */}
|
|
{showEmpty && (
|
|
<div className="mobile-search-empty-state">
|
|
{recentSearches.length > 0 && (
|
|
<div className="mobile-search-section">
|
|
<div className="mobile-search-section-label">{t('search.recentSearches')}</div>
|
|
{recentSearches.map(term => (
|
|
<button key={term} className="mobile-search-item" onClick={() => useRecent(term)}>
|
|
<div className="mobile-search-avatar">
|
|
<Clock size={18} />
|
|
</div>
|
|
<div className="mobile-search-item-info" style={{ flex: 1 }}>
|
|
<span className="mobile-search-item-title">{term}</span>
|
|
</div>
|
|
<button
|
|
className="mobile-search-recent-remove"
|
|
onClick={e => removeRecent(term, e)}
|
|
aria-label={t('search.clearLabel')}
|
|
>
|
|
<X size={14} />
|
|
</button>
|
|
</button>
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
<div className="mobile-search-section">
|
|
<div className="mobile-search-section-label">{t('search.browse')}</div>
|
|
<div className="mobile-search-chips">
|
|
<button className="mobile-search-chip" onClick={() => goCategory('/albums')}>
|
|
<Music2 size={15} /> {t('search.albums')}
|
|
</button>
|
|
<button className="mobile-search-chip" onClick={() => goCategory('/artists')}>
|
|
<Users size={15} /> {t('search.artists')}
|
|
</button>
|
|
<button className="mobile-search-chip" onClick={() => goCategory('/genres')}>
|
|
<Music size={15} /> {t('search.genres')}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="mobile-search-hint">
|
|
<Search size={52} className="mobile-search-hint-icon" />
|
|
<span className="mobile-search-hint-text">{t('search.emptyHint')}</span>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* ── No results ── */}
|
|
{!loading && query && !hasResults && (
|
|
<div className="mobile-search-noresults">
|
|
{t('search.noResults', { query })}
|
|
</div>
|
|
)}
|
|
|
|
{/* ── Results ── */}
|
|
{hasResults && (
|
|
<>
|
|
{results!.artists.length > 0 && (
|
|
<div className="mobile-search-section">
|
|
<div className="mobile-search-section-label">{t('search.artists')}</div>
|
|
{results!.artists.map(a => (
|
|
<button key={a.id} className="mobile-search-item" onClick={() => goTo(`/artist/${a.id}`)}>
|
|
<MobileSearchArtistThumb artist={a} />
|
|
<div className="mobile-search-item-info">
|
|
<span className="mobile-search-item-title">{a.name}</span>
|
|
<span className="mobile-search-item-sub">{t('search.artists')}</span>
|
|
</div>
|
|
<ChevronRight size={16} className="mobile-search-item-chevron" />
|
|
</button>
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
{results!.albums.length > 0 && (
|
|
<div className="mobile-search-section">
|
|
<div className="mobile-search-section-label">{t('search.albums')}</div>
|
|
{results!.albums.map(a => (
|
|
<button key={a.id} className="mobile-search-item" onClick={() => goTo(`/album/${a.id}`)}>
|
|
{a.coverArt ? (
|
|
<CachedImage
|
|
className="mobile-search-thumb"
|
|
src={buildCoverArtUrl(a.coverArt, 80)}
|
|
cacheKey={coverArtCacheKey(a.coverArt, 80)}
|
|
alt=""
|
|
/>
|
|
) : (
|
|
<div className="mobile-search-avatar">
|
|
<Disc3 size={20} />
|
|
</div>
|
|
)}
|
|
<div className="mobile-search-item-info">
|
|
<span className="mobile-search-item-title">{a.name}</span>
|
|
<span className="mobile-search-item-sub">{a.artist}</span>
|
|
</div>
|
|
<ChevronRight size={16} className="mobile-search-item-chevron" />
|
|
</button>
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
{results!.songs.length > 0 && (
|
|
<div className="mobile-search-section">
|
|
<div className="mobile-search-section-label">{t('search.songs')}</div>
|
|
{results!.songs.map(s => (
|
|
<button key={s.id} className="mobile-search-item" onClick={() => enqueueSong(s)}>
|
|
{s.coverArt ? (
|
|
<CachedImage
|
|
className="mobile-search-thumb"
|
|
src={buildCoverArtUrl(s.coverArt, 80)}
|
|
cacheKey={coverArtCacheKey(s.coverArt, 80)}
|
|
alt=""
|
|
/>
|
|
) : (
|
|
<div className="mobile-search-avatar">
|
|
<Music size={20} />
|
|
</div>
|
|
)}
|
|
<div className="mobile-search-item-info">
|
|
<span className="mobile-search-item-title">{s.title}</span>
|
|
<span className="mobile-search-item-sub">{s.artist}{s.album ? ` · ${s.album}` : ''}</span>
|
|
</div>
|
|
</button>
|
|
))}
|
|
</div>
|
|
)}
|
|
</>
|
|
)}
|
|
</div>
|
|
</div>,
|
|
document.body
|
|
);
|
|
}
|