mirror of
https://github.com/kilyabin/psysonic.git
synced 2026-07-22 06:25:41 +00:00
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:
+101
-13
@@ -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
@@ -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
@@ -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
@@ -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) {
|
||||
|
||||
@@ -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" />
|
||||
)}
|
||||
|
||||
@@ -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
@@ -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
@@ -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=""
|
||||
/>
|
||||
) : (
|
||||
|
||||
Reference in New Issue
Block a user