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
+26 -4
View File
@@ -1,11 +1,11 @@
import React, { useState, useEffect, useRef, useCallback, useMemo } from 'react';
import { useNavigate } from 'react-router-dom';
import { Search, Disc3, Users, Music, TextSearch } 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';
function debounce(fn: (q: string) => void, ms: number): (q: string) => void {
@@ -22,6 +22,26 @@ function LiveSearchAlbumThumb({ coverArt }: { coverArt: string }) {
return <CachedImage className="search-result-thumb" src={src} cacheKey={cacheKey} alt="" />;
}
function LiveSearchArtistThumb({ artist }: { artist: Pick<SubsonicArtist, 'id' | 'coverArt'> }) {
const [failed, setFailed] = useState(false);
const coverId = artist.coverArt || artist.id;
const src = useMemo(() => buildCoverArtUrl(coverId, 40), [coverId]);
const cacheKey = useMemo(() => coverArtCacheKey(coverId, 40), [coverId]);
useEffect(() => { setFailed(false); }, [coverId]);
if (failed) return <div className="search-result-icon"><Users size={14} /></div>;
return (
<CachedImage
className="search-result-thumb"
src={src}
cacheKey={cacheKey}
alt=""
loading="eager"
fetchQueueBias={FETCH_QUEUE_BIAS_SEARCH_ARTIST_OVER_ALBUM}
onError={() => setFailed(true)}
/>
);
}
export default function LiveSearch() {
const { t } = useTranslation();
const [query, setQuery] = useState('');
@@ -306,8 +326,10 @@ export default function LiveSearch() {
openContextMenu(e.clientX, e.clientY, a, 'artist');
}}
role="option" aria-selected={activeIndex === i}>
<div className="search-result-icon"><Users size={14} /></div>
<span>{a.name}</span>
<LiveSearchArtistThumb artist={a} />
<div>
<div className="search-result-name">{a.name}</div>
</div>
</button>
);
})}