From 9d30285ff1d0d59d546711c086bad2a9b6f63f42 Mon Sep 17 00:00:00 2001 From: cucadmuh <49571317+cucadmuh@users.noreply.github.com> Date: Wed, 6 May 2026 00:15:58 +0300 Subject: [PATCH] Perf/UI cover cache mainstage (#468) * Enhance CachedImage and ArtistDetail components with improved image caching and priority handling - Refactor CachedImage to utilize a priority system for image loading based on viewport visibility, improving performance during scrolling. - Update useCachedUrl to accept an optional getPriority function for better cache management. - Optimize ArtistDetail and Artists components by using useMemo for cover art URLs, reducing redundant calculations and improving rendering efficiency. - Adjust image loading logic in CachedImage to ensure smoother transitions and avoid unnecessary fetch requests. * perf(ui): unblock IDB cover art, stabilize mainstage rails and virtual lists Let IndexedDB reads bypass the network concurrency slot so cached thumbnails paint without queueing behind remote fetches; debounce disk eviction during heavy scrolling. Fix mainstage horizontal rails: dedupe album/song ids for React keys, widen artwork budget overscan, avoid resetting the budget on list append, and raise Home initial artwork budgets. CachedImage treats already-decoded images as loaded; rail cards load cover images eagerly. Refresh dynamic color extraction and extend virtual scrolling / scroll roots on Albums, Artists, Playlists, and related surfaces. Remove local agent-only commit instructions from the repository tree. * perf(virtual): viewport-based overscan for main scroll lists Drive TanStack Virtual overscan from measured scroll height so each list renders about one screen of extra rows above and below the viewport for snappier scrolling on Albums, Artists (list mode), and Tracks virtual song list. Introduce useResizeClientHeight helpers (ID + ref) for ResizeObserver-based clientHeight tracking. * docs(changelog): note PR #468 UI cover cache, rails, and virtual lists Add a coarse summary under 1.46.0 Changed for cover-art pipeline, mainstage rails, viewport-based overscan, and library/chrome polish. --- .gitignore | 3 + CHANGELOG.md | 9 ++ src/components/AlbumCard.tsx | 2 +- src/components/AlbumHeader.tsx | 9 +- src/components/AlbumRow.tsx | 39 +++-- src/components/CachedImage.tsx | 101 +++++++++--- src/components/LiveSearch.tsx | 15 +- src/components/MiniPlayer.tsx | 14 +- src/components/SongCard.tsx | 2 +- src/components/SongRail.tsx | 36 +++-- src/components/VirtualSongList.tsx | 5 +- src/hooks/useResizeClientHeight.ts | 39 +++++ src/pages/Albums.tsx | 114 ++++++++++++-- src/pages/ArtistDetail.tsx | 86 +++++++--- src/pages/Artists.tsx | 244 +++++++++++++++++++++++------ src/pages/Home.tsx | 23 +-- src/pages/MostPlayed.tsx | 22 ++- src/pages/PlaylistDetail.tsx | 8 +- src/pages/Playlists.tsx | 43 +++-- src/pages/Tracks.tsx | 13 +- src/utils/dedupeById.ts | 15 ++ src/utils/dynamicColors.ts | 126 +++++++++++---- src/utils/imageCache.ts | 206 +++++++++++++++++------- 23 files changed, 895 insertions(+), 279 deletions(-) create mode 100644 src/hooks/useResizeClientHeight.ts create mode 100644 src/utils/dedupeById.ts diff --git a/.gitignore b/.gitignore index a70292af..711280e3 100644 --- a/.gitignore +++ b/.gitignore @@ -36,6 +36,9 @@ src-tauri/gen/ # Documentation CLAUDE.md +# Local commit-instructions for agents (never commit) +to_commit.md + # Claude Code memory (local only) memory/ diff --git a/CHANGELOG.md b/CHANGELOG.md index 2b3db3f0..48a8f6ee 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -57,6 +57,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 * Heavier app **routes** are loaded **lazily** so the initial JS bundle stays smaller (`App.tsx` and related entry wiring). * Restored **default Vite `chunkSizeWarningLimit`** behaviour so oversized chunks are reported again during production builds (`vite.config.ts`). +### UI — cover cache, mainstage rails, and smoother virtual lists + +**By [@cucadmuh](https://github.com/cucadmuh), PR [#468](https://github.com/Psychotoxical/psysonic/pull/468)** + +* **Image cache:** **Network fetches** share a small concurrency pool; **IndexedDB** hits are no longer queued behind remote downloads. **Debounced** disk eviction avoids hammering storage during fast cover scrolling. Related **shared blob URL** / hot-path fixes for thumbnails. +* **Mainstage & rails:** **Horizontal rows** use **reworked artwork windowing** (higher initial budgets, extra slack ahead of the viewport, **no budget reset** when “load more” extends the list). **Duplicate album/song IDs** from the API are **deduped** for stable React reconciliation. **CachedImage** handles **already-decoded / cache-hit** images cleanly; rail **Album / song cards** load covers **eagerly** to reduce **blank art** while scrubbing sideways. +* **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. + ## Fixed ### Hot cache, HTTP streaming replay, and queue source indicator diff --git a/src/components/AlbumCard.tsx b/src/components/AlbumCard.tsx index 52f3f81a..865c36fe 100644 --- a/src/components/AlbumCard.tsx +++ b/src/components/AlbumCard.tsx @@ -97,7 +97,7 @@ function AlbumCard({ src={coverUrl} cacheKey={coverCacheKey} alt={`${album.name} Cover`} - loading="lazy" + loading="eager" decoding="async" /> ) : ( diff --git a/src/components/AlbumHeader.tsx b/src/components/AlbumHeader.tsx index 4321febd..7980be09 100644 --- a/src/components/AlbumHeader.tsx +++ b/src/components/AlbumHeader.tsx @@ -1,4 +1,4 @@ -import React, { useState } from 'react'; +import React, { useMemo, useState } from 'react'; import { useNavigate } from 'react-router-dom'; import { Play, Heart, ExternalLink, X, ChevronLeft, Download, ListPlus, HardDriveDownload, Loader2, Highlighter, Shuffle, Share2 } from 'lucide-react'; import { SubsonicSong, buildCoverArtUrl } from '../api/subsonic'; @@ -135,6 +135,11 @@ export default function AlbumHeader({ const formatLabel = [...new Set(songs.map(s => s.suffix).filter((f): f is string => !!f))].map(f => f.toUpperCase()).join(' / '); const isNewAlbum = isAlbumRecentlyAdded(info.created); + const lightboxCoverSrc = useMemo( + () => (info.coverArt ? buildCoverArtUrl(info.coverArt, 2000) : ''), + [info.coverArt], + ); + const handleShareAlbum = async () => { try { const ok = await copyEntityShareLink('album', info.id); @@ -150,7 +155,7 @@ export default function AlbumHeader({ {bioOpen && bio && } {lightboxOpen && info.coverArt && ( setLightboxOpen(false)} /> diff --git a/src/components/AlbumRow.tsx b/src/components/AlbumRow.tsx index 078331c5..ff727ac3 100644 --- a/src/components/AlbumRow.tsx +++ b/src/components/AlbumRow.tsx @@ -1,10 +1,11 @@ -import React, { useRef, useState, useEffect } from 'react'; +import React, { useRef, useState, useEffect, useMemo } from 'react'; import { SubsonicAlbum } from '../api/subsonic'; import AlbumCard from './AlbumCard'; import { ChevronLeft, ChevronRight, ArrowRight } from 'lucide-react'; import { NavLink, useNavigate } from 'react-router-dom'; import { useTranslation } from 'react-i18next'; import { usePerfProbeFlags } from '../utils/perfFlags'; +import { dedupeById } from '../utils/dedupeById'; interface Props { title: string; @@ -50,6 +51,7 @@ export default function AlbumRow({ const [artworkBudget, setArtworkBudget] = useState(initialArtworkBudget); const loadingRef = useRef(false); + const uniqueAlbums = useMemo(() => dedupeById(albums), [albums]); const recomputeArtworkBudget = () => { if (!windowArtworkByViewport) return; @@ -62,19 +64,23 @@ export default function AlbumRow({ const gap = Number.parseFloat(gridStyles.columnGap || gridStyles.gap || '16') || 16; const step = Math.max(1, cardW + gap); const visibleCount = Math.ceil((scrollLeft + clientWidth) / step); - const nextBudget = Math.max(initialArtworkBudget, visibleCount + 4); + // Extra slack so fast horizontal scroll doesn’t hit the idx≥budget cliff between frames. + const nextBudget = Math.max(initialArtworkBudget, visibleCount + 12); setArtworkBudget(prev => (nextBudget > prev ? nextBudget : prev)); }; const handleScroll = () => { - if (interactivityDisabled) return; + if (windowArtworkByViewport) recomputeArtworkBudget(); + if (!scrollRef.current) return; const { scrollLeft, scrollWidth, clientWidth } = scrollRef.current; - setShowLeft(scrollLeft > 0); - setShowRight(scrollLeft < scrollWidth - clientWidth - 5); - recomputeArtworkBudget(); - // Auto-load trigger + if (!interactivityDisabled) { + setShowLeft(scrollLeft > 0); + setShowRight(scrollLeft < scrollWidth - clientWidth - 5); + } + + // Auto-load trigger (native horizontal scroll still works when rail buttons are perf-disabled) if (onLoadMore && !loadingRef.current && scrollLeft > 0 && scrollLeft + clientWidth >= scrollWidth - 300) { triggerLoadMore(); } @@ -90,15 +96,13 @@ export default function AlbumRow({ }; useEffect(() => { - if (interactivityDisabled) return; handleScroll(); const raf = window.requestAnimationFrame(() => { - // One post-layout pass ensures we account for final grid/card geometry. - recomputeArtworkBudget(); + if (windowArtworkByViewport) recomputeArtworkBudget(); }); window.addEventListener('resize', handleScroll); const ro = new ResizeObserver(() => { - recomputeArtworkBudget(); + if (windowArtworkByViewport) recomputeArtworkBudget(); }); if (scrollRef.current) ro.observe(scrollRef.current); return () => { @@ -106,11 +110,14 @@ export default function AlbumRow({ window.removeEventListener('resize', handleScroll); ro.disconnect(); }; - }, [albums, interactivityDisabled, windowArtworkByViewport, initialArtworkBudget]); + }, [uniqueAlbums, interactivityDisabled, windowArtworkByViewport, initialArtworkBudget]); + // Reset when the row’s identity changes (new data / server), not when the list grows via + // “load more” — reusing albums.length would shrink the budget mid-scroll and flash placeholders. + const rowArtworkResetKey = uniqueAlbums[0]?.id ?? ''; useEffect(() => { setArtworkBudget(initialArtworkBudget); - }, [initialArtworkBudget, albums.length]); + }, [initialArtworkBudget, rowArtworkResetKey]); const scroll = (dir: 'left' | 'right') => { if (!scrollRef.current) return; @@ -118,7 +125,7 @@ export default function AlbumRow({ scrollRef.current.scrollBy({ left: dir === 'left' ? -amount : amount, behavior: 'smooth' }); }; - if (albums.length === 0) return null; + if (uniqueAlbums.length === 0) return null; return ( @@ -154,8 +161,8 @@ export default function AlbumRow({ - - {albums.map((a, idx) => ( + + {uniqueAlbums.map((a, idx) => ( { @@ -17,7 +18,19 @@ interface CachedImageProps extends React.ImgHTMLAttributes { * loading immediately. Pass false for CSS background-image consumers that * should only see a stable blob URL (prevents a double crossfade). */ -export function useCachedUrl(fetchUrl: string, cacheKey: string, fallbackToFetch = true): string { +export function useCachedUrl( + fetchUrl: string, + cacheKey: string, + fallbackToFetch = true, + getPriority?: () => number, +): string { + // `buildCoverArtUrl` rotates salt/token on every call — `fetchUrl` is a new + // string each render though the logical image is unchanged (`cacheKey`). If + // `fetchUrl` were an effect dependency, cleanup would run every frame, call + // `releaseUrl`, revoke the blob, and break until onError hides it. + const fetchUrlRef = useRef(fetchUrl); + fetchUrlRef.current = fetchUrl; + // Synchronously acquire on first render when the blob is already hot. This // makes the very first a blob URL, avoiding a fetchUrl→blobUrl // swap that would trigger a redundant network request and decode pass. @@ -26,6 +39,9 @@ export function useCachedUrl(fetchUrl: string, cacheKey: string, fallbackToFetch // exactly what to release on cleanup or when keys change. const ownedKeyRef = useRef(resolved ? cacheKey : null); + const getPriorityRef = useRef(getPriority); + getPriorityRef.current = getPriority; + useEffect(() => { const release = () => { if (ownedKeyRef.current) { @@ -34,14 +50,17 @@ export function useCachedUrl(fetchUrl: string, cacheKey: string, fallbackToFetch } }; - if (!fetchUrl) { + const currentUrl = fetchUrlRef.current; + if (!currentUrl) { release(); setResolved(''); - return; + return release; } - // Lazy initializer (or a previous run) already acquired the right key. - if (ownedKeyRef.current === cacheKey) return release; + // Same logical image as last run — only `cacheKey` drives this effect. + if (ownedKeyRef.current === cacheKey) { + return release; + } // Different key than we're currently holding: drop the old one. release(); @@ -57,7 +76,7 @@ export function useCachedUrl(fetchUrl: string, cacheKey: string, fallbackToFetch // Slow path: fetch (or read from IDB), then acquire. setResolved(''); const controller = new AbortController(); - getCachedBlob(fetchUrl, cacheKey, controller.signal).then(blob => { + getCachedBlob(currentUrl, cacheKey, controller.signal, () => getPriorityRef.current?.() ?? 0).then(blob => { if (controller.signal.aborted || !blob) return; const url = acquireUrl(cacheKey); if (!url) return; @@ -68,32 +87,59 @@ export function useCachedUrl(fetchUrl: string, cacheKey: string, fallbackToFetch controller.abort(); release(); }; - }, [fetchUrl, cacheKey]); + }, [cacheKey]); return fallbackToFetch ? (resolved || fetchUrl) : resolved; } export default function CachedImage({ src, cacheKey, style, onLoad, onError, ...props }: CachedImageProps) { - const [inView, setInView] = useState(false); const [fallbackSrc, setFallbackSrc] = useState(undefined); const imgRef = useRef(null); + /** + * Drives disk/network waiter ordering only. We intentionally do **not** gate + * `useCachedUrl` on intersection — relying on IO to “arm” loading proved brittle + * (custom scroll roots, content-visibility, horizontal rails) and led to blank covers. + */ + const priorityRef = useRef(0); + const getViewportImagePriority = useCallback(() => priorityRef.current, []); useEffect(() => { const el = imgRef.current; if (!el) return; + const root = + typeof document !== 'undefined' + ? (document.getElementById(APP_MAIN_SCROLL_VIEWPORT_ID) as Element | null) + : null; + const updateFromEntry = (entry: IntersectionObserverEntry) => { + if (entry.isIntersecting) { + const r = entry.boundingClientRect; + const rootEl = entry.rootBounds; + const vh = (rootEl?.height ?? window.innerHeight) || 1; + const originTop = rootEl?.top ?? 0; + const vc = originTop + vh * 0.5; + const cy = r.top + r.height * 0.5; + const dist = Math.abs(cy - vc); + priorityRef.current = entry.intersectionRatio * 1e7 - dist * 1e3; + } else { + priorityRef.current = -1e12; + } + }; const observer = new IntersectionObserver( - ([entry]) => { if (entry.isIntersecting) { setInView(true); observer.disconnect(); } }, - { rootMargin: '300px' }, // start fetching 300px before entering viewport + entries => { for (const e of entries) updateFromEntry(e); }, + { + root: root ?? undefined, + rootMargin: '300px', + threshold: [0, 0.02, 0.1, 0.25, 0.5, 0.75, 1], + }, ); observer.observe(el); return () => observer.disconnect(); }, []); - // Pass empty string when not yet in view so useCachedUrl skips the fetch entirely. - // fallbackToFetch=false: avoid the fetchUrl→blobUrl src swap, which causes the browser - // to start a server fetch, then abort it when we replace src with the blob URL — - // visible in DevTools as a flood of "Pending / 0 B" requests on Chromium/WebView2. - const resolvedSrc = useCachedUrl(inView ? src : '', cacheKey, false); + // 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. + // Priority still applies to the slow path inside getCachedBlob. + const resolvedSrc = useCachedUrl(src, cacheKey, true, getViewportImagePriority); const [loaded, setLoaded] = useState(false); // Reset only when the logical image changes (cacheKey), not on fetchUrl→blobUrl @@ -103,6 +149,26 @@ export default function CachedImage({ src, cacheKey, style, onLoad, onError, ... setFallbackSrc(undefined); }, [cacheKey]); + const isFallback = fallbackSrc !== undefined; + const finalSrc = fallbackSrc ?? (resolvedSrc || undefined); + + // Browsers sometimes skip `load` for cache hits / lazy + horizontal scroll — unstick opacity. + useEffect(() => { + if (!finalSrc) return; + let alive = true; + const id = requestAnimationFrame(() => { + if (!alive) return; + const img = imgRef.current; + if (img?.complete && img.naturalWidth > 0) { + setLoaded(true); + } + }); + return () => { + alive = false; + cancelAnimationFrame(id); + }; + }, [finalSrc]); + const handleError = (e: React.SyntheticEvent) => { if (onError) { // Caller wants custom error handling (e.g. hide the element) @@ -114,9 +180,6 @@ export default function CachedImage({ src, cacheKey, style, onLoad, onError, ... } }; - const isFallback = fallbackSrc !== undefined; - const finalSrc = fallbackSrc ?? (resolvedSrc || undefined); - const fallbackStyle: React.CSSProperties = isFallback ? { objectFit: 'contain', background: 'var(--bg-card, var(--ctp-surface0, #313244))', padding: '15%' } : {}; diff --git a/src/components/LiveSearch.tsx b/src/components/LiveSearch.tsx index 4ad9fcbe..c3044cf1 100644 --- a/src/components/LiveSearch.tsx +++ b/src/components/LiveSearch.tsx @@ -1,4 +1,4 @@ -import React, { useState, useEffect, useRef, useCallback } from 'react'; +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'; @@ -16,6 +16,12 @@ function debounce(fn: (q: string) => void, ms: number): (q: string) => void { }; } +function LiveSearchAlbumThumb({ coverArt }: { coverArt: string }) { + const src = useMemo(() => buildCoverArtUrl(coverArt, 40), [coverArt]); + const cacheKey = useMemo(() => coverArtCacheKey(coverArt, 40), [coverArt]); + return ; +} + export default function LiveSearch() { const { t } = useTranslation(); const [query, setQuery] = useState(''); @@ -323,12 +329,7 @@ export default function LiveSearch() { }} role="option" aria-selected={activeIndex === i}> {a.coverArt ? ( - + ) : ( )} diff --git a/src/components/MiniPlayer.tsx b/src/components/MiniPlayer.tsx index bafd6e51..3882ff16 100644 --- a/src/components/MiniPlayer.tsx +++ b/src/components/MiniPlayer.tsx @@ -1,4 +1,4 @@ -import React, { useEffect, useLayoutEffect, useRef, useState } from 'react'; +import React, { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'; import { createPortal } from 'react-dom'; import { emit, listen } from '@tauri-apps/api/event'; import { invoke } from '@tauri-apps/api/core'; @@ -481,6 +481,14 @@ export default function MiniPlayer() { }, [queueOpen, state.queueIndex]); const { track, isPlaying } = state; + const miniCoverSrc = useMemo( + () => (track?.coverArt ? buildCoverArtUrl(track.coverArt, 300) : ''), + [track?.coverArt], + ); + const miniCoverKey = useMemo( + () => (track?.coverArt ? coverArtCacheKey(track.coverArt, 300) : ''), + [track?.coverArt], + ); const progress = duration > 0 ? Math.min(100, (currentTime / duration) * 100) : 0; return ( @@ -540,8 +548,8 @@ export default function MiniPlayer() { {track?.coverArt ? ( ) : ( diff --git a/src/components/SongCard.tsx b/src/components/SongCard.tsx index c8d508c3..53edbc2f 100644 --- a/src/components/SongCard.tsx +++ b/src/components/SongCard.tsx @@ -89,7 +89,7 @@ function SongCard({ song, disableArtwork = false, artworkSize = 200 }: SongCardP src={coverUrl} cacheKey={coverCacheKey} alt={`${song.album} Cover`} - loading="lazy" + loading="eager" decoding="async" /> ) : ( diff --git a/src/components/SongRail.tsx b/src/components/SongRail.tsx index 24125217..c10febd2 100644 --- a/src/components/SongRail.tsx +++ b/src/components/SongRail.tsx @@ -1,8 +1,9 @@ -import React, { useRef, useState, useEffect } from 'react'; +import React, { useRef, useState, useEffect, useMemo } from 'react'; import { ChevronLeft, ChevronRight, RefreshCw } from 'lucide-react'; import { SubsonicSong } from '../api/subsonic'; import SongCard from './SongCard'; import { usePerfProbeFlags } from '../utils/perfFlags'; +import { dedupeById } from '../utils/dedupeById'; interface Props { title: string; @@ -36,6 +37,7 @@ export default function SongRail({ const artworkDisabled = perfFlags.disableMainstageRailArtwork || disableArtwork; const interactivityDisabled = perfFlags.disableMainstageRailInteractivity || disableInteractivity; const scrollRef = useRef(null); + const uniqueSongs = useMemo(() => dedupeById(songs), [songs]); const [showLeft, setShowLeft] = useState(false); const [showRight, setShowRight] = useState(true); const [artworkBudget, setArtworkBudget] = useState(initialArtworkBudget); @@ -51,29 +53,30 @@ export default function SongRail({ const gap = Number.parseFloat(gridStyles.columnGap || gridStyles.gap || '12') || 12; const step = Math.max(1, cardW + gap); const visibleCount = Math.ceil((scrollLeft + clientWidth) / step); - const nextBudget = Math.max(initialArtworkBudget, visibleCount + 4); + const nextBudget = Math.max(initialArtworkBudget, visibleCount + 12); setArtworkBudget(prev => (nextBudget > prev ? nextBudget : prev)); }; const handleScroll = () => { - if (interactivityDisabled) return; + if (windowArtworkByViewport) recomputeArtworkBudget(); + if (!scrollRef.current) return; const { scrollLeft, scrollWidth, clientWidth } = scrollRef.current; - setShowLeft(scrollLeft > 0); - setShowRight(scrollLeft < scrollWidth - clientWidth - 5); - recomputeArtworkBudget(); + + if (!interactivityDisabled) { + setShowLeft(scrollLeft > 0); + setShowRight(scrollLeft < scrollWidth - clientWidth - 5); + } }; useEffect(() => { - if (interactivityDisabled) return; handleScroll(); const raf = window.requestAnimationFrame(() => { - // One post-layout pass ensures we account for final grid/card geometry. - recomputeArtworkBudget(); + if (windowArtworkByViewport) recomputeArtworkBudget(); }); window.addEventListener('resize', handleScroll); const ro = new ResizeObserver(() => { - recomputeArtworkBudget(); + if (windowArtworkByViewport) recomputeArtworkBudget(); }); if (scrollRef.current) ro.observe(scrollRef.current); return () => { @@ -81,11 +84,12 @@ export default function SongRail({ window.removeEventListener('resize', handleScroll); ro.disconnect(); }; - }, [songs, interactivityDisabled, windowArtworkByViewport, initialArtworkBudget]); + }, [uniqueSongs, interactivityDisabled, windowArtworkByViewport, initialArtworkBudget]); + const rowArtworkResetKey = uniqueSongs[0]?.id ?? ''; useEffect(() => { setArtworkBudget(initialArtworkBudget); - }, [initialArtworkBudget, songs.length]); + }, [initialArtworkBudget, rowArtworkResetKey]); const scroll = (dir: 'left' | 'right') => { if (!scrollRef.current) return; @@ -94,7 +98,7 @@ export default function SongRail({ }; // Hide rail entirely if empty and no empty-state copy - if (songs.length === 0 && !loading && !emptyText) return null; + if (uniqueSongs.length === 0 && !loading && !emptyText) return null; return ( @@ -135,11 +139,11 @@ export default function SongRail({ - {songs.length === 0 && emptyText ? ( + {uniqueSongs.length === 0 && emptyText ? ( {emptyText} ) : ( - - {songs.map((s, idx) => ( + + {uniqueSongs.map((s, idx) => ( (null); + const scrollParentHeight = useRefElementClientHeight(scrollParentRef); + const songListOverscan = Math.max(8, Math.ceil(scrollParentHeight / ROW_HEIGHT)); const requestSeqRef = useRef(0); // Debounce query @@ -139,7 +142,7 @@ export default function VirtualSongList({ title, emptyBrowseText }: Props) { count: songs.length, getScrollElement: () => scrollParentRef.current, estimateSize: () => ROW_HEIGHT, - overscan: 8, + overscan: songListOverscan, }); const totalSize = virtualizer.getTotalSize(); diff --git a/src/hooks/useResizeClientHeight.ts b/src/hooks/useResizeClientHeight.ts new file mode 100644 index 00000000..19a0d557 --- /dev/null +++ b/src/hooks/useResizeClientHeight.ts @@ -0,0 +1,39 @@ +import { type RefObject, useLayoutEffect, useState } from 'react'; + +/** + * Track an element's `clientHeight` (ResizeObserver). Used so virtualizers can + * set `overscan` to roughly one viewport of rows beyond the visible range. + */ +export function useElementClientHeightById(elementId: string, fallback = 800): number { + const [h, setH] = useState(fallback); + useLayoutEffect(() => { + const el = typeof document !== 'undefined' ? document.getElementById(elementId) : null; + if (!el) { + setH(fallback); + return; + } + const update = () => setH(el.clientHeight); + const ro = new ResizeObserver(update); + ro.observe(el); + update(); + return () => ro.disconnect(); + }, [elementId, fallback]); + return h; +} + +export function useRefElementClientHeight( + ref: RefObject, + fallback = 600, +): number { + const [h, setH] = useState(fallback); + useLayoutEffect(() => { + const el = ref.current; + if (!el) return; + const update = () => setH(el.clientHeight); + const ro = new ResizeObserver(update); + ro.observe(el); + update(); + return () => ro.disconnect(); + }, [ref, fallback]); + return h; +} diff --git a/src/pages/Albums.tsx b/src/pages/Albums.tsx index 32d48f1e..7c671afd 100644 --- a/src/pages/Albums.tsx +++ b/src/pages/Albums.tsx @@ -1,4 +1,4 @@ -import React, { useEffect, useState, useCallback, useRef, useMemo } from 'react'; +import React, { useState, useEffect, useRef, useCallback, useMemo, useLayoutEffect } from 'react'; import AlbumCard from '../components/AlbumCard'; import GenreFilterBar from '../components/GenreFilterBar'; import YearFilterButton from '../components/YearFilterButton'; @@ -16,8 +16,16 @@ import { join } from '@tauri-apps/api/path'; import { showToast } from '../utils/toast'; import { useZipDownloadStore } from '../store/zipDownloadStore'; import { CheckSquare2, Download, HardDriveDownload, ListMusic, Disc3, ListPlus } from 'lucide-react'; +import { useVirtualizer } from '@tanstack/react-virtual'; +import { APP_MAIN_SCROLL_VIEWPORT_ID } from '../constants/appScroll'; +import { useElementClientHeightById } from '../hooks/useResizeClientHeight'; import { usePerfProbeFlags } from '../utils/perfFlags'; +const ALBUM_GRID_GAP_PX = 16; // matches --space-4 +const ALBUM_GRID_MIN_CARD_PX = 140; +/** Estimated row height for virtual window (card + margin). */ +const ALBUM_VIRTUAL_ROW_HEIGHT = 288; + type SortType = 'alphabeticalByName' | 'alphabeticalByArtist'; type CompFilter = 'all' | 'only' | 'hide'; @@ -87,6 +95,38 @@ export default function Albums() { return out; }, [albums, compFilter, starredOnly, starredOverrides]); + const albumGridWrapRef = useRef(null); + const [albumGridCols, setAlbumGridCols] = useState(4); + + useLayoutEffect(() => { + if (perfFlags.disableMainstageVirtualLists) return; + const el = albumGridWrapRef.current; + if (!el) return; + const ro = new ResizeObserver(() => { + const w = el.clientWidth; + const cols = Math.max(1, Math.floor((w + ALBUM_GRID_GAP_PX) / (ALBUM_GRID_MIN_CARD_PX + ALBUM_GRID_GAP_PX))); + setAlbumGridCols(cols); + }); + ro.observe(el); + return () => ro.disconnect(); + }, [perfFlags.disableMainstageVirtualLists, visibleAlbums.length]); + + const albumVirtualRowCount = Math.max(0, Math.ceil(visibleAlbums.length / albumGridCols)); + + const mainScrollViewportHeight = useElementClientHeightById(APP_MAIN_SCROLL_VIEWPORT_ID); + /** ~One full viewport of grid rows above + below visible range (TanStack overscan = rows per side). */ + const albumGridOverscan = Math.max( + 2, + Math.ceil(mainScrollViewportHeight / ALBUM_VIRTUAL_ROW_HEIGHT), + ); + + const albumGridVirtualizer = useVirtualizer({ + count: perfFlags.disableMainstageVirtualLists ? 0 : albumVirtualRowCount, + getScrollElement: () => document.getElementById(APP_MAIN_SCROLL_VIEWPORT_ID), + estimateSize: () => ALBUM_VIRTUAL_ROW_HEIGHT, + overscan: albumGridOverscan, + }); + const selectedAlbums = visibleAlbums.filter(a => selectedIds.has(a.id)); const openContextMenu = usePlayerStore(state => state.openContextMenu); const enqueue = usePlayerStore(state => state.enqueue); @@ -313,18 +353,66 @@ export default function Albums() { ) : ( <> {!perfFlags.disableMainstageGridCards && ( - - {visibleAlbums.map(a => ( - - ))} - + perfFlags.disableMainstageVirtualLists ? ( + + {visibleAlbums.map(a => ( + + ))} + + ) : ( + + + {albumGridVirtualizer.getVirtualItems().map(vRow => { + const start = vRow.index * albumGridCols; + const rowAlbums = visibleAlbums.slice(start, start + albumGridCols); + return ( + + {rowAlbums.map(a => ( + + ))} + + ); + })} + + + ) )} {!genreFiltered && ( diff --git a/src/pages/ArtistDetail.tsx b/src/pages/ArtistDetail.tsx index fc8a912b..de8ba97a 100644 --- a/src/pages/ArtistDetail.tsx +++ b/src/pages/ArtistDetail.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState, useRef, Fragment } from 'react'; +import { useEffect, useState, useRef, Fragment, useMemo } from 'react'; import { useParams, useNavigate } from 'react-router-dom'; import { getArtist, getArtistInfo, getTopSongs, getSimilarSongs2, getAlbum, search, setRating, SubsonicArtist, SubsonicAlbum, SubsonicSong, SubsonicArtistInfo, buildCoverArtUrl, coverArtCacheKey, star, unstar, uploadArtistImage } from '../api/subsonic'; import AlbumCard from '../components/AlbumCard'; @@ -29,6 +29,20 @@ function formatDuration(seconds: number): string { return `${m}:${s.toString().padStart(2, '0')}`; } +function ArtistSuggestionTrackCover({ coverArt, album }: { coverArt: string; album: string }) { + const src = useMemo(() => buildCoverArtUrl(coverArt, 64), [coverArt]); + const cacheKey = useMemo(() => coverArtCacheKey(coverArt, 64), [coverArt]); + return ( + { (e.currentTarget as HTMLImageElement).style.display = 'none'; }} + /> + ); +} + /** Strip dangerous tags/attributes from server-provided HTML */ function sanitizeHtml(html: string): string { const parser = new DOMParser(); @@ -72,6 +86,8 @@ export default function ArtistDetail() { const isMobile = useIsMobile(); const [coverRevision, setCoverRevision] = useState(0); const [avatarGlow, setAvatarGlow] = useState(''); + /** True after header CachedImage onError — avoid `display:none` on the img (breaks recovery). */ + const [headerCoverFailed, setHeaderCoverFailed] = useState(false); const imageInputRef = useRef(null); const playTrack = usePlayerStore(state => state.playTrack); @@ -424,6 +440,29 @@ export default function ArtistDetail() { } }; + // Cover URLs — must run every render (before early returns) or hook order breaks. + const coverId = artist ? (artist.coverArt || artist.id) : ''; + const artistCover300Src = useMemo( + () => (coverId ? buildCoverArtUrl(coverId, 300) : ''), + [coverId], + ); + const artistCover300Key = useMemo( + () => (coverId ? coverArtCacheKey(coverId, 300) : ''), + [coverId], + ); + const artistCover2000Src = useMemo( + () => (coverId ? buildCoverArtUrl(coverId, 2000) : ''), + [coverId], + ); + const artistCover80FallbackSrc = useMemo( + () => (coverId ? buildCoverArtUrl(coverId, 80) : ''), + [coverId], + ); + + useEffect(() => { + setHeaderCoverFailed(false); + }, [coverId, coverRevision, id]); + if (loading) { return ( @@ -442,7 +481,6 @@ export default function ArtistDetail() { ); } - const coverId = artist.coverArt || artist.id; const wikiUrl = `https://en.wikipedia.org/wiki/${encodeURIComponent(artist.name)}`; const serverSimilarArtists: SubsonicArtist[] = (info?.similarArtist ?? []).map(sa => ({ @@ -489,7 +527,7 @@ export default function ArtistDetail() { {lightboxOpen && ( setLightboxOpen(false)} /> @@ -510,15 +548,19 @@ export default function ArtistDetail() { onClick={() => setLightboxOpen(true)} aria-label={`${artist.name} Bild vergrößern`} > - extractCoverColors(e.currentTarget.src).then(({ accent }) => { if (accent) setAvatarGlow(accent); })} - onError={e => { (e.currentTarget as HTMLImageElement).style.display = 'none'; }} - /> + {!headerCoverFailed ? ( + extractCoverColors(e.currentTarget.src).then(({ accent }) => { if (accent) setAvatarGlow(accent); })} + onError={() => setHeaderCoverFailed(true)} + /> + ) : ( + + )} ) : ( @@ -667,7 +709,7 @@ export default function ArtistDetail() { {(info?.largeImageUrl || coverId) && ( { (e.target as HTMLImageElement).style.display = 'none'; }} @@ -702,7 +744,7 @@ export default function ArtistDetail() { const track = songToTrack(song); return ( { @@ -752,13 +794,7 @@ export default function ArtistDetail() { : } {song.coverArt && ( - { (e.currentTarget as HTMLImageElement).style.display = 'none'; }} - /> + )} {song.title} @@ -802,9 +838,9 @@ export default function ArtistDetail() { {(showAudiomuseSimilar ? serverSimilarArtists : similarArtists) .slice(0, isMobile && similarCollapsed ? 5 : undefined) - .map(a => ( + .map((a, i) => ( navigate(`/artist/${a.id}`)} > @@ -823,7 +859,7 @@ export default function ArtistDetail() { {albums.length > 0 ? ( - {albums.map(a => )} + {albums.map((a, i) => )} ) : ( {t('artistDetail.noAlbums')} @@ -844,7 +880,7 @@ export default function ArtistDetail() { ) : ( - {featuredAlbums.map(a => )} + {featuredAlbums.map((a, i) => )} )} diff --git a/src/pages/Artists.tsx b/src/pages/Artists.tsx index 630378dd..6247ff80 100644 --- a/src/pages/Artists.tsx +++ b/src/pages/Artists.tsx @@ -7,10 +7,23 @@ import { usePlayerStore } from '../store/playerStore'; import { useAuthStore } from '../store/authStore'; import CachedImage from '../components/CachedImage'; import { useTranslation } from 'react-i18next'; +import { useVirtualizer } from '@tanstack/react-virtual'; +import { APP_MAIN_SCROLL_VIEWPORT_ID } from '../constants/appScroll'; +import { useElementClientHeightById } from '../hooks/useResizeClientHeight'; +import { usePerfProbeFlags } from '../utils/perfFlags'; const ALL_SENTINEL = 'ALL'; const ALPHABET = [ALL_SENTINEL, '#', ...'ABCDEFGHIJKLMNOPQRSTUVWXYZ'.split('')]; +/** Virtual row height guesses — letter heading vs dense rows vs last row in section (group gap). */ +const ARTIST_LIST_LETTER_ROW_EST = 48; +const ARTIST_LIST_ROW_EST = 64; +const ARTIST_LIST_LAST_IN_LETTER_EST = 88; + +type ArtistListFlatRow = + | { kind: 'letter'; letter: string } + | { kind: 'artist'; artist: SubsonicArtist; isLastInLetter: boolean }; + // Catppuccin accent colors — one is picked deterministically from the artist name const CTP_COLORS = [ 'var(--ctp-rosewater)', 'var(--ctp-flamingo)', 'var(--ctp-pink)', 'var(--ctp-mauve)', @@ -35,12 +48,20 @@ function nameInitial(name: string): string { function ArtistCardAvatar({ artist, showImages }: { artist: SubsonicArtist; showImages: boolean }) { const color = nameColor(artist.name); - if (showImages && artist.coverArt) { + const coverId = artist.coverArt || artist.id; + const { coverSrc, coverKey } = useMemo( + () => ({ + coverSrc: coverId ? buildCoverArtUrl(coverId, 300) : '', + coverKey: coverId ? coverArtCacheKey(coverId, 300) : '', + }), + [coverId], + ); + if (showImages && coverId) { return ( @@ -55,12 +76,20 @@ function ArtistCardAvatar({ artist, showImages }: { artist: SubsonicArtist; show function ArtistRowAvatar({ artist, showImages }: { artist: SubsonicArtist; showImages: boolean }) { const color = nameColor(artist.name); - if (showImages && artist.coverArt) { + const coverId = artist.coverArt || artist.id; + const { coverSrc, coverKey } = useMemo( + () => ({ + coverSrc: coverId ? buildCoverArtUrl(coverId, 64) : '', + coverKey: coverId ? coverArtCacheKey(coverId, 64) : '', + }), + [coverId], + ); + if (showImages && coverId) { return ( @@ -75,6 +104,7 @@ function ArtistRowAvatar({ artist, showImages }: { artist: SubsonicArtist; showI } export default function Artists() { + const perfFlags = usePerfProbeFlags(); const { t } = useTranslation(); const [artists, setArtists] = useState([]); const [loading, setLoading] = useState(true); @@ -184,6 +214,46 @@ export default function Artists() { return { groups: g, letters: Object.keys(g).sort() }; }, [visible, viewMode]); + const artistListFlatRows = useMemo((): ArtistListFlatRow[] => { + if (viewMode !== 'list') return []; + const out: ArtistListFlatRow[] = []; + for (const letter of letters) { + out.push({ kind: 'letter', letter }); + const group = groups[letter]; + for (let i = 0; i < group.length; i++) { + out.push({ kind: 'artist', artist: group[i], isLastInLetter: i === group.length - 1 }); + } + } + return out; + }, [viewMode, letters, groups]); + + const mainScrollViewportHeight = useElementClientHeightById(APP_MAIN_SCROLL_VIEWPORT_ID); + /** Mixed row heights; smallest typical step ≈ artist row — one viewport of extra indices each side. */ + const artistListOverscan = Math.max( + 12, + Math.ceil(mainScrollViewportHeight / ARTIST_LIST_ROW_EST), + ); + + const artistListVirtualizer = useVirtualizer({ + count: + perfFlags.disableMainstageVirtualLists || viewMode !== 'list' ? 0 : artistListFlatRows.length, + getScrollElement: () => document.getElementById(APP_MAIN_SCROLL_VIEWPORT_ID), + estimateSize: index => { + const row = artistListFlatRows[index]; + if (!row) return ARTIST_LIST_ROW_EST; + if (row.kind === 'letter') return ARTIST_LIST_LETTER_ROW_EST; + return row.isLastInLetter ? ARTIST_LIST_LAST_IN_LETTER_EST : ARTIST_LIST_ROW_EST; + }, + /** Stable keys — avoids row DOM reuse glitches when the filtered slice changes. */ + getItemKey: index => { + const row = artistListFlatRows[index]; + if (!row) return index; + if (row.kind === 'letter') return `letter:${row.letter}`; + return `artist:${row.artist.id}`; + }, + overscan: artistListOverscan, + }); + return ( @@ -307,49 +377,129 @@ export default function Artists() { )} {!loading && viewMode === 'list' && ( - <> - {letters.map(letter => ( - - {letter} - - {groups[letter].map(artist => ( - { - if (selectionMode) { - toggleSelect(artist.id); - } else { - navigate(`/artist/${artist.id}`); - } - }} - onContextMenu={(e) => { - e.preventDefault(); - if (selectionMode && selectedIds.size > 0) { - openContextMenu(e.clientX, e.clientY, selectedArtists, 'multi-artist'); - } else { - openContextMenu(e.clientX, e.clientY, artist, 'artist'); - } - }} - id={`artist-${artist.id}`} - style={selectionMode && selectedIds.has(artist.id) ? { - background: 'var(--accent-dim)', - color: 'var(--accent)' - } : {}} - > - - - {artist.name} - {artist.albumCount != null && ( - {t('artists.albumCount', { count: artist.albumCount })} - )} - - - ))} + perfFlags.disableMainstageVirtualLists ? ( + <> + {letters.map(letter => ( + + {letter} + + {groups[letter].map(artist => ( + { + if (selectionMode) { + toggleSelect(artist.id); + } else { + navigate(`/artist/${artist.id}`); + } + }} + onContextMenu={(e) => { + e.preventDefault(); + if (selectionMode && selectedIds.size > 0) { + openContextMenu(e.clientX, e.clientY, selectedArtists, 'multi-artist'); + } else { + openContextMenu(e.clientX, e.clientY, artist, 'artist'); + } + }} + id={`artist-${artist.id}`} + style={selectionMode && selectedIds.has(artist.id) ? { + background: 'var(--accent-dim)', + color: 'var(--accent)' + } : {}} + > + + + {artist.name} + {artist.albumCount != null && ( + {t('artists.albumCount', { count: artist.albumCount })} + )} + + + ))} + + ))} + > + ) : ( + + + {artistListVirtualizer.getVirtualItems().map(vi => { + const row = artistListFlatRows[vi.index]; + if (!row) return null; + if (row.kind === 'letter') { + return ( + + {row.letter} + + ); + } + const artist = row.artist; + return ( + + { + if (selectionMode) { + toggleSelect(artist.id); + } else { + navigate(`/artist/${artist.id}`); + } + }} + onContextMenu={(e) => { + e.preventDefault(); + if (selectionMode && selectedIds.size > 0) { + openContextMenu(e.clientX, e.clientY, selectedArtists, 'multi-artist'); + } else { + openContextMenu(e.clientX, e.clientY, artist, 'artist'); + } + }} + id={`artist-${artist.id}`} + style={selectionMode && selectedIds.has(artist.id) ? { + background: 'var(--accent-dim)', + color: 'var(--accent)' + } : {}} + > + + + {artist.name} + {artist.albumCount != null && ( + {t('artists.albumCount', { count: artist.albumCount })} + )} + + + + ); + })} - ))} - > + + ) )} {!loading && hasMore && ( diff --git a/src/pages/Home.tsx b/src/pages/Home.tsx index 1e4dbd55..884993e4 100644 --- a/src/pages/Home.tsx +++ b/src/pages/Home.tsx @@ -11,6 +11,7 @@ import { useAuthStore } from '../store/authStore'; import { filterAlbumsByMixRatings, getMixMinRatingsConfigFromAuth } from '../utils/mixRatingFilter'; import { usePerfProbeFlags } from '../utils/perfFlags'; import { bumpPerfCounter } from '../utils/perfTelemetry'; +import { dedupeById } from '../utils/dedupeById'; /** Match Random Albums overshoot when mix filter uses album/artist axes so hero + discover row can still fill. */ const HOME_RANDOM_FETCH = 100; @@ -20,8 +21,9 @@ const HOME_DISCOVER_SONGS_SIZE = 18; const HOME_ALBUM_ROW_ARTWORK_SIZE = 300; const HOME_SONG_RAIL_ARTWORK_SIZE = 200; const HOME_ARTWORK_WINDOWING = true; -const HOME_ALBUM_ROW_INITIAL_ARTWORK_BUDGET = 3; -const HOME_SONG_RAIL_INITIAL_ARTWORK_BUDGET = 4; +// At least one viewport width of cards on first paint (low values left half the row as placeholders). +const HOME_ALBUM_ROW_INITIAL_ARTWORK_BUDGET = 14; +const HOME_SONG_RAIL_INITIAL_ARTWORK_BUDGET = 16; // Keep artwork enabled across Home rows in normal mode. const HOME_ARTWORK_VISIBLE_ROW_BUDGET_WHEN_ENABLED = 8; @@ -73,20 +75,20 @@ export default function Home() { : Promise.resolve([]), ]); if (cancelled) return; - const r = await filterAlbumsByMixRatings(rRaw, mixCfg); - setStarred(s); - setRecent(n); + const r = dedupeById(await filterAlbumsByMixRatings(rRaw, mixCfg)); + setStarred(dedupeById(s)); + setRecent(dedupeById(n)); setHeroAlbums(r.slice(0, HOME_HERO_COUNT)); setRandom(r.slice(HOME_HERO_COUNT, HOME_DISCOVER_SLICE)); - setMostPlayed(f); - setRecentlyPlayed(rp); - setDiscoverSongs(songs); + setMostPlayed(dedupeById(f)); + setRecentlyPlayed(dedupeById(rp)); + setDiscoverSongs(dedupeById(songs)); const shuffled = [...artists]; for (let i = shuffled.length - 1; i > 0; i--) { const j = Math.floor(Math.random() * (i + 1)); [shuffled[i], shuffled[j]] = [shuffled[j], shuffled[i]]; } - setRandomArtists(shuffled.slice(0, 16)); + setRandomArtists(dedupeById(shuffled).slice(0, 16)); } catch { /* ignore */ } finally { @@ -111,8 +113,9 @@ export default function Home() { try { const more = await getAlbumList(type, 12, currentList.length); const mixCfg = getMixMinRatingsConfigFromAuth(); - const batch = + const batchRaw = type === 'random' ? await filterAlbumsByMixRatings(more, mixCfg) : more; + const batch = dedupeById(batchRaw); const newItems = batch.filter(m => !currentList.find(c => c.id === m.id)); if (newItems.length > 0) setter(prev => [...prev, ...newItems]); } catch (e) { diff --git a/src/pages/MostPlayed.tsx b/src/pages/MostPlayed.tsx index 2a93d877..13c9d0fd 100644 --- a/src/pages/MostPlayed.tsx +++ b/src/pages/MostPlayed.tsx @@ -1,4 +1,4 @@ -import React, { useEffect, useState, useCallback } from 'react'; +import React, { useEffect, useState, useCallback, useMemo } from 'react'; import { useNavigate } from 'react-router-dom'; import { ArrowUpDown, ArrowDown, ArrowUp, TrendingUp, UsersRound } from 'lucide-react'; import { getAlbumList, SubsonicAlbum, buildCoverArtUrl, coverArtCacheKey } from '../api/subsonic'; @@ -49,6 +49,12 @@ function formatPlays(n: number, t: ReturnType buildCoverArtUrl(coverArt, 80), [coverArt]); + const cacheKey = useMemo(() => coverArtCacheKey(coverArt, 80), [coverArt]); + return ; +} + export default function MostPlayed() { const { t } = useTranslation(); const navigate = useNavigate(); @@ -136,12 +142,7 @@ export default function MostPlayed() { > {i + 1} {artist.coverArt ? ( - + ) : ( )} @@ -175,12 +176,7 @@ export default function MostPlayed() { > {sortAsc ? withPlays.length - i : i + 1} {album.coverArt ? ( - + ) : ( )} diff --git a/src/pages/PlaylistDetail.tsx b/src/pages/PlaylistDetail.tsx index d304b2e2..b6af500e 100644 --- a/src/pages/PlaylistDetail.tsx +++ b/src/pages/PlaylistDetail.tsx @@ -235,6 +235,12 @@ const PL_COLUMNS: readonly ColDef[] = [ const PL_CENTERED = new Set(['favorite', 'rating', 'duration']); +function PlaylistSearchResultThumb({ coverArt }: { coverArt: string }) { + const src = useMemo(() => buildCoverArtUrl(coverArt, 40), [coverArt]); + const cacheKey = useMemo(() => coverArtCacheKey(coverArt, 40), [coverArt]); + return ; +} + export default function PlaylistDetail() { const { id } = useParams<{ id: string }>(); const { t } = useTranslation(); @@ -1408,7 +1414,7 @@ export default function PlaylistDetail() { return next; })} /> - + {song.title} {song.artist} · {song.album} diff --git a/src/pages/Playlists.tsx b/src/pages/Playlists.tsx index b14f7109..c500484e 100644 --- a/src/pages/Playlists.tsx +++ b/src/pages/Playlists.tsx @@ -1,4 +1,4 @@ -import React, { useEffect, useState, useRef, useCallback } from 'react'; +import React, { useEffect, useState, useRef, useCallback, useMemo } from 'react'; import { useNavigate } from 'react-router-dom'; import { ListMusic, Play, Plus, Trash2, CheckSquare2, Check, Clock3, Sparkles, Pencil } from 'lucide-react'; import { deletePlaylist, SubsonicPlaylist, getPlaylist, buildCoverArtUrl, coverArtCacheKey, updatePlaylist, getGenres, SubsonicGenre, filterSongsToActiveLibrary } from '../api/subsonic'; @@ -16,6 +16,32 @@ function formatDuration(seconds: number): string { return formatHumanHoursMinutes(seconds); } +function PlaylistSmartCoverCell({ coverId }: { coverId: string }) { + const src = useMemo(() => buildCoverArtUrl(coverId, 200), [coverId]); + const cacheKey = useMemo(() => coverArtCacheKey(coverId, 200), [coverId]); + return ( + + ); +} + +function PlaylistCardMainCover({ coverArt, alt }: { coverArt: string; alt: string }) { + const src = useMemo(() => buildCoverArtUrl(coverArt, 256), [coverArt]); + const cacheKey = useMemo(() => coverArtCacheKey(coverArt, 256), [coverArt]); + return ( + + ); +} + const SMART_PREFIX = 'psy-smart-'; const LIMIT_MAX = 500; const YEAR_MIN = 1950; @@ -940,25 +966,14 @@ export default function Playlists() { {Array.from({ length: 4 }, (_, i) => { const id = smartCoverIdsByPlaylist[pl.id][i % smartCoverIdsByPlaylist[pl.id].length]; return id ? ( - + ) : ( ); })} ) : pl.coverArt ? ( - + ) : ( diff --git a/src/pages/Tracks.tsx b/src/pages/Tracks.tsx index 9686a732..dad91dd9 100644 --- a/src/pages/Tracks.tsx +++ b/src/pages/Tracks.tsx @@ -28,7 +28,7 @@ const RATED_RAIL_DISPLAY = 30; const RATED_RAIL_CACHE_MS = 60_000; /** Match Home: only mount artwork for cards near the horizontal viewport. */ const TRACKS_SONG_RAIL_WINDOWING = true; -const TRACKS_SONG_RAIL_INITIAL_ARTWORK_BUDGET = 4; +const TRACKS_SONG_RAIL_INITIAL_ARTWORK_BUDGET = 14; export default function Tracks() { const perfFlags = usePerfProbeFlags(); @@ -91,7 +91,14 @@ export default function Tracks() { reloadRated(); }, [activeServerId, rerollHero, rerollRandom, reloadRated]); - const heroCoverUrl = hero?.coverArt ? buildCoverArtUrl(hero.coverArt, 600) : ''; + const heroCoverUrl = useMemo( + () => (hero?.coverArt ? buildCoverArtUrl(hero.coverArt, 600) : ''), + [hero?.coverArt], + ); + const heroCoverKey = useMemo( + () => (hero?.coverArt ? coverArtCacheKey(hero.coverArt, 600) : ''), + [hero?.coverArt], + ); // Hide the hero song from the random rail if the server happens to return it in // both fetches (Navidrome's getRandomSongs sometimes overlaps within a short window). @@ -117,7 +124,7 @@ export default function Tracks() { {heroCoverUrl ? ( ) : ( diff --git a/src/utils/dedupeById.ts b/src/utils/dedupeById.ts new file mode 100644 index 00000000..8891d660 --- /dev/null +++ b/src/utils/dedupeById.ts @@ -0,0 +1,15 @@ +/** + * Keeps the first occurrence of each `id`. Subsonic responses (and merged pages) + * occasionally repeat the same album/song id; duplicate React keys then warn and + * break reconciliation. + */ +export function dedupeById(items: T[]): T[] { + const seen = new Set(); + const out: T[] = []; + for (const item of items) { + if (seen.has(item.id)) continue; + seen.add(item.id); + out.push(item); + } + return out; +} diff --git a/src/utils/dynamicColors.ts b/src/utils/dynamicColors.ts index bb4d3c8b..351a43d8 100644 --- a/src/utils/dynamicColors.ts +++ b/src/utils/dynamicColors.ts @@ -117,49 +117,109 @@ export function ensureContrast( const FS_BG_LUMINANCE = 0.010; const MIN_CONTRAST = 4.5; +function isRemoteHttpUrl(url: string): boolean { + return /^https?:\/\//i.test(url); +} + +function isBlobOrDataUrl(url: string): boolean { + return url.startsWith('blob:') || url.startsWith('data:'); +} + +/** + * Samples decoded pixels from an HTMLImageElement (already loaded). + * Throws if the canvas is tainted (caller should catch). + */ +function sampleImageToAccent(img: HTMLImageElement): CoverColors { + const canvas = document.createElement('canvas'); + canvas.width = 8; + canvas.height = 8; + const ctx = canvas.getContext('2d'); + if (!ctx) return { accent: '' }; + + ctx.drawImage(img, 0, 0, 8, 8); + const { data } = ctx.getImageData(0, 0, 8, 8); + + let bestSat = -1; + let bestR = 180; + let bestG = 100; + let bestB = 50; + for (let i = 0; i < data.length; i += 4) { + const r = data[i]; + const g = data[i + 1]; + const b = data[i + 2]; + const [, s] = rgbToHsl(r, g, b); + if (s > bestSat) { + bestSat = s; + bestR = r; + bestG = g; + bestB = b; + } + } + + const [fr, fg, fb] = ensureContrast([bestR, bestG, bestB], FS_BG_LUMINANCE, MIN_CONTRAST); + return { accent: `rgb(${fr},${fg},${fb})` }; +} + +function loadImage(url: string, crossOrigin: '' | 'anonymous'): Promise { + return new Promise((resolve, reject) => { + const img = new Image(); + img.onload = () => resolve(img); + img.onerror = () => reject(new Error('image load failed')); + if (crossOrigin) img.crossOrigin = crossOrigin; + img.src = url; + }); +} + /** * Loads `imageUrl` into an 8×8 canvas and finds the most vibrant pixel * (highest HSL saturation). Applies `ensureContrast` to guarantee * WCAG AA readability against the FS player background. * + * Remote `https?://` URLs would taint a canvas if drawn from a plain `` + * without CORS — we prefer `fetch` → `blob:` for sampling, then fall back to + * `crossOrigin = "anonymous"` when the server allows it. + * * Resolves with `{ accent: '' }` on any error — the caller's CSS * `var(--dynamic-fs-accent, var(--accent))` then falls back to the theme accent. */ -export function extractCoverColors(imageUrl: string): Promise { - if (!imageUrl) return Promise.resolve({ accent: '' }); - // Logo fallback has no meaningful color — skip extraction and use theme accent - if (imageUrl.includes('logo-psysonic')) return Promise.resolve({ accent: '' }); +export async function extractCoverColors(imageUrl: string): Promise { + if (!imageUrl) return { accent: '' }; + if (imageUrl.includes('logo-psysonic')) return { accent: '' }; - return new Promise(resolve => { - const img = new Image(); - // Blob URLs are same-origin in Tauri WebKit — no crossOrigin needed. - img.onload = () => { + const safeSample = async (url: string, co: '' | 'anonymous'): Promise => { + try { + const img = await loadImage(url, co); try { - const canvas = document.createElement('canvas'); - canvas.width = 8; - canvas.height = 8; - const ctx = canvas.getContext('2d'); - if (!ctx) { resolve({ accent: '' }); return; } - - ctx.drawImage(img, 0, 0, 8, 8); - const { data } = ctx.getImageData(0, 0, 8, 8); - - // Pick pixel with highest HSL saturation (most vibrant). - let bestSat = -1; - let bestR = 180, bestG = 100, bestB = 50; // warm orange fallback - for (let i = 0; i < data.length; i += 4) { - const r = data[i], g = data[i + 1], b = data[i + 2]; - const [, s] = rgbToHsl(r, g, b); - if (s > bestSat) { bestSat = s; bestR = r; bestG = g; bestB = b; } - } - - const [fr, fg, fb] = ensureContrast([bestR, bestG, bestB], FS_BG_LUMINANCE, MIN_CONTRAST); - resolve({ accent: `rgb(${fr},${fg},${fb})` }); + return sampleImageToAccent(img); } catch { - resolve({ accent: '' }); + return { accent: '' }; } - }; - img.onerror = () => resolve({ accent: '' }); - img.src = imageUrl; - }); + } catch { + return { accent: '' }; + } + }; + + if (isBlobOrDataUrl(imageUrl)) { + return safeSample(imageUrl, ''); + } + + if (isRemoteHttpUrl(imageUrl)) { + try { + const resp = await fetch(imageUrl); + if (resp.ok) { + const blob = await resp.blob(); + const objectUrl = URL.createObjectURL(blob); + try { + return await safeSample(objectUrl, ''); + } finally { + URL.revokeObjectURL(objectUrl); + } + } + } catch { + // CORS / network — try credentialed image load if server sends ACAO for art + } + return safeSample(imageUrl, 'anonymous'); + } + + return safeSample(imageUrl, ''); } diff --git a/src/utils/imageCache.ts b/src/utils/imageCache.ts index 0b9ed528..9ba8df34 100644 --- a/src/utils/imageCache.ts +++ b/src/utils/imageCache.ts @@ -3,8 +3,19 @@ import { useAuthStore } from '../store/authStore'; const DB_NAME = 'psysonic-img-cache'; const STORE_NAME = 'images'; const MAX_AGE_MS = 30 * 24 * 60 * 60 * 1000; // 30 days -const MAX_BLOB_CACHE = 200; // hot in-memory blob entries (LRU) -const MAX_CONCURRENT_FETCHES = 5; +/** In-memory blobs — scrolling large grids used to thrash at 200 and re-hit IndexedDB for “cold” keys that still had a live shared object URL. */ +const MAX_BLOB_CACHE = 600; // hot in-memory blob entries (LRU) +/** Network-only pool — IndexedDB hits must not queue behind remote fetches. */ +const MAX_CONCURRENT_NET_FETCHES = 6; + +type LoadWaiter = { + getPriority: () => number; + resolve: (granted: boolean) => void; +}; +const loadWaiters: LoadWaiter[] = []; + +/** One in-flight read per logical image — avoids duplicate IndexedDB transactions when many cells mount together. */ +const inflightBlobGets = new Map>(); // In-memory blob cache: cacheKey → Blob (insertion-order = LRU approximation). // Only the Map entry is dropped on overflow — the underlying Blob is freed by @@ -32,21 +43,34 @@ function purgeUrlEntry(cacheKey: string): void { * Returns a shared object URL for the cached blob of `cacheKey`, or null if * not currently in memory. Pair every successful call with releaseUrl(). * Subsequent acquires reuse the same URL and just bump the refcount. + * + * IMPORTANT: the Blob can be LRU-evicted from `blobCache` while `urlEntries` + * still holds a valid object URL (another `` still references it). We + * must reuse that URL — otherwise callers fall through to IndexedDB / network + * again and scrolling janks even when data was already resolved once. */ export function acquireUrl(cacheKey: string): string | null { const blob = blobCache.get(cacheKey); - if (!blob) return null; - rememberBlob(cacheKey, blob); // refresh LRU position - let entry = urlEntries.get(cacheKey); - if (!entry) { - entry = { url: URL.createObjectURL(blob), refs: 0, revokeTimer: null }; - urlEntries.set(cacheKey, entry); - } else if (entry.revokeTimer) { - clearTimeout(entry.revokeTimer); - entry.revokeTimer = null; + if (blob) { + rememberBlob(cacheKey, blob); // refresh LRU position } - entry.refs++; - return entry.url; + + const entry = urlEntries.get(cacheKey); + if (entry) { + if (entry.revokeTimer) { + clearTimeout(entry.revokeTimer); + entry.revokeTimer = null; + } + entry.refs++; + return entry.url; + } + + if (!blob) return null; + + const newEntry: UrlEntry = { url: URL.createObjectURL(blob), refs: 0, revokeTimer: null }; + urlEntries.set(cacheKey, newEntry); + newEntry.refs++; + return newEntry.url; } /** Decrements the refcount; revokes (after grace delay) when it reaches zero. */ @@ -61,34 +85,72 @@ export function releaseUrl(cacheKey: string): void { }, URL_REVOKE_DELAY_MS); } -let activeFetches = 0; -const fetchQueue: Array<() => void> = []; +let activeNetFetches = 0; -function acquireFetchSlot(signal?: AbortSignal): Promise { +function removeLoadWaiter(waiter: LoadWaiter): void { + const i = loadWaiters.indexOf(waiter); + if (i !== -1) loadWaiters.splice(i, 1); +} + +/** + * Slot for remote `fetch` only. IndexedDB reads run before this — cached disk + * art can render without waiting on in-flight network downloads. + */ +function acquireNetFetchSlot(signal?: AbortSignal, getPriority?: () => number): Promise { if (signal?.aborted) return Promise.resolve(false); - if (activeFetches < MAX_CONCURRENT_FETCHES) { - activeFetches++; + if (activeNetFetches < MAX_CONCURRENT_NET_FETCHES) { + activeNetFetches++; return Promise.resolve(true); } return new Promise(resolve => { - const onGrant = () => { - signal?.removeEventListener('abort', onAbort); - resolve(true); - }; + let waiter: LoadWaiter; const onAbort = () => { - const idx = fetchQueue.indexOf(onGrant); - if (idx !== -1) fetchQueue.splice(idx, 1); + signal?.removeEventListener('abort', onAbort); + removeLoadWaiter(waiter); resolve(false); }; - fetchQueue.push(onGrant); + waiter = { + getPriority: getPriority ?? (() => 0), + resolve: (granted: boolean) => { + signal?.removeEventListener('abort', onAbort); + resolve(granted); + }, + }; + loadWaiters.push(waiter); signal?.addEventListener('abort', onAbort, { once: true }); }); } -function releaseFetchSlot(): void { - activeFetches--; - const next = fetchQueue.shift(); - if (next) { activeFetches++; next(); } +function pickHighestPriorityWaiterIndex(): number { + if (loadWaiters.length === 0) return -1; + let best = 0; + let bestP = safePriority(loadWaiters[0].getPriority); + for (let i = 1; i < loadWaiters.length; i++) { + const p = safePriority(loadWaiters[i].getPriority); + if (p > bestP) { + bestP = p; + best = i; + } + } + return best; +} + +function safePriority(fn: () => number): number { + try { + return fn(); + } catch { + return 0; + } +} + +function releaseNetFetchSlot(): void { + activeNetFetches = Math.max(0, activeNetFetches - 1); + if (activeNetFetches >= MAX_CONCURRENT_NET_FETCHES) return; + const idx = pickHighestPriorityWaiterIndex(); + if (idx === -1) return; + const [w] = loadWaiters.splice(idx, 1); + activeNetFetches++; + w.resolve(true); } function rememberBlob(key: string, blob: Blob): void { @@ -175,6 +237,19 @@ async function evictDiskIfNeeded(maxBytes: number): Promise { } } +/** Batched eviction — avoids `getAll()` on every cover write during fast scrolling. */ +let evictDebounceTimer: ReturnType | null = null; +let evictPendingMaxBytes = 0; + +function scheduleEvictDiskIfNeeded(maxBytes: number): void { + evictPendingMaxBytes = maxBytes; + if (evictDebounceTimer) clearTimeout(evictDebounceTimer); + evictDebounceTimer = setTimeout(() => { + evictDebounceTimer = null; + void evictDiskIfNeeded(evictPendingMaxBytes); + }, 450); +} + async function putBlob(key: string, blob: Blob): Promise { try { const database = await openDB(); @@ -185,7 +260,7 @@ async function putBlob(key: string, blob: Blob): Promise { tx.onerror = () => resolve(); }); const maxBytes = useAuthStore.getState().maxCacheMb * 1024 * 1024; - evictDiskIfNeeded(maxBytes); + scheduleEvictDiskIfNeeded(maxBytes); } catch { // Ignore write errors } @@ -210,6 +285,7 @@ export async function getImageCacheSize(): Promise { export async function invalidateCacheKey(cacheKey: string): Promise { blobCache.delete(cacheKey); purgeUrlEntry(cacheKey); + inflightBlobGets.delete(cacheKey); try { const database = await openDB(); await new Promise(resolve => { @@ -230,7 +306,12 @@ export async function invalidateCoverArt(entityId: string): Promise { } export async function clearImageCache(): Promise { + if (evictDebounceTimer) { + clearTimeout(evictDebounceTimer); + evictDebounceTimer = null; + } blobCache.clear(); + inflightBlobGets.clear(); for (const key of Array.from(urlEntries.keys())) purgeUrlEntry(key); try { const database = await openDB(); @@ -253,8 +334,14 @@ export async function clearImageCache(): Promise { * @param fetchUrl The actual URL to fetch from (may contain ephemeral auth params). * @param cacheKey A stable key that identifies the image across sessions. * @param signal Optional AbortSignal — aborts queue-waiting and in-flight fetches. + * @param getPriority Called when waiting for a **network** slot (IndexedDB hits skip this queue). */ -export async function getCachedBlob(fetchUrl: string, cacheKey: string, signal?: AbortSignal): Promise { +export async function getCachedBlob( + fetchUrl: string, + cacheKey: string, + signal?: AbortSignal, + getPriority?: () => number, +): Promise { if (!fetchUrl || signal?.aborted) return null; const memHit = blobCache.get(cacheKey); @@ -263,29 +350,40 @@ export async function getCachedBlob(fetchUrl: string, cacheKey: string, signal?: return memHit; } - const idbHit = await getBlobFromIDB(cacheKey); - if (signal?.aborted) return null; - if (idbHit) { - rememberBlob(cacheKey, idbHit); - return idbHit; - } + const existing = inflightBlobGets.get(cacheKey); + if (existing) return existing; - const acquired = await acquireFetchSlot(signal); - if (!acquired || signal?.aborted) { - if (acquired) releaseFetchSlot(); - return null; - } - try { - const resp = await fetch(fetchUrl, { signal }); - if (!resp.ok) return null; - const newBlob = await resp.blob(); + const run = (async () => { if (signal?.aborted) return null; - putBlob(cacheKey, newBlob); // fire-and-forget - rememberBlob(cacheKey, newBlob); - return newBlob; - } catch { - return null; - } finally { - releaseFetchSlot(); - } + + const idbHit = await getBlobFromIDB(cacheKey); + if (signal?.aborted) return null; + if (idbHit) { + rememberBlob(cacheKey, idbHit); + return idbHit; + } + + const acquired = await acquireNetFetchSlot(signal, getPriority); + if (!acquired || signal?.aborted) { + if (acquired) releaseNetFetchSlot(); + return null; + } + try { + const resp = await fetch(fetchUrl, { signal }); + if (!resp.ok) return null; + const newBlob = await resp.blob(); + if (signal?.aborted) return null; + putBlob(cacheKey, newBlob); // fire-and-forget + rememberBlob(cacheKey, newBlob); + return newBlob; + } catch { + return null; + } finally { + releaseNetFetchSlot(); + } + })(); + + inflightBlobGets.set(cacheKey, run); + run.finally(() => inflightBlobGets.delete(cacheKey)); + return run; }
{emptyText}
{t('artistDetail.noAlbums')}