diff --git a/src/pages/Genres.tsx b/src/pages/Genres.tsx index cd153c20..bb058229 100644 --- a/src/pages/Genres.tsx +++ b/src/pages/Genres.tsx @@ -1,4 +1,4 @@ -import React, { useEffect, useRef, useState } from 'react'; +import React, { useEffect, useMemo, useRef, useState } from 'react'; import { useNavigate } from 'react-router-dom'; import { useTranslation } from 'react-i18next'; import { @@ -45,23 +45,52 @@ function genreColor(name: string): string { } const SCROLL_KEY = 'genres-scroll'; +const VISIBLE_KEY = 'genres-visible'; +const PAGE_SIZE = 60; export default function Genres() { const { t } = useTranslation(); const navigate = useNavigate(); - const [genres, setGenres] = useState([]); + const [rawGenres, setRawGenres] = useState([]); const [loading, setLoading] = useState(true); + const [visibleCount, setVisibleCount] = useState(() => { + // Restore the previous visibleCount when navigating back from a detail + // page so scroll position lines up with rendered cards. + const saved = sessionStorage.getItem(VISIBLE_KEY); + return saved ? Math.max(PAGE_SIZE, parseInt(saved, 10)) : PAGE_SIZE; + }); const containerRef = useRef(null); + const observerTarget = useRef(null); useEffect(() => { getGenres() - .then(data => { - const sorted = [...data].sort((a, b) => b.albumCount - a.albumCount); - setGenres(sorted); - }) + .then(data => setRawGenres(data)) .finally(() => setLoading(false)); }, []); // getGenres is not folder-scoped — no dep on musicLibraryFilterVersion + // Memoised sort — without this the page re-sorted 500+ entries on every + // unrelated re-render (e.g. theme change, sidebar toggle). + const genres = useMemo( + () => [...rawGenres].sort((a, b) => b.albumCount - a.albumCount), + [rawGenres], + ); + + const visible = useMemo(() => genres.slice(0, visibleCount), [genres, visibleCount]); + const hasMore = visibleCount < genres.length; + + // Infinite scroll — render the next batch when the user is ~1.5 screens + // away from the sentinel, so the rest of the watermarks never block first + // paint of the page. + useEffect(() => { + if (!hasMore) return; + const observer = new IntersectionObserver( + entries => { if (entries[0].isIntersecting) setVisibleCount(c => c + PAGE_SIZE); }, + { rootMargin: '1500px' }, + ); + if (observerTarget.current) observer.observe(observerTarget.current); + return () => observer.disconnect(); + }, [hasMore]); + // Restore scroll position after genres are rendered useEffect(() => { if (loading || genres.length === 0) return; @@ -69,6 +98,7 @@ export default function Genres() { if (!saved) return; const pos = parseInt(saved, 10); sessionStorage.removeItem(SCROLL_KEY); + sessionStorage.removeItem(VISIBLE_KEY); requestAnimationFrame(() => { if (containerRef.current) containerRef.current.scrollTop = pos; }); @@ -77,6 +107,7 @@ export default function Genres() { const handleGenreClick = (genreValue: string) => { if (containerRef.current) { sessionStorage.setItem(SCROLL_KEY, String(containerRef.current.scrollTop)); + sessionStorage.setItem(VISIBLE_KEY, String(visibleCount)); } navigate(`/genres/${encodeURIComponent(genreValue)}`); }; @@ -96,33 +127,36 @@ export default function Genres() { {loading &&

{t('genres.loading')}

} {!loading && genres.length === 0 &&

{t('genres.empty')}

} - {!loading && genres.length > 0 && ( -
- {genres.map(genre => { - const Icon = getGenreIcon(genre.value); - const color = genreColor(genre.value); - return ( -
handleGenreClick(genre.value)} - role="button" - tabIndex={0} - onKeyDown={e => e.key === 'Enter' && handleGenreClick(genre.value)} - data-tooltip={genre.value} - > -
- + {!loading && visible.length > 0 && ( + <> +
+ {visible.map(genre => { + const Icon = getGenreIcon(genre.value); + const color = genreColor(genre.value); + return ( +
handleGenreClick(genre.value)} + role="button" + tabIndex={0} + onKeyDown={e => e.key === 'Enter' && handleGenreClick(genre.value)} + data-tooltip={genre.value} + > +
+ +
+

{genre.value}

+

+ {t('genres.albumCount', { count: genre.albumCount })} +

-

{genre.value}

-

- {t('genres.albumCount', { count: genre.albumCount })} -

-
- ); - })} -
+ ); + })} +
+ {hasMore &&
} + )}
);