From c96eb0a805962f8717ccc1674a4572220a413767 Mon Sep 17 00:00:00 2001 From: Psychotoxical Date: Sat, 18 Apr 2026 22:24:14 +0200 Subject: [PATCH] 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) --- src/locales/de.ts | 3 + src/locales/en.ts | 3 + src/locales/es.ts | 3 + src/locales/fr.ts | 3 + src/locales/nb.ts | 3 + src/locales/nl.ts | 3 + src/locales/ru.ts | 5 ++ src/locales/zh.ts | 3 + src/pages/Favorites.tsx | 155 +++++++++++++++++++++++++++++++++++++++- 9 files changed, 180 insertions(+), 1 deletion(-) diff --git a/src/locales/de.ts b/src/locales/de.ts index 0aa17d3a..44ac6989 100644 --- a/src/locales/de.ts +++ b/src/locales/de.ts @@ -218,6 +218,9 @@ export const deTranslation = { clearArtistFilter: 'Künstlerfilter zurücksetzen', noFilterResults: 'Keine Ergebnisse mit den ausgewählten Filtern.', allArtists: 'Alle Künstler', + topArtists: 'Top-Künstler nach Favoriten', + topArtistsSongCount_one: '{{count}} Song', + topArtistsSongCount_other: '{{count}} Songs', }, randomLanding: { title: 'Mix erstellen', diff --git a/src/locales/en.ts b/src/locales/en.ts index 36f5825d..305a5895 100644 --- a/src/locales/en.ts +++ b/src/locales/en.ts @@ -219,6 +219,9 @@ export const enTranslation = { clearArtistFilter: 'Clear artist filter', noFilterResults: 'No results with selected filters.', allArtists: 'All Artists', + topArtists: 'Top Artists by Favorites', + topArtistsSongCount_one: '{{count}} song', + topArtistsSongCount_other: '{{count}} songs', }, randomLanding: { title: 'Build a Mix', diff --git a/src/locales/es.ts b/src/locales/es.ts index 8ef28c9f..927837fd 100644 --- a/src/locales/es.ts +++ b/src/locales/es.ts @@ -219,6 +219,9 @@ export const esTranslation = { clearArtistFilter: 'Limpiar filtro de artista', noFilterResults: 'No hay resultados con los filtros seleccionados.', allArtists: 'Todos los Artistas', + topArtists: 'Artistas favoritos principales', + topArtistsSongCount_one: '{{count}} canción', + topArtistsSongCount_other: '{{count}} canciones', }, randomLanding: { title: 'Crear Mezcla', diff --git a/src/locales/fr.ts b/src/locales/fr.ts index a76b77a1..28e4c256 100644 --- a/src/locales/fr.ts +++ b/src/locales/fr.ts @@ -218,6 +218,9 @@ export const frTranslation = { clearArtistFilter: 'Effacer le filtre artiste', noFilterResults: 'Aucun résultat avec les filtres sélectionnés.', allArtists: 'Tous les artistes', + topArtists: 'Artistes favoris principaux', + topArtistsSongCount_one: '{{count}} morceau', + topArtistsSongCount_other: '{{count}} morceaux', }, randomLanding: { title: 'Créer un mix', diff --git a/src/locales/nb.ts b/src/locales/nb.ts index 9341e03c..605aa327 100644 --- a/src/locales/nb.ts +++ b/src/locales/nb.ts @@ -218,6 +218,9 @@ export const nbTranslation = { clearArtistFilter: 'Tøm artistfilter', noFilterResults: 'Ingen resultater med valgte filtre.', allArtists: 'Alle artister', + topArtists: 'Toppartister etter favoritter', + topArtistsSongCount_one: '{{count}} sang', + topArtistsSongCount_other: '{{count}} sanger', }, randomLanding: { title: 'Lag en miks', diff --git a/src/locales/nl.ts b/src/locales/nl.ts index 9bbdf0e0..abddd629 100644 --- a/src/locales/nl.ts +++ b/src/locales/nl.ts @@ -217,6 +217,9 @@ export const nlTranslation = { clearArtistFilter: 'Artiestfilter wissen', noFilterResults: 'Geen resultaten met de geselecteerde filters.', allArtists: 'Alle artiesten', + topArtists: 'Top-artiesten op favorieten', + topArtistsSongCount_one: '{{count}} nummer', + topArtistsSongCount_other: '{{count}} nummers', }, randomLanding: { title: 'Mix samenstellen', diff --git a/src/locales/ru.ts b/src/locales/ru.ts index 8edc7cf3..ad1ddeac 100644 --- a/src/locales/ru.ts +++ b/src/locales/ru.ts @@ -222,6 +222,11 @@ export const ruTranslation = { clearArtistFilter: 'Сбросить фильтр исполнителя', noFilterResults: 'Нет результатов с выбранными фильтрами.', allArtists: 'Все исполнители', + topArtists: 'Топ исполнителей по избранному', + topArtistsSongCount_one: '{{count}} трек', + topArtistsSongCount_few: '{{count}} трека', + topArtistsSongCount_many: '{{count}} треков', + topArtistsSongCount_other: '{{count}} трека', }, randomLanding: { title: 'Собрать микс', diff --git a/src/locales/zh.ts b/src/locales/zh.ts index dc8865c6..edadac0f 100644 --- a/src/locales/zh.ts +++ b/src/locales/zh.ts @@ -217,6 +217,9 @@ export const zhTranslation = { clearArtistFilter: '清除艺术家筛选', noFilterResults: '所选筛选条件下无结果。', allArtists: '全部艺术家', + topArtists: '按收藏数排行的艺术家', + topArtistsSongCount_one: '{{count}} 首歌曲', + topArtistsSongCount_other: '{{count}} 首歌曲', }, randomLanding: { title: '创建混音', diff --git a/src/pages/Favorites.tsx b/src/pages/Favorites.tsx index ca708f32..3a088378 100644 --- a/src/pages/Favorites.tsx +++ b/src/pages/Favorites.tsx @@ -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(); + 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 && ( + setSelectedArtist(prev => prev === key ? null : key)} + /> + )} + {(visibleSongs.length > 0 || selectedArtist || selectedGenres.length > 0 || yearRange[0] !== MIN_YEAR || yearRange[1] !== CURRENT_YEAR) && (
{/* ── Section Header with Stats & Filters ───────────────────────── */} @@ -656,6 +690,11 @@ export default function Favorites() { )} ); + case 'genre': return ( +
+ {song.genre ?? '—'} +
+ ); case 'format': return (
{(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(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 ( +
+
+

{title}

+
+ + +
+
+ +
+
+ {artists.map(a => ( + onToggle(a.id)} + songCountLabel={t('favorites.topArtistsSongCount', { count: a.count })} + /> + ))} +
+
+
+ ); +} + +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 ( +
+
+ {coverId ? ( + { + e.currentTarget.style.display = 'none'; + e.currentTarget.parentElement?.classList.add('fallback-visible'); + }} + /> + ) : ( + + )} +
+
+ {artist.name} + {songCountLabel} +
+
+ ); +} + // ── Radio Station Row ───────────────────────────────────────────────────────── interface RadioStationRowProps {