feat(most-played): add toggle to filter compilation artists

Toggle button in the Top Artists section header hides known compilation
artist names (Various Artists, VA, Soundtracks, OST, etc.) from the
ranking. Default off. Active state shown in accent color.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Psychotoxical
2026-04-07 22:20:35 +02:00
parent 7b4eeea0dc
commit 4182b67732
4 changed files with 64 additions and 5 deletions
+3
View File
@@ -791,6 +791,9 @@ export const deTranslation = {
sortLeast: 'Wenigste Plays zuerst', sortLeast: 'Wenigste Plays zuerst',
loadMore: 'Mehr Alben laden', loadMore: 'Mehr Alben laden',
noData: 'Noch keine Wiedergabedaten. Fang an zu hören!', noData: 'Noch keine Wiedergabedaten. Fang an zu hören!',
noArtists: 'Alle Künstler herausgefiltert.',
filterCompilations: 'Sampler-Künstler ausblenden (Various Artists, Soundtracks usw.)',
filterCompilationsShort: 'Sampler ausblenden',
}, },
radio: { radio: {
title: 'Internetradio', title: 'Internetradio',
+3
View File
@@ -792,6 +792,9 @@ export const enTranslation = {
sortLeast: 'Fewest plays first', sortLeast: 'Fewest plays first',
loadMore: 'Load more albums', loadMore: 'Load more albums',
noData: 'No play data yet. Start listening!', noData: 'No play data yet. Start listening!',
noArtists: 'All artists filtered out.',
filterCompilations: 'Hide compilation artists (Various Artists, Soundtracks, etc.)',
filterCompilationsShort: 'Hide compilations',
}, },
radio: { radio: {
title: 'Internet Radio', title: 'Internet Radio',
+33 -5
View File
@@ -1,6 +1,6 @@
import React, { useEffect, useState, useCallback } from 'react'; import React, { useEffect, useState, useCallback } from 'react';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { ArrowUpDown, ArrowDown, ArrowUp, TrendingUp } from 'lucide-react'; import { ArrowUpDown, ArrowDown, ArrowUp, TrendingUp, UsersRound } from 'lucide-react';
import { getAlbumList, SubsonicAlbum, buildCoverArtUrl, coverArtCacheKey } from '../api/subsonic'; import { getAlbumList, SubsonicAlbum, buildCoverArtUrl, coverArtCacheKey } from '../api/subsonic';
import { useAuthStore } from '../store/authStore'; import { useAuthStore } from '../store/authStore';
import CachedImage from '../components/CachedImage'; import CachedImage from '../components/CachedImage';
@@ -16,11 +16,24 @@ interface ArtistEntry {
totalPlays: number; totalPlays: number;
} }
function deriveTopArtists(albums: SubsonicAlbum[]): ArtistEntry[] { const COMPILATION_NAMES = new Set([
'various artists', 'various', 'va', 'v.a.', 'v.a',
'diverse artister', 'diversos artistas', 'artistes variés',
'vários artistas', 'verschiedene künstler', 'verscheidene artiesten',
'compilations', 'soundtrack', 'original soundtrack', 'ost',
'original motion picture soundtrack', 'original score',
]);
function isCompilation(name: string): boolean {
return COMPILATION_NAMES.has(name.toLowerCase().trim());
}
function deriveTopArtists(albums: SubsonicAlbum[], filterCompilations: boolean): ArtistEntry[] {
const map = new Map<string, ArtistEntry>(); const map = new Map<string, ArtistEntry>();
for (const a of albums) { for (const a of albums) {
const plays = a.playCount ?? 0; const plays = a.playCount ?? 0;
if (plays === 0) continue; if (plays === 0) continue;
if (filterCompilations && isCompilation(a.artist ?? '')) continue;
const entry = map.get(a.artistId); const entry = map.get(a.artistId);
if (entry) { if (entry) {
entry.totalPlays += plays; entry.totalPlays += plays;
@@ -46,8 +59,9 @@ export default function MostPlayed() {
const [loadingMore, setLoadingMore] = useState(false); const [loadingMore, setLoadingMore] = useState(false);
const [hasMore, setHasMore] = useState(true); const [hasMore, setHasMore] = useState(true);
const [sortAsc, setSortAsc] = useState(false); // false = most plays first const [sortAsc, setSortAsc] = useState(false); // false = most plays first
const [filterCompilations, setFilterCompilations] = useState(false);
const topArtists = deriveTopArtists(albums).slice(0, 10); const topArtists = deriveTopArtists(albums, filterCompilations).slice(0, 10);
const load = useCallback(async () => { const load = useCallback(async () => {
setLoading(true); setLoading(true);
@@ -96,9 +110,23 @@ export default function MostPlayed() {
</div> </div>
{/* ── Top Artists ── */} {/* ── Top Artists ── */}
{!loading && topArtists.length > 0 && ( {!loading && (
<section className="mp-section"> <section className="mp-section">
<h2 className="mp-section-title">{t('mostPlayed.topArtists')}</h2> <div className="mp-section-header">
<h2 className="mp-section-title">{t('mostPlayed.topArtists')}</h2>
<button
className={`btn btn-ghost mp-filter-btn${filterCompilations ? ' mp-filter-btn--active' : ''}`}
onClick={() => setFilterCompilations(v => !v)}
data-tooltip={t('mostPlayed.filterCompilations')}
data-tooltip-pos="left"
>
<UsersRound size={14} />
{t('mostPlayed.filterCompilationsShort')}
</button>
</div>
{topArtists.length === 0 && (
<div className="empty-state" style={{ padding: '12px 0' }}>{t('mostPlayed.noArtists')}</div>
)}
<div className="mp-artist-grid"> <div className="mp-artist-grid">
{topArtists.map((artist, i) => ( {topArtists.map((artist, i) => (
<button <button
+25
View File
@@ -6417,12 +6417,37 @@
margin-bottom: 2.5rem; margin-bottom: 2.5rem;
} }
.mp-section-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 0.75rem;
}
.mp-filter-btn {
display: flex;
align-items: center;
gap: 5px;
font-size: 12px;
padding: 4px 10px;
opacity: 0.6;
}
.mp-filter-btn:hover { opacity: 1; }
.mp-filter-btn--active {
opacity: 1;
color: var(--accent);
border-color: var(--accent);
}
.mp-section-title { .mp-section-title {
font-size: 15px; font-size: 15px;
font-weight: 600; font-weight: 600;
color: var(--text-muted); color: var(--text-muted);
text-transform: uppercase; text-transform: uppercase;
letter-spacing: 0.06em; letter-spacing: 0.06em;
margin-bottom: 0;
margin: 0 0 1rem; margin: 0 0 1rem;
} }