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')}