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
+101 -13
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,18 +353,66 @@ export default function Albums() {
) : (
<>
{!perfFlags.disableMainstageGridCards && (
<div className="album-grid-wrap">
{visibleAlbums.map(a => (
<AlbumCard
key={a.id}
album={a}
selectionMode={selectionMode}
selected={selectedIds.has(a.id)}
onToggleSelect={toggleSelect}
selectedAlbums={selectedAlbums}
/>
))}
</div>
perfFlags.disableMainstageVirtualLists ? (
<div className="album-grid-wrap">
{visibleAlbums.map(a => (
<AlbumCard
key={a.id}
album={a}
selectionMode={selectionMode}
selected={selectedIds.has(a.id)}
onToggleSelect={toggleSelect}
selectedAlbums={selectedAlbums}
/>
))}
</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' }}>
+61 -25
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`}
>
<CachedImage
key={coverRevision}
src={buildCoverArtUrl(coverId, 300)}
cacheKey={coverArtCacheKey(coverId, 300)}
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'; }}
/>
{!headerCoverFailed ? (
<CachedImage
key={coverRevision}
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={() => 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>
+197 -47
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,49 +377,129 @@ export default function Artists() {
)}
{!loading && viewMode === 'list' && (
<>
{letters.map(letter => (
<div key={letter} style={{ marginBottom: '1.5rem' }}>
<h3 className="letter-heading">{letter}</h3>
<div className="artist-list">
{groups[letter].map(artist => (
<button
key={artist.id}
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>
))}
perfFlags.disableMainstageVirtualLists ? (
<>
{letters.map(letter => (
<div key={letter} style={{ marginBottom: '1.5rem' }}>
<h3 className="letter-heading">{letter}</h3>
<div className="artist-list">
{groups[letter].map(artist => (
<button
key={artist.id}
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 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=""
/>
) : (