From d31291a463586072dbcb501ab74ba9324f99aa13 Mon Sep 17 00:00:00 2001 From: Psychotoxical <171614930+Psychotoxical@users.noreply.github.com> Date: Sat, 25 Apr 2026 23:57:44 +0200 Subject: [PATCH] perf(genres): replace icon cards with tag-cloud pills MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 -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) --- src/pages/Genres.tsx | 130 ++++++++++---------------------------- src/styles/components.css | 72 +++++++++------------ 2 files changed, 64 insertions(+), 138 deletions(-) diff --git a/src/pages/Genres.tsx b/src/pages/Genres.tsx index 66b44e72..99f1656c 100644 --- a/src/pages/Genres.tsx +++ b/src/pages/Genres.tsx @@ -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([]); 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(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 &&

{t('genres.loading')}

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

{t('genres.empty')}

} - {!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 })} -

-
- ); - })} -
- {hasMore &&
} - + {!loading && genres.length > 0 && ( +
+ {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 ( + + ); + })} +
)}
); diff --git a/src/styles/components.css b/src/styles/components.css index 8d8368b6..9a9026c8 100644 --- a/src/styles/components.css +++ b/src/styles/components.css @@ -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 ─ */