From c3d37546cf861cc6d8dae9e91c5a4eac5affe11d Mon Sep 17 00:00:00 2001 From: cucadmuh <49571317+cucadmuh@users.noreply.github.com> Date: Wed, 6 May 2026 01:26:30 +0300 Subject: [PATCH] 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. --- CHANGELOG.md | 17 ++ src/components/CachedImage.tsx | 56 +++++- src/components/LiveSearch.tsx | 30 ++- src/components/MobileSearchOverlay.tsx | 36 +++- src/styles/components.css | 4 + src/utils/coverArtRegisteredSizes.test.ts | 40 ++++ src/utils/coverArtRegisteredSizes.ts | 9 + src/utils/coverBlobDownscale.ts | 62 +++++++ src/utils/imageCache.ts | 216 +++++++++++++++++++++- 9 files changed, 449 insertions(+), 21 deletions(-) create mode 100644 src/utils/coverArtRegisteredSizes.test.ts create mode 100644 src/utils/coverArtRegisteredSizes.ts create mode 100644 src/utils/coverBlobDownscale.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 48a8f6ee..c1b44979 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -41,6 +41,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 * Filter state is ephemeral per page (not persisted) so users don't come back to a half-empty library and wonder where their content went. * Reads star state live from in-memory overrides — toggling a favourite from a context menu updates the visible list immediately, no refetch. +### Search — artist photos in live and mobile results + +**By [@cucadmuh](https://github.com/cucadmuh), PR [#470](https://github.com/Psychotoxical/psysonic/pull/470)** + +* **Live search** and the **mobile search overlay** now load **artist images** for rows in the **Artists** section via the same **`getCoverArt` / image-cache path** as album art (**`coverArt`** when present, otherwise the **artist id** where the server supports it), with a **fallback icon** when art is missing or fails. +* **Mobile** artist hits use a **round** thumbnail next to **square** album art so the two result types read clearly at a glance. + ## Changed ### Dependencies — npm / Cargo refresh and rodio 0.22 @@ -66,6 +73,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 * **Virtualised lists:** **Albums**, **Artists** (list mode), and the **Tracks** virtual song browser **derive** **TanStack Virtual `overscan`** from the **measured scroll viewport** (~ **one screen** of extra rows above and below) instead of a tiny fixed cushion. * **Library & chrome:** assorted **scroll / layout** improvements on **Artist detail**, **Playlists**, **Most played**; smaller touch-ups to **Mini player**, **Live search**, **Album header**, and **dynamic colour** extraction used by player / album surfaces. +### Covers / image cache — parallel fetch + downscale, registry guard, search slot hints + +**By [@cucadmuh](https://github.com/cucadmuh), PR [#470](https://github.com/Psychotoxical/psysonic/pull/470)** + +* When a **pixel size** misses **disk** but **another size** of the same **`cover:id` is already cached**, **remote `getCoverArt`** and **client JPEG downscale** run in **parallel**; the **first good blob wins** and the other side **aborts** (network + downscale **signals**). +* **Sibling key reads** use **one IndexedDB readonly transaction** instead of many separate transactions. +* **`COVER_ART_REGISTERED_SIZES`** centralises known **`getCoverArt` widths** for **invalidation** and sibling lookup; **`coverArtRegisteredSizes.test.ts`** (**Vitest**) keeps **literal** `coverArtCacheKey(_, n)` call sites in **`src`** in sync with that list. +* **`downscaleCoverBlob`** respects **AbortSignal** through **`canvas.toBlob`**. +* **`CachedImage`:** **`fetchQueueBias`** gives **artist** search thumbs **higher network-slot priority** than **album** thumbs when the pool is saturated; **`observeRootMargin`** defaults **wider** so **priority** updates **earlier** ahead of the scroll viewport. + ## Fixed ### Hot cache, HTTP streaming replay, and queue source indicator diff --git a/src/components/CachedImage.tsx b/src/components/CachedImage.tsx index 674834db..cfcc557b 100644 --- a/src/components/CachedImage.tsx +++ b/src/components/CachedImage.tsx @@ -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 { 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(undefined); const imgRef = useRef(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 with no src and opacity stuck at 0. diff --git a/src/components/LiveSearch.tsx b/src/components/LiveSearch.tsx index c3044cf1..a864e955 100644 --- a/src/components/LiveSearch.tsx +++ b/src/components/LiveSearch.tsx @@ -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 ; } +function LiveSearchArtistThumb({ artist }: { artist: Pick }) { + 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
; + return ( + 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}> -
- {a.name} + +
+
{a.name}
+
); })} diff --git a/src/components/MobileSearchOverlay.tsx b/src/components/MobileSearchOverlay.tsx index 0022042e..90731a91 100644 --- a/src/components/MobileSearchOverlay.tsx +++ b/src/components/MobileSearchOverlay.tsx @@ -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 }) { + 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 ( +
+ +
+ ); + } + return ( + 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 }
{t('search.artists')}
{results!.artists.map(a => (