Files
Psychotoxical-psysonic/src/components/AlbumRow.tsx
T
cucadmuh 9d30285ff1 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.
2026-05-06 00:15:58 +03:00

198 lines
7.1 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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;
titleLink?: string;
albums: SubsonicAlbum[];
moreLink?: string;
moreText?: string;
onLoadMore?: () => Promise<void>;
showRating?: boolean;
/** Optional content rendered in the row header, left of the scroll-nav. */
headerExtra?: React.ReactNode;
disableArtwork?: boolean;
disableInteractivity?: boolean;
artworkSize?: number;
windowArtworkByViewport?: boolean;
initialArtworkBudget?: number;
}
export default function AlbumRow({
title,
titleLink,
albums,
moreLink,
moreText,
onLoadMore,
showRating,
headerExtra,
disableArtwork = false,
disableInteractivity = false,
artworkSize,
windowArtworkByViewport = false,
initialArtworkBudget = 8,
}: Props) {
const perfFlags = usePerfProbeFlags();
const artworkDisabled = perfFlags.disableMainstageRailArtwork || disableArtwork;
const interactivityDisabled = perfFlags.disableMainstageRailInteractivity || disableInteractivity;
const { t } = useTranslation();
const scrollRef = useRef<HTMLDivElement>(null);
const navigate = useNavigate();
const [showLeft, setShowLeft] = useState(false);
const [showRight, setShowRight] = useState(true);
const [loadingMore, setLoadingMore] = useState(false);
const [artworkBudget, setArtworkBudget] = useState(initialArtworkBudget);
const loadingRef = useRef(false);
const uniqueAlbums = useMemo(() => dedupeById(albums), [albums]);
const recomputeArtworkBudget = () => {
if (!windowArtworkByViewport) return;
const el = scrollRef.current;
if (!el) return;
const { scrollLeft, clientWidth } = el;
const firstCard = el.querySelector<HTMLElement>('.album-card, .artist-card');
const cardW = firstCard?.clientWidth || firstCard?.getBoundingClientRect().width || 170;
const gridStyles = window.getComputedStyle(el);
const gap = Number.parseFloat(gridStyles.columnGap || gridStyles.gap || '16') || 16;
const step = Math.max(1, cardW + gap);
const visibleCount = Math.ceil((scrollLeft + clientWidth) / step);
// 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 (windowArtworkByViewport) recomputeArtworkBudget();
if (!scrollRef.current) return;
const { scrollLeft, scrollWidth, clientWidth } = scrollRef.current;
if (!interactivityDisabled) {
setShowLeft(scrollLeft > 0);
setShowRight(scrollLeft < scrollWidth - clientWidth - 5);
}
// Auto-load trigger (native horizontal scroll still works when rail buttons are perf-disabled)
if (onLoadMore && !loadingRef.current && scrollLeft > 0 && scrollLeft + clientWidth >= scrollWidth - 300) {
triggerLoadMore();
}
};
const triggerLoadMore = async () => {
if (!onLoadMore || loadingRef.current) return;
loadingRef.current = true;
setLoadingMore(true);
await onLoadMore();
setLoadingMore(false);
loadingRef.current = false;
};
useEffect(() => {
handleScroll();
const raf = window.requestAnimationFrame(() => {
if (windowArtworkByViewport) recomputeArtworkBudget();
});
window.addEventListener('resize', handleScroll);
const ro = new ResizeObserver(() => {
if (windowArtworkByViewport) recomputeArtworkBudget();
});
if (scrollRef.current) ro.observe(scrollRef.current);
return () => {
window.cancelAnimationFrame(raf);
window.removeEventListener('resize', handleScroll);
ro.disconnect();
};
}, [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, rowArtworkResetKey]);
const scroll = (dir: 'left' | 'right') => {
if (!scrollRef.current) return;
const amount = scrollRef.current.clientWidth * 0.75;
scrollRef.current.scrollBy({ left: dir === 'left' ? -amount : amount, behavior: 'smooth' });
};
if (uniqueAlbums.length === 0) return null;
return (
<section className="album-row-section">
<div className="album-row-header">
{titleLink ? (
<NavLink to={titleLink} className="section-title-link" style={{ marginBottom: 0 }}>
{title}<ChevronRight size={18} className="section-title-chevron" />
</NavLink>
) : (
<h2 className="section-title" style={{ marginBottom: 0 }}>{title}</h2>
)}
<div className="album-row-nav">
{headerExtra}
{!interactivityDisabled && (
<>
<button
className={`nav-btn ${!showLeft ? 'disabled' : ''}`}
onClick={() => scroll('left')}
disabled={!showLeft}
>
<ChevronLeft size={20} />
</button>
<button
className={`nav-btn ${!showRight ? 'disabled' : ''}`}
onClick={() => scroll('right')}
disabled={!showRight}
>
<ChevronRight size={20} />
</button>
</>
)}
</div>
</div>
<div className="album-grid-wrapper">
<div className="album-grid" ref={scrollRef} onScroll={handleScroll}>
{uniqueAlbums.map((a, idx) => (
<AlbumCard
key={a.id}
album={a}
showRating={showRating}
disableArtwork={
artworkDisabled ||
(windowArtworkByViewport && idx >= artworkBudget)
}
artworkSize={artworkSize}
/>
))}
{loadingMore && (
<div className="album-card-more" style={{ cursor: 'default' }}>
<div style={{ padding: '1rem', background: 'var(--bg-app)', borderRadius: '50%' }}>
<div className="spinner" style={{ width: 24, height: 24 }} />
</div>
<span style={{ fontSize: 13, fontWeight: 500 }}>{t('common.loadingMore')}</span>
</div>
)}
{!loadingMore && moreLink && (
<div className="album-card-more" onClick={() => navigate(moreLink)}>
<div style={{ padding: '1rem', background: 'var(--bg-app)', borderRadius: '50%' }}>
<ArrowRight size={24} />
</div>
<span style={{ fontSize: 13, fontWeight: 500 }}>{moreText}</span>
</div>
)}
</div>
</div>
</section>
);
}