feat(favorites): add genre column + Top Favorite Artists row

Genre column (toggleable via column picker) and a horizontally scrolling
Top Favorite Artists section between Radio Stations and Songs, aggregated
from starred tracks. Closes #87.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Psychotoxical
2026-04-18 22:24:14 +02:00
parent 66c0ecbc1f
commit c96eb0a805
9 changed files with 180 additions and 1 deletions
+154 -1
View File
@@ -10,7 +10,7 @@ import {
} from '../api/subsonic';
import { usePlayerStore, songToTrack } from '../store/playerStore';
import StarRating from '../components/StarRating';
import { Cast, ChevronDown, ChevronLeft, ChevronRight, Check, Heart, ListPlus, Play, Star, X, SlidersHorizontal, ArrowUp, ArrowDown, RotateCcw } from 'lucide-react';
import { Cast, ChevronDown, ChevronLeft, ChevronRight, Check, Heart, ListPlus, Play, Star, Users, X, SlidersHorizontal, ArrowUp, ArrowDown, RotateCcw } from 'lucide-react';
import { useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { unstar } from '../api/subsonic';
@@ -25,6 +25,7 @@ const FAV_COLUMNS: readonly ColDef[] = [
{ key: 'title', i18nKey: 'trackTitle', minWidth: 150, defaultWidth: 0, required: true, flex: true },
{ key: 'artist', i18nKey: 'trackArtist', minWidth: 80, defaultWidth: 180, required: false },
{ key: 'album', i18nKey: 'trackAlbum', minWidth: 80, defaultWidth: 180, required: false },
{ key: 'genre', i18nKey: 'trackGenre', minWidth: 60, defaultWidth: 120, required: false },
{ key: 'rating', i18nKey: 'trackRating', minWidth: 80, defaultWidth: 120, required: false },
{ key: 'duration', i18nKey: 'trackDuration', minWidth: 72, defaultWidth: 92, required: false },
{ key: 'format', i18nKey: 'trackFormat', minWidth: 60, defaultWidth: 80, required: false },
@@ -205,6 +206,30 @@ export default function Favorites() {
loadAll();
}, [musicLibraryFilterVersion]);
// ── Top Favorite Artists aggregated from favorited songs ─────────────
const topFavoriteArtists = useMemo(() => {
const counts = new Map<string, { id: string; name: string; count: number; coverArtId: string }>();
for (const s of songs) {
if (starredOverrides[s.id] === false) continue;
const key = s.artistId || s.artist;
if (!key) continue;
const existing = counts.get(key);
if (existing) {
existing.count += 1;
} else {
counts.set(key, {
id: key,
name: s.artist || key,
count: 1,
coverArtId: s.artistId || '',
});
}
}
return Array.from(counts.values())
.sort((a, b) => b.count - a.count)
.slice(0, 12);
}, [songs, starredOverrides]);
// ── Filter & sort logic ──────────────────────────────────────────────────
const filteredSongs = useMemo(() => {
return songs.filter(s => {
@@ -309,6 +334,15 @@ export default function Favorites() {
/>
)}
{topFavoriteArtists.length >= 2 && (
<TopFavoriteArtistsRow
title={t('favorites.topArtists')}
artists={topFavoriteArtists}
selectedKey={selectedArtist}
onToggle={key => setSelectedArtist(prev => prev === key ? null : key)}
/>
)}
{(visibleSongs.length > 0 || selectedArtist || selectedGenres.length > 0 || yearRange[0] !== MIN_YEAR || yearRange[1] !== CURRENT_YEAR) && (
<section className="album-row-section">
{/* ── Section Header with Stats & Filters ───────────────────────── */}
@@ -656,6 +690,11 @@ export default function Favorites() {
)}
</div>
);
case 'genre': return (
<div key="genre" className="track-genre">
{song.genre ?? '—'}
</div>
);
case 'format': return (
<div key="format" className="track-meta">
{(song.suffix || song.bitRate) && (
@@ -709,6 +748,120 @@ export default function Favorites() {
);
}
// ── Top Favorite Artists Row ──────────────────────────────────────────────────
interface TopFavoriteArtist {
id: string;
name: string;
count: number;
coverArtId: string;
}
interface TopFavoriteArtistsRowProps {
title: string;
artists: TopFavoriteArtist[];
selectedKey: string | null;
onToggle: (key: string) => void;
}
function TopFavoriteArtistsRow({ title, artists, selectedKey, onToggle }: TopFavoriteArtistsRowProps) {
const { t } = useTranslation();
const scrollRef = useRef<HTMLDivElement>(null);
const [showLeft, setShowLeft] = useState(false);
const [showRight, setShowRight] = useState(true);
const handleScroll = () => {
if (!scrollRef.current) return;
const { scrollLeft, scrollWidth, clientWidth } = scrollRef.current;
setShowLeft(scrollLeft > 0);
setShowRight(scrollLeft < scrollWidth - clientWidth - 5);
};
useEffect(() => {
handleScroll();
window.addEventListener('resize', handleScroll);
return () => window.removeEventListener('resize', handleScroll);
}, [artists]);
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' });
};
return (
<section className="album-row-section">
<div className="album-row-header">
<h2 className="section-title" style={{ marginBottom: 0 }}>{title}</h2>
<div className="album-row-nav">
<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}>
{artists.map(a => (
<TopFavoriteArtistCard
key={a.id}
artist={a}
isSelected={selectedKey === a.id}
onClick={() => onToggle(a.id)}
songCountLabel={t('favorites.topArtistsSongCount', { count: a.count })}
/>
))}
</div>
</div>
</section>
);
}
interface TopFavoriteArtistCardProps {
artist: TopFavoriteArtist;
isSelected: boolean;
onClick: () => void;
songCountLabel: string;
}
function TopFavoriteArtistCard({ artist, isSelected, onClick, songCountLabel }: TopFavoriteArtistCardProps) {
const coverId = artist.coverArtId;
const coverSrc = useMemo(() => coverId ? buildCoverArtUrl(coverId, 300) : '', [coverId]);
const coverCacheKey = useMemo(() => coverId ? coverArtCacheKey(coverId, 300) : '', [coverId]);
return (
<div
className={`artist-card${isSelected ? ' artist-card-selected' : ''}`}
onClick={onClick}
style={isSelected ? { outline: '2px solid var(--accent)', outlineOffset: '-2px', borderRadius: 12 } : undefined}
>
<div className="artist-card-avatar">
{coverId ? (
<CachedImage
src={coverSrc}
cacheKey={coverCacheKey}
alt={artist.name}
loading="lazy"
onError={(e) => {
e.currentTarget.style.display = 'none';
e.currentTarget.parentElement?.classList.add('fallback-visible');
}}
/>
) : (
<Users size={32} color="var(--text-muted)" />
)}
</div>
<div className="artist-card-info">
<span className="artist-card-name">{artist.name}</span>
<span className="artist-card-meta">{songCountLabel}</span>
</div>
</div>
);
}
// ── Radio Station Row ─────────────────────────────────────────────────────────
interface RadioStationRowProps {