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 => (