Feat/search improvements (#470)

* 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.
This commit is contained in:
cucadmuh
2026-05-06 01:26:30 +03:00
committed by GitHub
parent 072bef473f
commit c3d37546cf
9 changed files with 449 additions and 21 deletions
+30 -6
View File
@@ -1,12 +1,12 @@
import React, { useState, useEffect, useRef, useCallback } from 'react';
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 } from '../api/subsonic';
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 from './CachedImage';
import CachedImage, { FETCH_QUEUE_BIAS_SEARCH_ARTIST_OVER_ALBUM } from './CachedImage';
import { showToast } from '../utils/toast';
const STORAGE_KEY = 'psysonic_recent_searches';
@@ -27,6 +27,32 @@ function debounce(fn: (q: string) => void, ms: number): (q: string) => void {
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();
@@ -188,9 +214,7 @@ export default function MobileSearchOverlay({ onClose }: { onClose: () => void }
<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}`)}>
<div className="mobile-search-avatar mobile-search-avatar--circle">
<Users size={20} />
</div>
<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>