From 4b8b6ae797d96b2b7549030eb9dba7e2acaca54c Mon Sep 17 00:00:00 2001 From: Psychotoxical Date: Tue, 7 Apr 2026 20:00:07 +0200 Subject: [PATCH] feat(most-played): add dedicated Most Played page (#86) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New sidebar item (TrendingUp icon, /most-played route) with: - Top Artists section: derived by grouping frequent albums by artistId and summing playCount — no extra API calls needed. Shows up to 10 artists as a responsive grid with avatar, rank and total play count. - Top Albums section: paginated list using getAlbumList('frequent'), 50 albums per batch with a "Load more" button. Each row shows rank, cover thumbnail (44px), album name, artist (navigates to artist page), year and play count. - Sort toggle (most plays first / fewest plays first). Also adds playCount?: number to SubsonicAlbum — Navidrome returns this field in getAlbumList2?type=frequent but it was missing from the type. Closes #86 Co-Authored-By: Claude Sonnet 4.6 --- src/App.tsx | 2 + src/api/subsonic.ts | 1 + src/components/Sidebar.tsx | 11 +- src/locales/de.ts | 11 ++ src/locales/en.ts | 11 ++ src/locales/fr.ts | 11 ++ src/locales/nb.ts | 11 ++ src/locales/nl.ts | 11 ++ src/locales/ru.ts | 11 ++ src/locales/zh.ts | 11 ++ src/pages/MostPlayed.tsx | 189 ++++++++++++++++++++++++++++++++++ src/store/sidebarStore.ts | 1 + src/styles/components.css | 201 +++++++++++++++++++++++++++++++++++++ 13 files changed, 477 insertions(+), 5 deletions(-) create mode 100644 src/pages/MostPlayed.tsx diff --git a/src/App.tsx b/src/App.tsx index 8f0cae2d..c4b89f0e 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -26,6 +26,7 @@ import Login from './pages/Login'; import AlbumDetail from './pages/AlbumDetail'; import LabelAlbums from './pages/LabelAlbums'; import Statistics from './pages/Statistics'; +import MostPlayed from './pages/MostPlayed'; import Help from './pages/Help'; import RandomAlbums from './pages/RandomAlbums'; import SearchResults from './pages/SearchResults'; @@ -304,6 +305,7 @@ function AppShell() { } /> } /> } /> + } /> : } /> } /> } /> diff --git a/src/api/subsonic.ts b/src/api/subsonic.ts index fe53197e..e92f75a7 100644 --- a/src/api/subsonic.ts +++ b/src/api/subsonic.ts @@ -58,6 +58,7 @@ export interface SubsonicAlbum { coverArt?: string; songCount: number; duration: number; + playCount?: number; year?: number; genre?: string; starred?: string; diff --git a/src/components/Sidebar.tsx b/src/components/Sidebar.tsx index 77a7319d..a5b6faa0 100644 --- a/src/components/Sidebar.tsx +++ b/src/components/Sidebar.tsx @@ -9,7 +9,7 @@ import { useTranslation } from 'react-i18next'; import { Disc3, Users, Music4, Radio, Settings, Heart, BarChart3, Shuffle, PanelLeftClose, PanelLeft, HelpCircle, Dices, AudioLines, HardDriveDownload, Tags, ListMusic, Cast, - ChevronDown, Check, Music2, + ChevronDown, Check, Music2, TrendingUp, } from 'lucide-react'; import PsysonicLogo from './PsysonicLogo'; import PSmallLogo from './PSmallLogo'; @@ -24,10 +24,11 @@ export const ALL_NAV_ITEMS: Record(); + for (const a of albums) { + const plays = a.playCount ?? 0; + if (plays === 0) continue; + const entry = map.get(a.artistId); + if (entry) { + entry.totalPlays += plays; + if (!entry.coverArt && a.coverArt) entry.coverArt = a.coverArt; + } else { + map.set(a.artistId, { id: a.artistId, name: a.artist, coverArt: a.coverArt, totalPlays: plays }); + } + } + return [...map.values()].sort((a, b) => b.totalPlays - a.totalPlays); +} + +function formatPlays(n: number, t: ReturnType['t']): string { + return t('mostPlayed.plays', { n: n.toLocaleString() }) as string; +} + +export default function MostPlayed() { + const { t } = useTranslation(); + const navigate = useNavigate(); + const musicLibraryFilterVersion = useAuthStore(s => s.musicLibraryFilterVersion); + + const [albums, setAlbums] = useState([]); + const [loading, setLoading] = useState(true); + const [loadingMore, setLoadingMore] = useState(false); + const [hasMore, setHasMore] = useState(true); + const [sortAsc, setSortAsc] = useState(false); // false = most plays first + + const topArtists = deriveTopArtists(albums).slice(0, 10); + + const load = useCallback(async () => { + setLoading(true); + setAlbums([]); + setHasMore(true); + try { + const result = await getAlbumList('frequent', PAGE_SIZE, 0); + setAlbums(result); + setHasMore(result.length === PAGE_SIZE); + } catch {} + setLoading(false); + }, [musicLibraryFilterVersion]); + + useEffect(() => { load(); }, [load]); + + const loadMore = async () => { + if (loadingMore || !hasMore) return; + setLoadingMore(true); + try { + const result = await getAlbumList('frequent', PAGE_SIZE, albums.length); + setAlbums(prev => [...prev, ...result]); + setHasMore(result.length === PAGE_SIZE); + } catch {} + setLoadingMore(false); + }; + + const sorted = sortAsc ? [...albums].reverse() : albums; + const withPlays = sorted.filter(a => (a.playCount ?? 0) > 0); + + return ( +
+
+
+ +

{t('mostPlayed.title')}

+
+ +
+ + {/* ── Top Artists ── */} + {!loading && topArtists.length > 0 && ( +
+

{t('mostPlayed.topArtists')}

+
+ {topArtists.map((artist, i) => ( + + ))} +
+
+ )} + + {/* ── Top Albums ── */} +
+

{t('mostPlayed.topAlbums')}

+ + {loading ? ( +
+ ) : withPlays.length === 0 ? ( +
{t('mostPlayed.noData')}
+ ) : ( + <> +
+ {withPlays.map((album, i) => ( +
navigate(`/album/${album.id}`)} + onContextMenu={e => { e.preventDefault(); playAlbum(album.id); }} + > + {sortAsc ? withPlays.length - i : i + 1} + {album.coverArt ? ( + + ) : ( +
+ )} +
+ {album.name} + { e.stopPropagation(); navigate(`/artist/${album.artistId}`); }} + > + {album.artist} + +
+ {album.year && {album.year}} + {(album.playCount ?? 0).toLocaleString()} +
+ ))} +
+ + {hasMore && ( + + )} + + )} +
+
+ ); +} diff --git a/src/store/sidebarStore.ts b/src/store/sidebarStore.ts index af9c3052..db58ee72 100644 --- a/src/store/sidebarStore.ts +++ b/src/store/sidebarStore.ts @@ -18,6 +18,7 @@ export const DEFAULT_SIDEBAR_ITEMS: SidebarItemConfig[] = [ { id: 'randomMix', visible: true }, { id: 'favorites', visible: true }, { id: 'playlists', visible: true }, + { id: 'mostPlayed', visible: true }, { id: 'radio', visible: true }, { id: 'statistics', visible: true }, { id: 'help', visible: true }, diff --git a/src/styles/components.css b/src/styles/components.css index 4ae32a2d..efcbc29b 100644 --- a/src/styles/components.css +++ b/src/styles/components.css @@ -6377,3 +6377,204 @@ font-variant-numeric: tabular-nums; flex-shrink: 0; } + +/* ─── Most Played page ───────────────────────────────────────────────────────── */ + +.mp-header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 2rem; + flex-wrap: wrap; + gap: 0.75rem; +} + +.mp-header-left { + display: flex; + align-items: center; + gap: 0.6rem; +} + +.mp-header-icon { + color: var(--accent); +} + +.mp-title { + font-size: clamp(22px, 2.5vw, 32px); + font-weight: 700; + color: var(--text-primary); + margin: 0; +} + +.mp-sort-btn { + display: flex; + align-items: center; + gap: 6px; + font-size: 13px; +} + +.mp-section { + margin-bottom: 2.5rem; +} + +.mp-section-title { + font-size: 15px; + font-weight: 600; + color: var(--text-muted); + text-transform: uppercase; + letter-spacing: 0.06em; + margin: 0 0 1rem; +} + +.mp-loading { + display: flex; + justify-content: center; + padding: 3rem; +} + +/* ── Top Artists grid ── */ +.mp-artist-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); + gap: 0.6rem; +} + +.mp-artist-card { + display: flex; + align-items: center; + gap: 0.65rem; + padding: 0.5rem 0.75rem; + border-radius: var(--radius); + background: var(--bg-card); + cursor: pointer; + text-align: left; + transition: background var(--transition-fast); + border: 1px solid transparent; +} + +.mp-artist-card:hover { + background: var(--bg-hover); + border-color: var(--border); +} + +.mp-rank { + font-size: 12px; + font-weight: 700; + color: var(--accent); + min-width: 18px; + text-align: right; +} + +.mp-artist-avatar { + width: 38px; + height: 38px; + border-radius: 50%; + object-fit: cover; + flex-shrink: 0; +} + +.mp-artist-avatar--placeholder { + background: var(--bg-surface); +} + +.mp-artist-info { + display: flex; + flex-direction: column; + min-width: 0; +} + +.mp-artist-name { + font-size: 13px; + font-weight: 500; + color: var(--text-primary); +} + +.mp-artist-plays { + font-size: 11px; + color: var(--text-muted); + margin-top: 1px; +} + +/* ── Album list ── */ +.mp-album-list { + display: flex; + flex-direction: column; + gap: 2px; +} + +.mp-album-row { + display: grid; + grid-template-columns: 28px 44px 1fr auto auto; + align-items: center; + gap: 0.75rem; + padding: 0.35rem 0.5rem; + border-radius: var(--radius-sm, 6px); + cursor: pointer; + transition: background var(--transition-fast); +} + +.mp-album-row:hover { + background: var(--bg-hover); +} + +.mp-album-rank { + font-size: 12px; + font-weight: 600; + color: var(--text-muted); + text-align: right; + font-variant-numeric: tabular-nums; +} + +.mp-album-cover { + width: 44px; + height: 44px; + border-radius: 4px; + object-fit: cover; + flex-shrink: 0; +} + +.mp-album-cover--placeholder { + background: var(--bg-surface); +} + +.mp-album-meta { + display: flex; + flex-direction: column; + min-width: 0; +} + +.mp-album-name { + font-size: 14px; + font-weight: 500; + color: var(--text-primary); +} + +.mp-album-artist { + font-size: 12px; + color: var(--text-muted); + margin-top: 1px; +} + +.mp-album-year { + font-size: 12px; + color: var(--text-muted); + white-space: nowrap; + font-variant-numeric: tabular-nums; +} + +.mp-album-plays { + font-size: 13px; + font-weight: 600; + color: var(--accent); + white-space: nowrap; + font-variant-numeric: tabular-nums; + min-width: 36px; + text-align: right; +} + +.mp-load-more { + margin-top: 1rem; + display: flex; + align-items: center; + gap: 0.5rem; +}