perf(genres): replace icon cards with tag-cloud pills

The previous genre grid mounted ~60 Lucide SVG cards per page (with
Watermark icon, gradient bg, infinite scroll) and froze the WebKitGTK
renderer for several seconds on libraries with many genres.

The new layout flows all genres as compact pills with log-scaled font
size based on albumCount — one <span>-equivalent button per genre, no
SVGs, no pagination needed. Pill colour is dimly tinted by the same
deterministic hash-to-CTP palette used before; text picks up the genre
colour on hover only, so the page reads calmly at rest.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Psychotoxical
2026-04-25 23:57:44 +02:00
parent 9306f0af2c
commit d31291a463
2 changed files with 64 additions and 138 deletions
+35 -95
View File
@@ -1,36 +1,10 @@
import React, { useEffect, useMemo, useRef, useState } from 'react'; import { useEffect, useMemo, useState } from 'react';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { import { Tags } from 'lucide-react';
Headphones, Zap, Music2, Music, Cpu, Mic, Radio, Cloud,
Leaf, Heart, Sun, Flame, Film, Globe, BookOpen, Podcast, Star,
Tags, type LucideIcon,
} from 'lucide-react';
import { getGenres, SubsonicGenre } from '../api/subsonic'; import { getGenres, SubsonicGenre } from '../api/subsonic';
import { APP_MAIN_SCROLL_VIEWPORT_ID } from '../constants/appScroll'; import { APP_MAIN_SCROLL_VIEWPORT_ID } from '../constants/appScroll';
function getGenreIcon(name: string): LucideIcon {
const n = name.toLowerCase();
if (/ambient|drone|new age/.test(n)) return Cloud;
if (/metal|hardcore|thrash|death|grind|doom/.test(n)) return Zap;
if (/rock/.test(n)) return Radio;
if (/jazz/.test(n)) return Music2;
if (/classical|orchestra|chamber|baroque|opera|symphon/.test(n)) return Music;
if (/electronic|techno|edm|house|trance|electro|synth/.test(n)) return Cpu;
if (/hip.?hop|rap/.test(n)) return Mic;
if (/pop/.test(n)) return Star;
if (/folk|country|bluegrass|americana/.test(n)) return Leaf;
if (/blues/.test(n)) return Music2;
if (/soul|r.?b|funk|gospel/.test(n)) return Heart;
if (/reggae|ska|dub/.test(n)) return Sun;
if (/punk/.test(n)) return Flame;
if (/soundtrack|score|ost|film|movie|cinema/.test(n)) return Film;
if (/world|latin|afro|celtic|tribal|traditional/.test(n)) return Globe;
if (/audiobook|spoken|hörbuch|speech|comedy/.test(n)) return BookOpen;
if (/podcast/.test(n)) return Podcast;
return Headphones;
}
const CTP_COLORS = [ const CTP_COLORS = [
'var(--ctp-rosewater)', 'var(--ctp-flamingo)', 'var(--ctp-pink)', 'var(--ctp-mauve)', 'var(--ctp-rosewater)', 'var(--ctp-flamingo)', 'var(--ctp-pink)', 'var(--ctp-mauve)',
'var(--ctp-red)', 'var(--ctp-maroon)', 'var(--ctp-peach)', 'var(--ctp-yellow)', 'var(--ctp-red)', 'var(--ctp-maroon)', 'var(--ctp-peach)', 'var(--ctp-yellow)',
@@ -45,63 +19,39 @@ function genreColor(name: string): string {
} }
const SCROLL_KEY = 'genres-scroll'; const SCROLL_KEY = 'genres-scroll';
const VISIBLE_KEY = 'genres-visible'; const FONT_MIN_REM = 0.78;
const PAGE_SIZE = 60; const FONT_MAX_REM = 1.7;
export default function Genres() { export default function Genres() {
const { t } = useTranslation(); const { t } = useTranslation();
const navigate = useNavigate(); const navigate = useNavigate();
const [rawGenres, setRawGenres] = useState<SubsonicGenre[]>([]); const [rawGenres, setRawGenres] = useState<SubsonicGenre[]>([]);
const [loading, setLoading] = useState(true); 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 observerTarget = useRef<HTMLDivElement>(null);
useEffect(() => { useEffect(() => {
getGenres() getGenres()
.then(data => setRawGenres(data)) .then(data => setRawGenres(data))
.finally(() => setLoading(false)); .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( const genres = useMemo(
() => [...rawGenres].sort((a, b) => b.albumCount - a.albumCount), () => [...rawGenres].sort((a, b) => b.albumCount - a.albumCount),
[rawGenres], [rawGenres],
); );
const visible = useMemo(() => genres.slice(0, visibleCount), [genres, visibleCount]); // Log-scale font sizing — flattens the long tail (a 1000-album genre and a
const hasMore = visibleCount < genres.length; // 50-album genre look distinct, but a 1-album genre still has a readable size).
const maxLog = useMemo(() => {
if (genres.length === 0) return 1;
return Math.log(Math.max(2, genres[0].albumCount));
}, [genres]);
// 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 root = document.getElementById(APP_MAIN_SCROLL_VIEWPORT_ID);
const observer = new IntersectionObserver(
entries => { if (entries[0].isIntersecting) setVisibleCount(c => c + PAGE_SIZE); },
{
root: root instanceof HTMLElement ? root : null,
rootMargin: '1500px',
},
);
if (observerTarget.current) observer.observe(observerTarget.current);
return () => observer.disconnect();
}, [hasMore]);
// Restore scroll position after genres are rendered
useEffect(() => { useEffect(() => {
if (loading || genres.length === 0) return; if (loading || genres.length === 0) return;
const saved = sessionStorage.getItem(SCROLL_KEY); const saved = sessionStorage.getItem(SCROLL_KEY);
if (!saved) return; if (!saved) return;
const pos = parseInt(saved, 10); const pos = parseInt(saved, 10);
sessionStorage.removeItem(SCROLL_KEY); sessionStorage.removeItem(SCROLL_KEY);
sessionStorage.removeItem(VISIBLE_KEY);
requestAnimationFrame(() => { requestAnimationFrame(() => {
const el = document.getElementById(APP_MAIN_SCROLL_VIEWPORT_ID); const el = document.getElementById(APP_MAIN_SCROLL_VIEWPORT_ID);
if (el) el.scrollTop = pos; if (el) el.scrollTop = pos;
@@ -110,10 +60,7 @@ export default function Genres() {
const handleGenreClick = (genreValue: string) => { const handleGenreClick = (genreValue: string) => {
const el = document.getElementById(APP_MAIN_SCROLL_VIEWPORT_ID); const el = document.getElementById(APP_MAIN_SCROLL_VIEWPORT_ID);
if (el) { if (el) sessionStorage.setItem(SCROLL_KEY, String(el.scrollTop));
sessionStorage.setItem(SCROLL_KEY, String(el.scrollTop));
sessionStorage.setItem(VISIBLE_KEY, String(visibleCount));
}
navigate(`/genres/${encodeURIComponent(genreValue)}`); navigate(`/genres/${encodeURIComponent(genreValue)}`);
}; };
@@ -132,36 +79,29 @@ export default function Genres() {
{loading && <p className="loading-text">{t('genres.loading')}</p>} {loading && <p className="loading-text">{t('genres.loading')}</p>}
{!loading && genres.length === 0 && <p className="loading-text">{t('genres.empty')}</p>} {!loading && genres.length === 0 && <p className="loading-text">{t('genres.empty')}</p>}
{!loading && visible.length > 0 && ( {!loading && genres.length > 0 && (
<> <div className="genre-cloud">
<div className="album-grid-wrap"> {genres.map(genre => {
{visible.map(genre => { const ratio = Math.log(Math.max(2, genre.albumCount)) / maxLog;
const Icon = getGenreIcon(genre.value); const fontRem = FONT_MIN_REM + ratio * (FONT_MAX_REM - FONT_MIN_REM);
const color = genreColor(genre.value); const color = genreColor(genre.value);
return ( return (
<div <button
key={genre.value} key={genre.value}
className="genre-card" type="button"
style={{ '--genre-color': color } as React.CSSProperties} className="genre-pill"
onClick={() => handleGenreClick(genre.value)} style={{
role="button" '--genre-color': color,
tabIndex={0} fontSize: `${fontRem.toFixed(3)}rem`,
onKeyDown={e => e.key === 'Enter' && handleGenreClick(genre.value)} } as React.CSSProperties}
data-tooltip={genre.value} onClick={() => handleGenreClick(genre.value)}
> data-tooltip={t('genres.albumCount', { count: genre.albumCount })}
<div className="genre-card-watermark"> >
<Icon size={80} strokeWidth={1.2} /> {genre.value}
</div> </button>
<p className="genre-card-name">{genre.value}</p> );
<p className="genre-card-count"> })}
{t('genres.albumCount', { count: genre.albumCount })} </div>
</p>
</div>
);
})}
</div>
{hasMore && <div ref={observerTarget} style={{ height: 1 }} />}
</>
)} )}
</div> </div>
); );
+29 -43
View File
@@ -7144,55 +7144,41 @@ html.no-compositing .fs-seekbar-played {
justify-content: flex-end; justify-content: flex-end;
} }
/* ─ Genre Cards ─ */ /* ─ Genre Tag Cloud ─ */
.genre-card { .genre-cloud {
aspect-ratio: 1;
border-radius: 12px;
padding: 0.9rem;
cursor: pointer;
position: relative;
overflow: hidden;
display: flex; display: flex;
flex-direction: column; flex-wrap: wrap;
justify-content: flex-end; align-items: center;
background: gap: 0.55rem 0.6rem;
linear-gradient(to top, rgba(0,0,0,0.55) 0%, rgba(0,0,0,0.15) 45%, transparent 70%), padding-bottom: 1rem;
var(--genre-color, var(--accent)); }
transition: transform 0.15s ease, box-shadow 0.15s ease;
.genre-pill {
appearance: none;
border: 1px solid color-mix(in srgb, var(--genre-color, var(--accent)) 22%, transparent);
background: color-mix(in srgb, var(--genre-color, var(--accent)) 6%, transparent);
color: var(--text-primary);
border-radius: 999px;
padding: 0.28em 0.78em;
cursor: pointer;
font-weight: 600;
line-height: 1.15;
white-space: nowrap;
user-select: none; user-select: none;
font-family: inherit;
transition: background 0.12s ease, border-color 0.12s ease, color 0.12s ease, transform 0.12s ease;
} }
.genre-card:hover { .genre-pill:hover {
transform: translateY(-3px) scale(1.02); background: color-mix(in srgb, var(--genre-color, var(--accent)) 14%, transparent);
box-shadow: 0 8px 28px rgba(0, 0, 0, 0.32); border-color: color-mix(in srgb, var(--genre-color, var(--accent)) 45%, transparent);
color: var(--genre-color, var(--accent));
transform: translateY(-1px);
} }
.genre-card-watermark { .genre-pill:focus-visible {
position: absolute; outline: 2px solid var(--genre-color, var(--accent));
top: 50%; outline-offset: 2px;
right: -10px;
transform: translateY(-55%);
opacity: 0.22;
color: #fff;
pointer-events: none;
}
.genre-card-name {
font-size: 0.86rem;
font-weight: 700;
color: #fff;
margin: 0;
line-height: 1.25;
overflow: hidden;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
}
.genre-card-count {
font-size: 0.72rem;
color: rgba(255, 255, 255, 0.85);
margin: 0.2rem 0 0;
} }
/* ─ Random Landing ─ */ /* ─ Random Landing ─ */