mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-22 07:15:47 +00:00
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:
@@ -1,12 +1,29 @@
|
||||
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { APP_MAIN_SCROLL_VIEWPORT_ID } from '../constants/appScroll';
|
||||
import { acquireUrl, getCachedBlob, releaseUrl } from '../utils/imageCache';
|
||||
import { acquireUrl, getCachedBlob, releaseUrl, subscribeCoverUpgraded } from '../utils/imageCache';
|
||||
|
||||
interface CachedImageProps extends React.ImgHTMLAttributes<HTMLImageElement> {
|
||||
src: string;
|
||||
cacheKey: string;
|
||||
/**
|
||||
* Added to the viewport-based score when waiting for a `getCachedBlob` **network** slot.
|
||||
* Use to order tiers (e.g. search: artist thumbs before album thumbs) without changing layout.
|
||||
*/
|
||||
fetchQueueBias?: number;
|
||||
/**
|
||||
* How far beyond the app scroll viewport `IntersectionObserver` expands the root.
|
||||
* Larger = priority / slot ordering updates while the row is still off-screen → less
|
||||
* empty flash when it hits the viewport. CSS margin syntax (`440px`, `10% 0`, …).
|
||||
*/
|
||||
observeRootMargin?: string;
|
||||
}
|
||||
|
||||
/** Search UI: load artist avatars before album covers when many requests compete. */
|
||||
export const FETCH_QUEUE_BIAS_SEARCH_ARTIST_OVER_ALBUM = 1_000_000_000;
|
||||
|
||||
/** Default IO lead — slightly before visible to reduce scroll-in jitter (tune per `CachedImage`). */
|
||||
export const DEFAULT_CACHED_IMAGE_PREPARE_MARGIN = '440px';
|
||||
|
||||
/**
|
||||
* Returns a shared, refcounted object URL for a cached image. Multiple
|
||||
* consumers of the same cacheKey see the exact same URL string, so the
|
||||
@@ -89,10 +106,36 @@ export function useCachedUrl(
|
||||
};
|
||||
}, [cacheKey]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!fetchUrl || !fallbackToFetch) return;
|
||||
let cancelled = false;
|
||||
const unsub = subscribeCoverUpgraded(cacheKey, () => {
|
||||
if (cancelled) return;
|
||||
const refreshed = acquireUrl(cacheKey);
|
||||
if (refreshed) {
|
||||
ownedKeyRef.current = cacheKey;
|
||||
setResolved(refreshed);
|
||||
}
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
unsub();
|
||||
};
|
||||
}, [cacheKey, fetchUrl, fallbackToFetch]);
|
||||
|
||||
return fallbackToFetch ? (resolved || fetchUrl) : resolved;
|
||||
}
|
||||
|
||||
export default function CachedImage({ src, cacheKey, style, onLoad, onError, ...props }: CachedImageProps) {
|
||||
export default function CachedImage({
|
||||
src,
|
||||
cacheKey,
|
||||
fetchQueueBias = 0,
|
||||
observeRootMargin = DEFAULT_CACHED_IMAGE_PREPARE_MARGIN,
|
||||
style,
|
||||
onLoad,
|
||||
onError,
|
||||
...props
|
||||
}: CachedImageProps) {
|
||||
const [fallbackSrc, setFallbackSrc] = useState<string | undefined>(undefined);
|
||||
const imgRef = useRef<HTMLImageElement>(null);
|
||||
/**
|
||||
@@ -101,7 +144,10 @@ export default function CachedImage({ src, cacheKey, style, onLoad, onError, ...
|
||||
* (custom scroll roots, content-visibility, horizontal rails) and led to blank covers.
|
||||
*/
|
||||
const priorityRef = useRef(0);
|
||||
const getViewportImagePriority = useCallback(() => priorityRef.current, []);
|
||||
const getViewportImagePriority = useCallback(
|
||||
() => fetchQueueBias + priorityRef.current,
|
||||
[fetchQueueBias],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const el = imgRef.current;
|
||||
@@ -128,13 +174,13 @@ export default function CachedImage({ src, cacheKey, style, onLoad, onError, ...
|
||||
entries => { for (const e of entries) updateFromEntry(e); },
|
||||
{
|
||||
root: root ?? undefined,
|
||||
rootMargin: '300px',
|
||||
rootMargin: observeRootMargin,
|
||||
threshold: [0, 0.02, 0.1, 0.25, 0.5, 0.75, 1],
|
||||
},
|
||||
);
|
||||
observer.observe(el);
|
||||
return () => observer.disconnect();
|
||||
}, []);
|
||||
}, [observeRootMargin]);
|
||||
|
||||
// Same as Hero/PlayerBar: show the salted fetch URL while IndexedDB/network resolves,
|
||||
// then swap to the shared blob URL — avoids an <img> with no src and opacity stuck at 0.
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user