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.
This commit is contained in:
cucadmuh
2026-05-06 00:15:58 +03:00
committed by GitHub
parent d8d8a76e0f
commit 9d30285ff1
23 changed files with 895 additions and 279 deletions
+3
View File
@@ -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/
+9
View File
@@ -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
+1 -1
View File
@@ -97,7 +97,7 @@ function AlbumCard({
src={coverUrl}
cacheKey={coverCacheKey}
alt={`${album.name} Cover`}
loading="lazy"
loading="eager"
decoding="async"
/>
) : (
+7 -2
View File
@@ -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 && <BioModal bio={bio} onClose={onCloseBio} />}
{lightboxOpen && info.coverArt && (
<CoverLightbox
src={buildCoverArtUrl(info.coverArt, 2000)}
src={lightboxCoverSrc}
alt={`${info.name} Cover`}
onClose={() => setLightboxOpen(false)}
/>
+21 -14
View File
@@ -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 doesnt 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;
if (!interactivityDisabled) {
setShowLeft(scrollLeft > 0);
setShowRight(scrollLeft < scrollWidth - clientWidth - 5);
recomputeArtworkBudget();
}
// Auto-load trigger
// 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 rows 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 (
<section className="album-row-section">
@@ -154,8 +161,8 @@ export default function AlbumRow({
</div>
<div className="album-grid-wrapper">
<div className="album-grid" ref={scrollRef} onScroll={interactivityDisabled ? undefined : handleScroll}>
{albums.map((a, idx) => (
<div className="album-grid" ref={scrollRef} onScroll={handleScroll}>
{uniqueAlbums.map((a, idx) => (
<AlbumCard
key={a.id}
album={a}
+82 -19
View File
@@ -1,4 +1,5 @@
import React, { useEffect, useRef, useState } from 'react';
import React, { useCallback, useEffect, useRef, useState } from 'react';
import { APP_MAIN_SCROLL_VIEWPORT_ID } from '../constants/appScroll';
import { acquireUrl, getCachedBlob, releaseUrl } from '../utils/imageCache';
interface CachedImageProps extends React.ImgHTMLAttributes<HTMLImageElement> {
@@ -17,7 +18,19 @@ interface CachedImageProps extends React.ImgHTMLAttributes<HTMLImageElement> {
* 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 <img> 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 <img src> 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<string | null>(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<string | undefined>(undefined);
const imgRef = useRef<HTMLImageElement>(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 <img> 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<HTMLImageElement>) => {
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%' }
: {};
+8 -7
View File
@@ -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 <CachedImage className="search-result-thumb" src={src} cacheKey={cacheKey} alt="" />;
}
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 ? (
<CachedImage
className="search-result-thumb"
src={buildCoverArtUrl(a.coverArt, 40)}
cacheKey={coverArtCacheKey(a.coverArt, 40)}
alt=""
/>
<LiveSearchAlbumThumb coverArt={a.coverArt} />
) : (
<div className="search-result-icon"><Disc3 size={14} /></div>
)}
+11 -3
View File
@@ -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() {
<div className="mini-player__art">
{track?.coverArt ? (
<CachedImage
src={buildCoverArtUrl(track.coverArt, 300)}
cacheKey={coverArtCacheKey(track.coverArt, 300)}
src={miniCoverSrc}
cacheKey={miniCoverKey}
alt={track.album}
/>
) : (
+1 -1
View File
@@ -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"
/>
) : (
+18 -14
View File
@@ -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<HTMLDivElement>(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;
if (!interactivityDisabled) {
setShowLeft(scrollLeft > 0);
setShowRight(scrollLeft < scrollWidth - clientWidth - 5);
recomputeArtworkBudget();
}
};
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 (
<section className="song-row-section">
@@ -135,11 +139,11 @@ export default function SongRail({
</div>
<div className="song-grid-wrapper">
{songs.length === 0 && emptyText ? (
{uniqueSongs.length === 0 && emptyText ? (
<p className="song-row-empty">{emptyText}</p>
) : (
<div className="song-grid" ref={scrollRef} onScroll={interactivityDisabled ? undefined : handleScroll}>
{songs.map((s, idx) => (
<div className="song-grid" ref={scrollRef} onScroll={handleScroll}>
{uniqueSongs.map((s, idx) => (
<SongCard
key={s.id}
song={s}
+4 -1
View File
@@ -1,4 +1,5 @@
import React, { useCallback, useEffect, useRef, useState } from 'react';
import { useRefElementClientHeight } from '../hooks/useResizeClientHeight';
import { Search as SearchIcon, X } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { useVirtualizer } from '@tanstack/react-virtual';
@@ -43,6 +44,8 @@ export default function VirtualSongList({ title, emptyBrowseText }: Props) {
const [browseUnsupported, setBrowseUnsupported] = useState(false);
const scrollParentRef = useRef<HTMLDivElement>(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();
+39
View File
@@ -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<HTMLElement | null>,
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;
}
+89 -1
View File
@@ -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<HTMLDivElement>(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,6 +353,7 @@ export default function Albums() {
) : (
<>
{!perfFlags.disableMainstageGridCards && (
perfFlags.disableMainstageVirtualLists ? (
<div className="album-grid-wrap">
{visibleAlbums.map(a => (
<AlbumCard
@@ -325,6 +366,53 @@ export default function Albums() {
/>
))}
</div>
) : (
<div
ref={albumGridWrapRef}
className="album-grid-wrap"
style={{ display: 'block', position: 'relative', width: '100%' }}
>
<div
style={{
height: albumVirtualRowCount === 0 ? 0 : albumGridVirtualizer.getTotalSize(),
width: '100%',
position: 'relative',
}}
>
{albumGridVirtualizer.getVirtualItems().map(vRow => {
const start = vRow.index * albumGridCols;
const rowAlbums = visibleAlbums.slice(start, start + albumGridCols);
return (
<div
key={vRow.key}
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
transform: `translateY(${vRow.start}px)`,
display: 'grid',
gridTemplateColumns: `repeat(${albumGridCols}, minmax(0, 1fr))`,
gap: 'var(--space-4)',
alignItems: 'start',
}}
>
{rowAlbums.map(a => (
<AlbumCard
key={a.id}
album={a}
selectionMode={selectionMode}
selected={selectedIds.has(a.id)}
onToggleSelect={toggleSelect}
selectedAlbums={selectedAlbums}
/>
))}
</div>
);
})}
</div>
</div>
)
)}
{!genreFiltered && (
<div ref={observerTarget} style={{ height: '20px', margin: '2rem 0', display: 'flex', justifyContent: 'center' }}>
+55 -19
View File
@@ -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 (
<CachedImage
src={src}
cacheKey={cacheKey}
alt={album}
style={{ width: '32px', height: '32px', borderRadius: '4px', objectFit: 'cover', flexShrink: 0 }}
onError={(e) => { (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<HTMLInputElement>(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 (
<div className="content-body" style={{ display: 'flex', justifyContent: 'center', padding: '4rem' }}>
@@ -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 && (
<CoverLightbox
src={buildCoverArtUrl(coverId, 2000)}
src={artistCover2000Src}
alt={artist.name}
onClose={() => setLightboxOpen(false)}
/>
@@ -510,15 +548,19 @@ export default function ArtistDetail() {
onClick={() => setLightboxOpen(true)}
aria-label={`${artist.name} Bild vergrößern`}
>
{!headerCoverFailed ? (
<CachedImage
key={coverRevision}
src={buildCoverArtUrl(coverId, 300)}
cacheKey={coverArtCacheKey(coverId, 300)}
src={artistCover300Src}
cacheKey={artistCover300Key}
alt={artist.name}
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
onLoad={e => extractCoverColors(e.currentTarget.src).then(({ accent }) => { if (accent) setAvatarGlow(accent); })}
onError={e => { (e.currentTarget as HTMLImageElement).style.display = 'none'; }}
onError={() => setHeaderCoverFailed(true)}
/>
) : (
<Users size={64} color="var(--text-muted)" style={{ margin: 'auto', display: 'block' }} />
)}
</button>
) : (
<Users size={64} color="var(--text-muted)" />
@@ -667,7 +709,7 @@ export default function ArtistDetail() {
<div className="np-artist-bio-row">
{(info?.largeImageUrl || coverId) && (
<img
src={info?.largeImageUrl || buildCoverArtUrl(coverId, 80)}
src={info?.largeImageUrl || artistCover80FallbackSrc}
alt={artist.name}
className="np-artist-thumb"
onError={e => { (e.target as HTMLImageElement).style.display = 'none'; }}
@@ -702,7 +744,7 @@ export default function ArtistDetail() {
const track = songToTrack(song);
return (
<div
key={song.id}
key={`${song.id}-${idx}`}
className="track-row track-row-with-actions"
style={{ gridTemplateColumns: '60px minmax(150px, 1fr) minmax(100px, 1fr) 65px' }}
onClick={e => {
@@ -752,13 +794,7 @@ export default function ArtistDetail() {
: <ChevronRight size={14} className="playlist-suggestion-preview-icon playlist-suggestion-preview-icon-play" />}
</button>
{song.coverArt && (
<CachedImage
src={buildCoverArtUrl(song.coverArt, 64)}
cacheKey={coverArtCacheKey(song.coverArt, 64)}
alt={song.album}
style={{ width: '32px', height: '32px', borderRadius: '4px', objectFit: 'cover', flexShrink: 0 }}
onError={(e) => { (e.currentTarget as HTMLImageElement).style.display = 'none'; }}
/>
<ArtistSuggestionTrackCover coverArt={song.coverArt} album={song.album} />
)}
<div style={{ display: 'flex', flexDirection: 'column', minWidth: 0 }}>
<div className="track-title">{song.title}</div>
@@ -802,9 +838,9 @@ export default function ArtistDetail() {
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.5rem' }}>
{(showAudiomuseSimilar ? serverSimilarArtists : similarArtists)
.slice(0, isMobile && similarCollapsed ? 5 : undefined)
.map(a => (
.map((a, i) => (
<button
key={a.id}
key={`${a.id}-${i}`}
className="artist-ext-link"
onClick={() => navigate(`/artist/${a.id}`)}
>
@@ -823,7 +859,7 @@ export default function ArtistDetail() {
</h2>
{albums.length > 0 ? (
<div className="album-grid-wrap album-grid-wrap--artist">
{albums.map(a => <AlbumCard key={a.id} album={a} />)}
{albums.map((a, i) => <AlbumCard key={`${a.id}-${i}`} album={a} />)}
</div>
) : (
<p style={{ color: 'var(--text-muted)' }}>{t('artistDetail.noAlbums')}</p>
@@ -844,7 +880,7 @@ export default function ArtistDetail() {
</div>
) : (
<div className="album-grid-wrap album-grid-wrap--artist" style={{ animation: 'fadeIn 0.3s ease' }}>
{featuredAlbums.map(a => <AlbumCard key={a.id} album={a} />)}
{featuredAlbums.map((a, i) => <AlbumCard key={`${a.id}-${i}`} album={a} />)}
</div>
)}
</Fragment>
+156 -6
View File
@@ -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 (
<div className="artist-card-avatar">
<CachedImage
src={buildCoverArtUrl(artist.coverArt, 300)}
cacheKey={coverArtCacheKey(artist.coverArt, 300)}
src={coverSrc}
cacheKey={coverKey}
alt={artist.name}
/>
</div>
@@ -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 (
<div className="artist-avatar">
<CachedImage
src={buildCoverArtUrl(artist.coverArt, 64)}
cacheKey={coverArtCacheKey(artist.coverArt, 64)}
src={coverSrc}
cacheKey={coverKey}
alt={artist.name}
style={{ width: '100%', height: '100%', objectFit: 'cover', borderRadius: '50%' }}
/>
@@ -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<SubsonicArtist[]>([]);
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 (
<div className="content-body animate-fade-in">
<div className="page-sticky-header">
@@ -307,6 +377,7 @@ export default function Artists() {
)}
{!loading && viewMode === 'list' && (
perfFlags.disableMainstageVirtualLists ? (
<>
{letters.map(letter => (
<div key={letter} style={{ marginBottom: '1.5rem' }}>
@@ -350,6 +421,85 @@ export default function Artists() {
</div>
))}
</>
) : (
<div style={{ position: 'relative', width: '100%' }}>
<div
style={{
height: artistListFlatRows.length === 0 ? 0 : artistListVirtualizer.getTotalSize(),
width: '100%',
position: 'relative',
}}
>
{artistListVirtualizer.getVirtualItems().map(vi => {
const row = artistListFlatRows[vi.index];
if (!row) return null;
if (row.kind === 'letter') {
return (
<div
key={vi.key}
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
transform: `translateY(${vi.start}px)`,
}}
>
<h3 className="letter-heading">{row.letter}</h3>
</div>
);
}
const artist = row.artist;
return (
<div
key={vi.key}
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
transform: `translateY(${vi.start}px)`,
paddingBottom: row.isLastInLetter ? '1.5rem' : undefined,
}}
>
<button
type="button"
className={`artist-row${selectionMode && selectedIds.has(artist.id) ? ' selected' : ''}`}
onClick={() => {
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)'
} : {}}
>
<ArtistRowAvatar artist={artist} showImages={showArtistImages} />
<div style={{ textAlign: 'left' }}>
<div className="artist-name">{artist.name}</div>
{artist.albumCount != null && (
<div className="artist-meta">{t('artists.albumCount', { count: artist.albumCount })}</div>
)}
</div>
</button>
</div>
);
})}
</div>
</div>
)
)}
{!loading && hasMore && (
+13 -10
View File
@@ -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<SubsonicSong[]>([]),
]);
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) {
+9 -13
View File
@@ -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<typeof import('react-i18next').use
return t('mostPlayed.plays', { n: n.toLocaleString() }) as string;
}
function MpCover80({ coverArt, alt, className }: { coverArt: string; alt: string; className: string }) {
const src = useMemo(() => buildCoverArtUrl(coverArt, 80), [coverArt]);
const cacheKey = useMemo(() => coverArtCacheKey(coverArt, 80), [coverArt]);
return <CachedImage src={src} cacheKey={cacheKey} alt={alt} className={className} />;
}
export default function MostPlayed() {
const { t } = useTranslation();
const navigate = useNavigate();
@@ -136,12 +142,7 @@ export default function MostPlayed() {
>
<span className="mp-rank">{i + 1}</span>
{artist.coverArt ? (
<CachedImage
src={buildCoverArtUrl(artist.coverArt, 80)}
cacheKey={coverArtCacheKey(artist.coverArt, 80)}
alt=""
className="mp-artist-avatar"
/>
<MpCover80 coverArt={artist.coverArt} alt="" className="mp-artist-avatar" />
) : (
<div className="mp-artist-avatar mp-artist-avatar--placeholder" />
)}
@@ -175,12 +176,7 @@ export default function MostPlayed() {
>
<span className="mp-album-rank">{sortAsc ? withPlays.length - i : i + 1}</span>
{album.coverArt ? (
<CachedImage
src={buildCoverArtUrl(album.coverArt, 80)}
cacheKey={coverArtCacheKey(album.coverArt, 80)}
alt=""
className="mp-album-cover"
/>
<MpCover80 coverArt={album.coverArt} alt="" className="mp-album-cover" />
) : (
<div className="mp-album-cover mp-album-cover--placeholder" />
)}
+7 -1
View File
@@ -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 <CachedImage src={src} cacheKey={cacheKey} alt="" className="playlist-search-thumb" />;
}
export default function PlaylistDetail() {
const { id } = useParams<{ id: string }>();
const { t } = useTranslation();
@@ -1408,7 +1414,7 @@ export default function PlaylistDetail() {
return next;
})}
/>
<CachedImage src={buildCoverArtUrl(song.coverArt ?? '', 40)} cacheKey={coverArtCacheKey(song.coverArt ?? '', 40)} alt="" className="playlist-search-thumb" />
<PlaylistSearchResultThumb coverArt={song.coverArt ?? ''} />
<div className="playlist-search-info">
<span className="playlist-search-title">{song.title}</span>
<span className="playlist-search-artist">{song.artist} · <span className="playlist-search-album">{song.album}</span></span>
+29 -14
View File
@@ -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 (
<CachedImage
className="playlist-cover-cell"
src={src}
cacheKey={cacheKey}
alt=""
/>
);
}
function PlaylistCardMainCover({ coverArt, alt }: { coverArt: string; alt: string }) {
const src = useMemo(() => buildCoverArtUrl(coverArt, 256), [coverArt]);
const cacheKey = useMemo(() => coverArtCacheKey(coverArt, 256), [coverArt]);
return (
<CachedImage
src={src}
cacheKey={cacheKey}
alt={alt}
className="album-card-cover-img"
/>
);
}
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 ? (
<CachedImage
key={i}
className="playlist-cover-cell"
src={buildCoverArtUrl(id, 200)}
cacheKey={coverArtCacheKey(id, 200)}
alt=""
/>
<PlaylistSmartCoverCell key={i} coverId={id} />
) : (
<div key={i} className="playlist-cover-cell playlist-cover-cell--empty" />
);
})}
</div>
) : pl.coverArt ? (
<CachedImage
src={buildCoverArtUrl(pl.coverArt, 256)}
cacheKey={coverArtCacheKey(pl.coverArt, 256)}
alt={pl.name}
className="album-card-cover-img"
/>
<PlaylistCardMainCover coverArt={pl.coverArt} alt={pl.name} />
) : (
<div className="album-card-cover-placeholder playlist-card-icon">
<ListMusic size={48} strokeWidth={1.2} />
+10 -3
View File
@@ -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 ? (
<CachedImage
src={heroCoverUrl}
cacheKey={coverArtCacheKey(hero.coverArt!, 600)}
cacheKey={heroCoverKey}
alt=""
/>
) : (
+15
View File
@@ -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<T extends { id: string }>(items: T[]): T[] {
const seen = new Set<string>();
const out: T[] = [];
for (const item of items) {
if (seen.has(item.id)) continue;
seen.add(item.id);
out.push(item);
}
return out;
}
+92 -32
View File
@@ -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<HTMLImageElement> {
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 `<img>`
* 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<CoverColors> {
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<CoverColors> {
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<CoverColors> => {
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})` });
const img = await loadImage(url, co);
try {
return sampleImageToAccent(img);
} catch {
resolve({ accent: '' });
return { accent: '' };
}
} catch {
return { accent: '' };
}
};
img.onerror = () => resolve({ accent: '' });
img.src = imageUrl;
});
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, '');
}
+127 -29
View File
@@ -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<string, Promise<Blob | null>>();
// 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 `<img>` 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;
if (blob) {
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) {
}
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<boolean> {
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<boolean> {
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<boolean>(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<void> {
}
}
/** Batched eviction — avoids `getAll()` on every cover write during fast scrolling. */
let evictDebounceTimer: ReturnType<typeof setTimeout> | 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<void> {
try {
const database = await openDB();
@@ -185,7 +260,7 @@ async function putBlob(key: string, blob: Blob): Promise<void> {
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<number> {
export async function invalidateCacheKey(cacheKey: string): Promise<void> {
blobCache.delete(cacheKey);
purgeUrlEntry(cacheKey);
inflightBlobGets.delete(cacheKey);
try {
const database = await openDB();
await new Promise<void>(resolve => {
@@ -230,7 +306,12 @@ export async function invalidateCoverArt(entityId: string): Promise<void> {
}
export async function clearImageCache(): Promise<void> {
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<void> {
* @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<Blob | null> {
export async function getCachedBlob(
fetchUrl: string,
cacheKey: string,
signal?: AbortSignal,
getPriority?: () => number,
): Promise<Blob | null> {
if (!fetchUrl || signal?.aborted) return null;
const memHit = blobCache.get(cacheKey);
@@ -263,6 +350,12 @@ export async function getCachedBlob(fetchUrl: string, cacheKey: string, signal?:
return memHit;
}
const existing = inflightBlobGets.get(cacheKey);
if (existing) return existing;
const run = (async () => {
if (signal?.aborted) return null;
const idbHit = await getBlobFromIDB(cacheKey);
if (signal?.aborted) return null;
if (idbHit) {
@@ -270,9 +363,9 @@ export async function getCachedBlob(fetchUrl: string, cacheKey: string, signal?:
return idbHit;
}
const acquired = await acquireFetchSlot(signal);
const acquired = await acquireNetFetchSlot(signal, getPriority);
if (!acquired || signal?.aborted) {
if (acquired) releaseFetchSlot();
if (acquired) releaseNetFetchSlot();
return null;
}
try {
@@ -286,6 +379,11 @@ export async function getCachedBlob(fetchUrl: string, cacheKey: string, signal?:
} catch {
return null;
} finally {
releaseFetchSlot();
releaseNetFetchSlot();
}
})();
inflightBlobGets.set(cacheKey, run);
run.finally(() => inflightBlobGets.delete(cacheKey));
return run;
}