mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-21 23:05:46 +00:00
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:
+27
-87
@@ -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 { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
Headphones, Zap, Music2, Music, Cpu, Mic, Radio, Cloud,
|
||||
Leaf, Heart, Sun, Flame, Film, Globe, BookOpen, Podcast, Star,
|
||||
Tags, type LucideIcon,
|
||||
} from 'lucide-react';
|
||||
import { Tags } from 'lucide-react';
|
||||
import { getGenres, SubsonicGenre } from '../api/subsonic';
|
||||
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 = [
|
||||
'var(--ctp-rosewater)', 'var(--ctp-flamingo)', 'var(--ctp-pink)', 'var(--ctp-mauve)',
|
||||
'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 VISIBLE_KEY = 'genres-visible';
|
||||
const PAGE_SIZE = 60;
|
||||
const FONT_MIN_REM = 0.78;
|
||||
const FONT_MAX_REM = 1.7;
|
||||
|
||||
export default function Genres() {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const [rawGenres, setRawGenres] = useState<SubsonicGenre[]>([]);
|
||||
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(() => {
|
||||
getGenres()
|
||||
.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;
|
||||
// Log-scale font sizing — flattens the long tail (a 1000-album genre and a
|
||||
// 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(() => {
|
||||
if (loading || genres.length === 0) return;
|
||||
const saved = sessionStorage.getItem(SCROLL_KEY);
|
||||
if (!saved) return;
|
||||
const pos = parseInt(saved, 10);
|
||||
sessionStorage.removeItem(SCROLL_KEY);
|
||||
sessionStorage.removeItem(VISIBLE_KEY);
|
||||
requestAnimationFrame(() => {
|
||||
const el = document.getElementById(APP_MAIN_SCROLL_VIEWPORT_ID);
|
||||
if (el) el.scrollTop = pos;
|
||||
@@ -110,10 +60,7 @@ export default function Genres() {
|
||||
|
||||
const handleGenreClick = (genreValue: string) => {
|
||||
const el = document.getElementById(APP_MAIN_SCROLL_VIEWPORT_ID);
|
||||
if (el) {
|
||||
sessionStorage.setItem(SCROLL_KEY, String(el.scrollTop));
|
||||
sessionStorage.setItem(VISIBLE_KEY, String(visibleCount));
|
||||
}
|
||||
if (el) sessionStorage.setItem(SCROLL_KEY, String(el.scrollTop));
|
||||
navigate(`/genres/${encodeURIComponent(genreValue)}`);
|
||||
};
|
||||
|
||||
@@ -132,36 +79,29 @@ export default function Genres() {
|
||||
{loading && <p className="loading-text">{t('genres.loading')}</p>}
|
||||
{!loading && genres.length === 0 && <p className="loading-text">{t('genres.empty')}</p>}
|
||||
|
||||
{!loading && visible.length > 0 && (
|
||||
<>
|
||||
<div className="album-grid-wrap">
|
||||
{visible.map(genre => {
|
||||
const Icon = getGenreIcon(genre.value);
|
||||
{!loading && genres.length > 0 && (
|
||||
<div className="genre-cloud">
|
||||
{genres.map(genre => {
|
||||
const ratio = Math.log(Math.max(2, genre.albumCount)) / maxLog;
|
||||
const fontRem = FONT_MIN_REM + ratio * (FONT_MAX_REM - FONT_MIN_REM);
|
||||
const color = genreColor(genre.value);
|
||||
return (
|
||||
<div
|
||||
<button
|
||||
key={genre.value}
|
||||
className="genre-card"
|
||||
style={{ '--genre-color': color } as React.CSSProperties}
|
||||
type="button"
|
||||
className="genre-pill"
|
||||
style={{
|
||||
'--genre-color': color,
|
||||
fontSize: `${fontRem.toFixed(3)}rem`,
|
||||
} as React.CSSProperties}
|
||||
onClick={() => handleGenreClick(genre.value)}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onKeyDown={e => e.key === 'Enter' && handleGenreClick(genre.value)}
|
||||
data-tooltip={genre.value}
|
||||
data-tooltip={t('genres.albumCount', { count: genre.albumCount })}
|
||||
>
|
||||
<div className="genre-card-watermark">
|
||||
<Icon size={80} strokeWidth={1.2} />
|
||||
</div>
|
||||
<p className="genre-card-name">{genre.value}</p>
|
||||
<p className="genre-card-count">
|
||||
{t('genres.albumCount', { count: genre.albumCount })}
|
||||
</p>
|
||||
</div>
|
||||
{genre.value}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{hasMore && <div ref={observerTarget} style={{ height: 1 }} />}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
+29
-43
@@ -7144,55 +7144,41 @@ html.no-compositing .fs-seekbar-played {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
/* ─ Genre Cards ─ */
|
||||
.genre-card {
|
||||
aspect-ratio: 1;
|
||||
border-radius: 12px;
|
||||
padding: 0.9rem;
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
/* ─ Genre Tag Cloud ─ */
|
||||
.genre-cloud {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: flex-end;
|
||||
background:
|
||||
linear-gradient(to top, rgba(0,0,0,0.55) 0%, rgba(0,0,0,0.15) 45%, transparent 70%),
|
||||
var(--genre-color, var(--accent));
|
||||
transition: transform 0.15s ease, box-shadow 0.15s ease;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 0.55rem 0.6rem;
|
||||
padding-bottom: 1rem;
|
||||
}
|
||||
|
||||
.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;
|
||||
font-family: inherit;
|
||||
transition: background 0.12s ease, border-color 0.12s ease, color 0.12s ease, transform 0.12s ease;
|
||||
}
|
||||
|
||||
.genre-card:hover {
|
||||
transform: translateY(-3px) scale(1.02);
|
||||
box-shadow: 0 8px 28px rgba(0, 0, 0, 0.32);
|
||||
.genre-pill:hover {
|
||||
background: color-mix(in srgb, var(--genre-color, var(--accent)) 14%, transparent);
|
||||
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 {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
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;
|
||||
.genre-pill:focus-visible {
|
||||
outline: 2px solid var(--genre-color, var(--accent));
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
/* ─ Random Landing ─ */
|
||||
|
||||
Reference in New Issue
Block a user