mirror of
https://github.com/kilyabin/psysonic.git
synced 2026-07-22 14:35:41 +00:00
feat(most-played): add dedicated Most Played page (#86)
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 <noreply@anthropic.com>
This commit is contained in:
@@ -26,6 +26,7 @@ import Login from './pages/Login';
|
|||||||
import AlbumDetail from './pages/AlbumDetail';
|
import AlbumDetail from './pages/AlbumDetail';
|
||||||
import LabelAlbums from './pages/LabelAlbums';
|
import LabelAlbums from './pages/LabelAlbums';
|
||||||
import Statistics from './pages/Statistics';
|
import Statistics from './pages/Statistics';
|
||||||
|
import MostPlayed from './pages/MostPlayed';
|
||||||
import Help from './pages/Help';
|
import Help from './pages/Help';
|
||||||
import RandomAlbums from './pages/RandomAlbums';
|
import RandomAlbums from './pages/RandomAlbums';
|
||||||
import SearchResults from './pages/SearchResults';
|
import SearchResults from './pages/SearchResults';
|
||||||
@@ -304,6 +305,7 @@ function AppShell() {
|
|||||||
<Route path="/search" element={<SearchResults />} />
|
<Route path="/search" element={<SearchResults />} />
|
||||||
<Route path="/search/advanced" element={<AdvancedSearch />} />
|
<Route path="/search/advanced" element={<AdvancedSearch />} />
|
||||||
<Route path="/statistics" element={<Statistics />} />
|
<Route path="/statistics" element={<Statistics />} />
|
||||||
|
<Route path="/most-played" element={<MostPlayed />} />
|
||||||
<Route path="/now-playing" element={isMobile ? <MobilePlayerView /> : <NowPlayingPage />} />
|
<Route path="/now-playing" element={isMobile ? <MobilePlayerView /> : <NowPlayingPage />} />
|
||||||
<Route path="/settings" element={<Settings />} />
|
<Route path="/settings" element={<Settings />} />
|
||||||
<Route path="/help" element={<Help />} />
|
<Route path="/help" element={<Help />} />
|
||||||
|
|||||||
@@ -58,6 +58,7 @@ export interface SubsonicAlbum {
|
|||||||
coverArt?: string;
|
coverArt?: string;
|
||||||
songCount: number;
|
songCount: number;
|
||||||
duration: number;
|
duration: number;
|
||||||
|
playCount?: number;
|
||||||
year?: number;
|
year?: number;
|
||||||
genre?: string;
|
genre?: string;
|
||||||
starred?: string;
|
starred?: string;
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import { useTranslation } from 'react-i18next';
|
|||||||
import {
|
import {
|
||||||
Disc3, Users, Music4, Radio, Settings, Heart, BarChart3, Shuffle,
|
Disc3, Users, Music4, Radio, Settings, Heart, BarChart3, Shuffle,
|
||||||
PanelLeftClose, PanelLeft, HelpCircle, Dices, AudioLines, HardDriveDownload, Tags, ListMusic, Cast,
|
PanelLeftClose, PanelLeft, HelpCircle, Dices, AudioLines, HardDriveDownload, Tags, ListMusic, Cast,
|
||||||
ChevronDown, Check, Music2,
|
ChevronDown, Check, Music2, TrendingUp,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import PsysonicLogo from './PsysonicLogo';
|
import PsysonicLogo from './PsysonicLogo';
|
||||||
import PSmallLogo from './PSmallLogo';
|
import PSmallLogo from './PSmallLogo';
|
||||||
@@ -24,10 +24,11 @@ export const ALL_NAV_ITEMS: Record<string, { icon: React.ElementType; labelKey:
|
|||||||
artists: { icon: Users, labelKey: 'sidebar.artists', to: '/artists', section: 'library' },
|
artists: { icon: Users, labelKey: 'sidebar.artists', to: '/artists', section: 'library' },
|
||||||
genres: { icon: Tags, labelKey: 'sidebar.genres', to: '/genres', section: 'library' },
|
genres: { icon: Tags, labelKey: 'sidebar.genres', to: '/genres', section: 'library' },
|
||||||
randomMix: { icon: Shuffle, labelKey: 'sidebar.randomMix', to: '/random-mix', section: 'library' },
|
randomMix: { icon: Shuffle, labelKey: 'sidebar.randomMix', to: '/random-mix', section: 'library' },
|
||||||
favorites: { icon: Heart, labelKey: 'sidebar.favorites', to: '/favorites', section: 'library' },
|
favorites: { icon: Heart, labelKey: 'sidebar.favorites', to: '/favorites', section: 'library' },
|
||||||
playlists: { icon: ListMusic, labelKey: 'sidebar.playlists', to: '/playlists', section: 'library' },
|
playlists: { icon: ListMusic, labelKey: 'sidebar.playlists', to: '/playlists', section: 'library' },
|
||||||
radio: { icon: Cast, labelKey: 'sidebar.radio', to: '/radio', section: 'library' },
|
mostPlayed: { icon: TrendingUp, labelKey: 'sidebar.mostPlayed', to: '/most-played', section: 'library' },
|
||||||
statistics: { icon: BarChart3, labelKey: 'sidebar.statistics', to: '/statistics', section: 'system' },
|
radio: { icon: Cast, labelKey: 'sidebar.radio', to: '/radio', section: 'library' },
|
||||||
|
statistics: { icon: BarChart3, labelKey: 'sidebar.statistics', to: '/statistics', section: 'system' },
|
||||||
help: { icon: HelpCircle, labelKey: 'sidebar.help', to: '/help', section: 'system' },
|
help: { icon: HelpCircle, labelKey: 'sidebar.help', to: '/help', section: 'system' },
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ export const deTranslation = {
|
|||||||
offlineLibrary: 'Offline-Bibliothek',
|
offlineLibrary: 'Offline-Bibliothek',
|
||||||
genres: 'Genres',
|
genres: 'Genres',
|
||||||
playlists: 'Playlists',
|
playlists: 'Playlists',
|
||||||
|
mostPlayed: 'Meistgehört',
|
||||||
radio: 'Internetradio',
|
radio: 'Internetradio',
|
||||||
libraryScope: 'Bibliotheksumfang',
|
libraryScope: 'Bibliotheksumfang',
|
||||||
allLibraries: 'Alle Bibliotheken',
|
allLibraries: 'Alle Bibliotheken',
|
||||||
@@ -777,6 +778,16 @@ export const deTranslation = {
|
|||||||
metaSaved: 'Playlist aktualisiert',
|
metaSaved: 'Playlist aktualisiert',
|
||||||
downloadZip: 'Herunterladen (ZIP)',
|
downloadZip: 'Herunterladen (ZIP)',
|
||||||
},
|
},
|
||||||
|
mostPlayed: {
|
||||||
|
title: 'Meistgehört',
|
||||||
|
topArtists: 'Top-Künstler',
|
||||||
|
topAlbums: 'Top-Alben',
|
||||||
|
plays: '{{n}}× gespielt',
|
||||||
|
sortMost: 'Meiste Plays zuerst',
|
||||||
|
sortLeast: 'Wenigste Plays zuerst',
|
||||||
|
loadMore: 'Mehr Alben laden',
|
||||||
|
noData: 'Noch keine Wiedergabedaten. Fang an zu hören!',
|
||||||
|
},
|
||||||
radio: {
|
radio: {
|
||||||
title: 'Internetradio',
|
title: 'Internetradio',
|
||||||
empty: 'Keine Radiosender konfiguriert.',
|
empty: 'Keine Radiosender konfiguriert.',
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ export const enTranslation = {
|
|||||||
offlineLibrary: 'Offline Library',
|
offlineLibrary: 'Offline Library',
|
||||||
genres: 'Genres',
|
genres: 'Genres',
|
||||||
playlists: 'Playlists',
|
playlists: 'Playlists',
|
||||||
|
mostPlayed: 'Most Played',
|
||||||
radio: 'Internet Radio',
|
radio: 'Internet Radio',
|
||||||
libraryScope: 'Library scope',
|
libraryScope: 'Library scope',
|
||||||
allLibraries: 'All libraries',
|
allLibraries: 'All libraries',
|
||||||
@@ -778,6 +779,16 @@ export const enTranslation = {
|
|||||||
metaSaved: 'Playlist updated',
|
metaSaved: 'Playlist updated',
|
||||||
downloadZip: 'Download (ZIP)',
|
downloadZip: 'Download (ZIP)',
|
||||||
},
|
},
|
||||||
|
mostPlayed: {
|
||||||
|
title: 'Most Played',
|
||||||
|
topArtists: 'Top Artists',
|
||||||
|
topAlbums: 'Top Albums',
|
||||||
|
plays: '{{n}} plays',
|
||||||
|
sortMost: 'Most plays first',
|
||||||
|
sortLeast: 'Fewest plays first',
|
||||||
|
loadMore: 'Load more albums',
|
||||||
|
noData: 'No play data yet. Start listening!',
|
||||||
|
},
|
||||||
radio: {
|
radio: {
|
||||||
title: 'Internet Radio',
|
title: 'Internet Radio',
|
||||||
empty: 'No radio stations configured.',
|
empty: 'No radio stations configured.',
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ export const frTranslation = {
|
|||||||
offlineLibrary: 'Bibliothèque hors ligne',
|
offlineLibrary: 'Bibliothèque hors ligne',
|
||||||
genres: 'Genres',
|
genres: 'Genres',
|
||||||
playlists: 'Playlists',
|
playlists: 'Playlists',
|
||||||
|
mostPlayed: 'Les plus joués',
|
||||||
radio: 'Radio Internet',
|
radio: 'Radio Internet',
|
||||||
libraryScope: 'Portée de la bibliothèque',
|
libraryScope: 'Portée de la bibliothèque',
|
||||||
allLibraries: 'Toutes les bibliothèques',
|
allLibraries: 'Toutes les bibliothèques',
|
||||||
@@ -777,6 +778,16 @@ export const frTranslation = {
|
|||||||
metaSaved: 'Playlist mise à jour',
|
metaSaved: 'Playlist mise à jour',
|
||||||
downloadZip: 'Télécharger (ZIP)',
|
downloadZip: 'Télécharger (ZIP)',
|
||||||
},
|
},
|
||||||
|
mostPlayed: {
|
||||||
|
title: 'Les plus joués',
|
||||||
|
topArtists: 'Artistes populaires',
|
||||||
|
topAlbums: 'Albums populaires',
|
||||||
|
plays: '{{n}} écoutes',
|
||||||
|
sortMost: 'Plus joués en premier',
|
||||||
|
sortLeast: 'Moins joués en premier',
|
||||||
|
loadMore: 'Charger plus d\'albums',
|
||||||
|
noData: 'Aucune donnée d\'écoute. Commencez à écouter !',
|
||||||
|
},
|
||||||
radio: {
|
radio: {
|
||||||
title: 'Radio Internet',
|
title: 'Radio Internet',
|
||||||
empty: 'Aucune station radio configurée.',
|
empty: 'Aucune station radio configurée.',
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ export const nbTranslation = {
|
|||||||
offlineLibrary: 'Frakoblet bibliotek',
|
offlineLibrary: 'Frakoblet bibliotek',
|
||||||
genres: 'Sjangere',
|
genres: 'Sjangere',
|
||||||
playlists: 'Spillelister',
|
playlists: 'Spillelister',
|
||||||
|
mostPlayed: 'Mest spilt',
|
||||||
radio: 'Internettradio',
|
radio: 'Internettradio',
|
||||||
libraryScope: 'Biblioteksomfang',
|
libraryScope: 'Biblioteksomfang',
|
||||||
allLibraries: 'Alle biblioteker',
|
allLibraries: 'Alle biblioteker',
|
||||||
@@ -776,6 +777,16 @@ export const nbTranslation = {
|
|||||||
metaSaved: 'Spillelisten er oppdatert',
|
metaSaved: 'Spillelisten er oppdatert',
|
||||||
downloadZip: 'Last ned (ZIP)',
|
downloadZip: 'Last ned (ZIP)',
|
||||||
},
|
},
|
||||||
|
mostPlayed: {
|
||||||
|
title: 'Mest spilt',
|
||||||
|
topArtists: 'Toppkunstnere',
|
||||||
|
topAlbums: 'Toppalbum',
|
||||||
|
plays: '{{n}} avspillinger',
|
||||||
|
sortMost: 'Mest spilt først',
|
||||||
|
sortLeast: 'Minst spilt først',
|
||||||
|
loadMore: 'Last inn flere album',
|
||||||
|
noData: 'Ingen avspillingsdata ennå. Begynn å høre!',
|
||||||
|
},
|
||||||
radio: {
|
radio: {
|
||||||
title: 'Internettradio',
|
title: 'Internettradio',
|
||||||
empty: 'Ingen radiostasjoner konfigurert.',
|
empty: 'Ingen radiostasjoner konfigurert.',
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ export const nlTranslation = {
|
|||||||
offlineLibrary: 'Offline bibliotheek',
|
offlineLibrary: 'Offline bibliotheek',
|
||||||
genres: 'Genres',
|
genres: 'Genres',
|
||||||
playlists: 'Playlists',
|
playlists: 'Playlists',
|
||||||
|
mostPlayed: 'Meest gespeeld',
|
||||||
radio: 'Internetradio',
|
radio: 'Internetradio',
|
||||||
libraryScope: 'Bibliotheekbereik',
|
libraryScope: 'Bibliotheekbereik',
|
||||||
allLibraries: 'Alle bibliotheken',
|
allLibraries: 'Alle bibliotheken',
|
||||||
@@ -777,6 +778,16 @@ export const nlTranslation = {
|
|||||||
metaSaved: 'Playlist bijgewerkt',
|
metaSaved: 'Playlist bijgewerkt',
|
||||||
downloadZip: 'Downloaden (ZIP)',
|
downloadZip: 'Downloaden (ZIP)',
|
||||||
},
|
},
|
||||||
|
mostPlayed: {
|
||||||
|
title: 'Meest gespeeld',
|
||||||
|
topArtists: 'Topkunstenaars',
|
||||||
|
topAlbums: 'Topalbums',
|
||||||
|
plays: '{{n}} keer gespeeld',
|
||||||
|
sortMost: 'Meest gespeeld eerst',
|
||||||
|
sortLeast: 'Minst gespeeld eerst',
|
||||||
|
loadMore: 'Meer albums laden',
|
||||||
|
noData: 'Nog geen afspeelgegevens. Begin met luisteren!',
|
||||||
|
},
|
||||||
radio: {
|
radio: {
|
||||||
title: 'Internetradio',
|
title: 'Internetradio',
|
||||||
empty: 'Geen radiostations geconfigureerd.',
|
empty: 'Geen radiostations geconfigureerd.',
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ export const ruTranslation = {
|
|||||||
offlineLibrary: 'Офлайн-библиотека',
|
offlineLibrary: 'Офлайн-библиотека',
|
||||||
genres: 'Жанры',
|
genres: 'Жанры',
|
||||||
playlists: 'Плейлисты',
|
playlists: 'Плейлисты',
|
||||||
|
mostPlayed: 'Часто слушаемое',
|
||||||
radio: 'Онлайн-радио',
|
radio: 'Онлайн-радио',
|
||||||
libraryScope: 'Область медиатеки',
|
libraryScope: 'Область медиатеки',
|
||||||
allLibraries: 'Все библиотеки',
|
allLibraries: 'Все библиотеки',
|
||||||
@@ -831,6 +832,16 @@ export const ruTranslation = {
|
|||||||
metaSaved: 'Плейлист сохранён',
|
metaSaved: 'Плейлист сохранён',
|
||||||
downloadZip: 'Скачать (ZIP)',
|
downloadZip: 'Скачать (ZIP)',
|
||||||
},
|
},
|
||||||
|
mostPlayed: {
|
||||||
|
title: 'Часто слушаемое',
|
||||||
|
topArtists: 'Топ исполнителей',
|
||||||
|
topAlbums: 'Топ альбомов',
|
||||||
|
plays: '{{n}} прослушиваний',
|
||||||
|
sortMost: 'Сначала популярные',
|
||||||
|
sortLeast: 'Сначала малоизвестные',
|
||||||
|
loadMore: 'Загрузить больше альбомов',
|
||||||
|
noData: 'Нет данных о прослушиваниях. Начните слушать!',
|
||||||
|
},
|
||||||
radio: {
|
radio: {
|
||||||
title: 'Онлайн-радио',
|
title: 'Онлайн-радио',
|
||||||
empty: 'Радиостанции не настроены.',
|
empty: 'Радиостанции не настроены.',
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ export const zhTranslation = {
|
|||||||
offlineLibrary: '离线音乐库',
|
offlineLibrary: '离线音乐库',
|
||||||
genres: '流派',
|
genres: '流派',
|
||||||
playlists: '播放列表',
|
playlists: '播放列表',
|
||||||
|
mostPlayed: '最常播放',
|
||||||
radio: '网络电台',
|
radio: '网络电台',
|
||||||
libraryScope: '资料库范围',
|
libraryScope: '资料库范围',
|
||||||
allLibraries: '所有资料库',
|
allLibraries: '所有资料库',
|
||||||
@@ -773,6 +774,16 @@ export const zhTranslation = {
|
|||||||
metaSaved: '播放列表已更新',
|
metaSaved: '播放列表已更新',
|
||||||
downloadZip: '下载 (ZIP)',
|
downloadZip: '下载 (ZIP)',
|
||||||
},
|
},
|
||||||
|
mostPlayed: {
|
||||||
|
title: '最常播放',
|
||||||
|
topArtists: '热门艺术家',
|
||||||
|
topAlbums: '热门专辑',
|
||||||
|
plays: '播放 {{n}} 次',
|
||||||
|
sortMost: '最多播放在前',
|
||||||
|
sortLeast: '最少播放在前',
|
||||||
|
loadMore: '加载更多专辑',
|
||||||
|
noData: '暂无播放数据,开始聆听吧!',
|
||||||
|
},
|
||||||
radio: {
|
radio: {
|
||||||
title: '网络电台',
|
title: '网络电台',
|
||||||
empty: '未配置任何电台。',
|
empty: '未配置任何电台。',
|
||||||
|
|||||||
@@ -0,0 +1,189 @@
|
|||||||
|
import React, { useEffect, useState, useCallback } from 'react';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import { ArrowUpDown, ArrowDown, ArrowUp, TrendingUp } from 'lucide-react';
|
||||||
|
import { getAlbumList, SubsonicAlbum, buildCoverArtUrl, coverArtCacheKey } from '../api/subsonic';
|
||||||
|
import { useAuthStore } from '../store/authStore';
|
||||||
|
import CachedImage from '../components/CachedImage';
|
||||||
|
import { playAlbum } from '../utils/playAlbum';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
|
const PAGE_SIZE = 50;
|
||||||
|
|
||||||
|
interface ArtistEntry {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
coverArt?: string;
|
||||||
|
totalPlays: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
function deriveTopArtists(albums: SubsonicAlbum[]): ArtistEntry[] {
|
||||||
|
const map = new Map<string, ArtistEntry>();
|
||||||
|
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<typeof import('react-i18next').useTranslation>['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<SubsonicAlbum[]>([]);
|
||||||
|
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 (
|
||||||
|
<div className="content-body animate-fade-in">
|
||||||
|
<div className="mp-header">
|
||||||
|
<div className="mp-header-left">
|
||||||
|
<TrendingUp size={22} className="mp-header-icon" />
|
||||||
|
<h1 className="mp-title">{t('mostPlayed.title')}</h1>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
className="btn btn-ghost mp-sort-btn"
|
||||||
|
onClick={() => setSortAsc(v => !v)}
|
||||||
|
data-tooltip={sortAsc ? t('mostPlayed.sortMost') : t('mostPlayed.sortLeast')}
|
||||||
|
>
|
||||||
|
{sortAsc ? <ArrowUp size={14} /> : <ArrowDown size={14} />}
|
||||||
|
{sortAsc ? t('mostPlayed.sortLeast') : t('mostPlayed.sortMost')}
|
||||||
|
<ArrowUpDown size={12} style={{ opacity: 0.45 }} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── Top Artists ── */}
|
||||||
|
{!loading && topArtists.length > 0 && (
|
||||||
|
<section className="mp-section">
|
||||||
|
<h2 className="mp-section-title">{t('mostPlayed.topArtists')}</h2>
|
||||||
|
<div className="mp-artist-grid">
|
||||||
|
{topArtists.map((artist, i) => (
|
||||||
|
<button
|
||||||
|
key={artist.id}
|
||||||
|
className="mp-artist-card"
|
||||||
|
onClick={() => navigate(`/artist/${artist.id}`)}
|
||||||
|
>
|
||||||
|
<span className="mp-rank">{i + 1}</span>
|
||||||
|
{artist.coverArt ? (
|
||||||
|
<CachedImage
|
||||||
|
src={buildCoverArtUrl(artist.coverArt, 80)}
|
||||||
|
cacheKey={coverArtCacheKey(artist.coverArt, 80)}
|
||||||
|
alt=""
|
||||||
|
className="mp-artist-avatar"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="mp-artist-avatar mp-artist-avatar--placeholder" />
|
||||||
|
)}
|
||||||
|
<div className="mp-artist-info">
|
||||||
|
<span className="mp-artist-name truncate">{artist.name}</span>
|
||||||
|
<span className="mp-artist-plays">{formatPlays(artist.totalPlays, t)}</span>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ── Top Albums ── */}
|
||||||
|
<section className="mp-section">
|
||||||
|
<h2 className="mp-section-title">{t('mostPlayed.topAlbums')}</h2>
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<div className="mp-loading"><div className="spinner" /></div>
|
||||||
|
) : withPlays.length === 0 ? (
|
||||||
|
<div className="empty-state">{t('mostPlayed.noData')}</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="mp-album-list">
|
||||||
|
{withPlays.map((album, i) => (
|
||||||
|
<div
|
||||||
|
key={album.id}
|
||||||
|
className="mp-album-row"
|
||||||
|
onClick={() => navigate(`/album/${album.id}`)}
|
||||||
|
onContextMenu={e => { e.preventDefault(); playAlbum(album.id); }}
|
||||||
|
>
|
||||||
|
<span className="mp-album-rank">{sortAsc ? withPlays.length - i : i + 1}</span>
|
||||||
|
{album.coverArt ? (
|
||||||
|
<CachedImage
|
||||||
|
src={buildCoverArtUrl(album.coverArt, 80)}
|
||||||
|
cacheKey={coverArtCacheKey(album.coverArt, 80)}
|
||||||
|
alt=""
|
||||||
|
className="mp-album-cover"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="mp-album-cover mp-album-cover--placeholder" />
|
||||||
|
)}
|
||||||
|
<div className="mp-album-meta">
|
||||||
|
<span className="mp-album-name truncate">{album.name}</span>
|
||||||
|
<span
|
||||||
|
className="mp-album-artist truncate track-artist-link"
|
||||||
|
onClick={e => { e.stopPropagation(); navigate(`/artist/${album.artistId}`); }}
|
||||||
|
>
|
||||||
|
{album.artist}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{album.year && <span className="mp-album-year">{album.year}</span>}
|
||||||
|
<span className="mp-album-plays">{(album.playCount ?? 0).toLocaleString()}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{hasMore && (
|
||||||
|
<button
|
||||||
|
className="btn btn-ghost mp-load-more"
|
||||||
|
onClick={loadMore}
|
||||||
|
disabled={loadingMore}
|
||||||
|
>
|
||||||
|
{loadingMore ? <div className="spinner" style={{ width: 14, height: 14, borderTopColor: 'currentColor' }} /> : null}
|
||||||
|
{t('mostPlayed.loadMore')}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -18,6 +18,7 @@ export const DEFAULT_SIDEBAR_ITEMS: SidebarItemConfig[] = [
|
|||||||
{ id: 'randomMix', visible: true },
|
{ id: 'randomMix', visible: true },
|
||||||
{ id: 'favorites', visible: true },
|
{ id: 'favorites', visible: true },
|
||||||
{ id: 'playlists', visible: true },
|
{ id: 'playlists', visible: true },
|
||||||
|
{ id: 'mostPlayed', visible: true },
|
||||||
{ id: 'radio', visible: true },
|
{ id: 'radio', visible: true },
|
||||||
{ id: 'statistics', visible: true },
|
{ id: 'statistics', visible: true },
|
||||||
{ id: 'help', visible: true },
|
{ id: 'help', visible: true },
|
||||||
|
|||||||
@@ -6377,3 +6377,204 @@
|
|||||||
font-variant-numeric: tabular-nums;
|
font-variant-numeric: tabular-nums;
|
||||||
flex-shrink: 0;
|
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;
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user