mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-21 23:05:46 +00:00
488 lines
26 KiB
TypeScript
488 lines
26 KiB
TypeScript
import React, { useEffect, useMemo, useState } from 'react';
|
|
import { getAlbumList, getArtists, getGenres, getRandomSongs, getStarred, SubsonicAlbum, SubsonicArtist, SubsonicGenre, SubsonicSong } from '../api/subsonic';
|
|
import AlbumRow from '../components/AlbumRow';
|
|
import { useTranslation } from 'react-i18next';
|
|
import { useAuthStore } from '../store/authStore';
|
|
import { useNavigate } from 'react-router-dom';
|
|
import { usePlayerStore } from '../store/playerStore';
|
|
import { lastfmIsConfigured, lastfmGetTopArtists, lastfmGetTopAlbums, lastfmGetTopTracks, lastfmGetRecentTracks, LastfmPeriod, LastfmTopArtist, LastfmTopAlbum, LastfmTopTrack, LastfmRecentTrack } from '../api/lastfm';
|
|
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
function relativeTime(timestamp: number, t: (key: string, opts?: any) => string): string {
|
|
const diff = Math.floor(Date.now() / 1000) - timestamp;
|
|
if (diff < 60) return t('statistics.lfmJustNow');
|
|
if (diff < 3600) return t('statistics.lfmMinutesAgo', { n: Math.floor(diff / 60) });
|
|
if (diff < 86400) return t('statistics.lfmHoursAgo', { n: Math.floor(diff / 3600) });
|
|
return t('statistics.lfmDaysAgo', { n: Math.floor(diff / 86400) });
|
|
}
|
|
|
|
function formatPlaytime(seconds: number): string {
|
|
const h = Math.floor(seconds / 3600);
|
|
const m = Math.floor((seconds % 3600) / 60);
|
|
if (h > 0) return `${h.toLocaleString()}h ${m}m`;
|
|
return `${m}m`;
|
|
}
|
|
|
|
const PERIODS: { key: LastfmPeriod; label: string }[] = [
|
|
{ key: '7day', label: 'lfmPeriod7day' },
|
|
{ key: '1month', label: 'lfmPeriod1month' },
|
|
{ key: '3month', label: 'lfmPeriod3month' },
|
|
{ key: '6month', label: 'lfmPeriod6month' },
|
|
{ key: '12month', label: 'lfmPeriod12month' },
|
|
{ key: 'overall', label: 'lfmPeriodOverall' },
|
|
];
|
|
|
|
export default function Statistics() {
|
|
const { t } = useTranslation();
|
|
const navigate = useNavigate();
|
|
const { lastfmSessionKey, lastfmUsername } = useAuthStore();
|
|
const musicLibraryFilterVersion = useAuthStore(s => s.musicLibraryFilterVersion);
|
|
const [recent, setRecent] = useState<SubsonicAlbum[]>([]);
|
|
const [frequent, setFrequent] = useState<SubsonicAlbum[]>([]);
|
|
const [highest, setHighest] = useState<SubsonicAlbum[]>([]);
|
|
const [starredSongs, setStarredSongs] = useState<SubsonicSong[]>([]);
|
|
const [topArtists, setTopArtists] = useState<SubsonicArtist[]>([]);
|
|
const userRatingOverrides = usePlayerStore(s => s.userRatingOverrides);
|
|
const queue = usePlayerStore(s => s.queue);
|
|
const currentTrack = usePlayerStore(s => s.currentTrack);
|
|
|
|
const topSongs = useMemo(() => {
|
|
const map = new Map<string, SubsonicSong>(starredSongs.map(s => [s.id, { ...s, userRating: userRatingOverrides[s.id] ?? s.userRating }]));
|
|
// Songs not yet in starredSongs but rated via override (e.g. from queue or currentTrack)
|
|
const candidates = currentTrack ? [currentTrack, ...queue] : queue;
|
|
for (const t of candidates) {
|
|
const r = userRatingOverrides[t.id];
|
|
if (r && !map.has(t.id)) {
|
|
map.set(t.id, { id: t.id, title: t.title, artist: t.artist, album: t.album, albumId: t.albumId, userRating: r } as SubsonicSong);
|
|
}
|
|
}
|
|
return [...map.values()]
|
|
.filter(s => (s.userRating ?? 0) > 0)
|
|
.sort((a, b) => (b.userRating ?? 0) - (a.userRating ?? 0))
|
|
.slice(0, 10);
|
|
}, [starredSongs, userRatingOverrides, queue, currentTrack]);
|
|
const [artistCount, setArtistCount] = useState<number | null>(null);
|
|
const [totalSongs, setTotalSongs] = useState<number | null>(null);
|
|
const [totalAlbums, setTotalAlbums] = useState<number | null>(null);
|
|
const [genres, setGenres] = useState<SubsonicGenre[]>([]);
|
|
const [loading, setLoading] = useState(true);
|
|
|
|
const [totalPlaytime, setTotalPlaytime] = useState<number | null>(null);
|
|
const [playtimeCapped, setPlaytimeCapped] = useState(false);
|
|
const [formatData, setFormatData] = useState<{ format: string; count: number }[] | null>(null);
|
|
const [formatSampleSize, setFormatSampleSize] = useState(0);
|
|
|
|
const [lfmPeriod, setLfmPeriod] = useState<LastfmPeriod>('1month');
|
|
const [lfmTopArtists, setLfmTopArtists] = useState<LastfmTopArtist[]>([]);
|
|
const [lfmTopAlbums, setLfmTopAlbums] = useState<LastfmTopAlbum[]>([]);
|
|
const [lfmTopTracks, setLfmTopTracks] = useState<LastfmTopTrack[]>([]);
|
|
const [lfmLoading, setLfmLoading] = useState(false);
|
|
const [lfmRecentTracks, setLfmRecentTracks] = useState<LastfmRecentTrack[]>([]);
|
|
const [lfmRecentLoading, setLfmRecentLoading] = useState(false);
|
|
|
|
useEffect(() => {
|
|
Promise.all([
|
|
getAlbumList('recent', 20).catch(() => []),
|
|
getAlbumList('frequent', 12).catch(() => []),
|
|
getAlbumList('highest', 12).catch(() => []),
|
|
getArtists().catch(() => []),
|
|
getGenres().catch(() => []),
|
|
getStarred().catch(() => ({ albums: [], artists: [], songs: [] })),
|
|
]).then(([rc, fr, hi, a, g, starred]) => {
|
|
setRecent(rc);
|
|
setFrequent(fr);
|
|
setHighest(hi);
|
|
setArtistCount(a.length);
|
|
setTotalSongs(g.reduce((acc: number, genre: SubsonicGenre) => acc + genre.songCount, 0));
|
|
setTotalAlbums(g.reduce((acc: number, genre: SubsonicGenre) => acc + genre.albumCount, 0));
|
|
const sorted = [...g].sort((a, b) => b.songCount - a.songCount);
|
|
setGenres(sorted);
|
|
setStarredSongs(starred.songs);
|
|
setTopArtists(
|
|
[...starred.artists]
|
|
.filter(a => (a.userRating ?? 0) > 0)
|
|
.sort((a, b) => (b.userRating ?? 0) - (a.userRating ?? 0))
|
|
.slice(0, 10),
|
|
);
|
|
setLoading(false);
|
|
}).catch(() => setLoading(false));
|
|
}, [musicLibraryFilterVersion]);
|
|
|
|
// Background fetch: total playtime (paginate getAlbumList up to 10 pages of 500)
|
|
useEffect(() => {
|
|
let cancelled = false;
|
|
(async () => {
|
|
let total = 0;
|
|
let offset = 0;
|
|
const pageSize = 500;
|
|
const maxPages = 10;
|
|
let capped = false;
|
|
for (let page = 0; page < maxPages; page++) {
|
|
try {
|
|
const albums = await getAlbumList('newest', pageSize, offset);
|
|
if (cancelled) return;
|
|
for (const a of albums) total += (a.duration ?? 0);
|
|
if (albums.length < pageSize) break;
|
|
if (page === maxPages - 1) capped = true;
|
|
offset += pageSize;
|
|
} catch {
|
|
break;
|
|
}
|
|
}
|
|
if (!cancelled) {
|
|
setTotalPlaytime(total);
|
|
setPlaytimeCapped(capped);
|
|
}
|
|
})();
|
|
return () => { cancelled = true; };
|
|
}, [musicLibraryFilterVersion]);
|
|
|
|
// Background fetch: format distribution (sample of 500 random songs)
|
|
useEffect(() => {
|
|
let cancelled = false;
|
|
getRandomSongs(500).then(songs => {
|
|
if (cancelled) return;
|
|
const counts: Record<string, number> = {};
|
|
for (const song of songs) {
|
|
const fmt = song.suffix?.toUpperCase() ?? 'Unknown';
|
|
counts[fmt] = (counts[fmt] ?? 0) + 1;
|
|
}
|
|
const sorted = Object.entries(counts)
|
|
.map(([format, count]) => ({ format, count }))
|
|
.sort((a, b) => b.count - a.count);
|
|
setFormatData(sorted);
|
|
setFormatSampleSize(songs.length);
|
|
}).catch(() => {});
|
|
return () => { cancelled = true; };
|
|
}, [musicLibraryFilterVersion]);
|
|
|
|
useEffect(() => {
|
|
if (!lastfmIsConfigured() || !lastfmSessionKey || !lastfmUsername) return;
|
|
setLfmRecentLoading(true);
|
|
lastfmGetRecentTracks(lastfmUsername, lastfmSessionKey, 20)
|
|
.then(tracks => { setLfmRecentTracks(tracks); setLfmRecentLoading(false); })
|
|
.catch(() => setLfmRecentLoading(false));
|
|
}, [lastfmSessionKey, lastfmUsername]);
|
|
|
|
useEffect(() => {
|
|
if (!lastfmIsConfigured() || !lastfmSessionKey || !lastfmUsername) return;
|
|
setLfmLoading(true);
|
|
Promise.all([
|
|
lastfmGetTopArtists(lastfmUsername, lastfmSessionKey, lfmPeriod, 10),
|
|
lastfmGetTopAlbums(lastfmUsername, lastfmSessionKey, lfmPeriod, 10),
|
|
lastfmGetTopTracks(lastfmUsername, lastfmSessionKey, lfmPeriod, 10),
|
|
]).then(([artists, albums, tracks]) => {
|
|
setLfmTopArtists(artists);
|
|
setLfmTopAlbums(albums);
|
|
setLfmTopTracks(tracks);
|
|
setLfmLoading(false);
|
|
}).catch(() => setLfmLoading(false));
|
|
}, [lfmPeriod, lastfmSessionKey, lastfmUsername]);
|
|
|
|
const loadMore = async (
|
|
type: 'frequent' | 'highest',
|
|
currentList: SubsonicAlbum[],
|
|
setter: React.Dispatch<React.SetStateAction<SubsonicAlbum[]>>
|
|
) => {
|
|
try {
|
|
const more = await getAlbumList(type, 12, currentList.length);
|
|
const newItems = more.filter(m => !currentList.find(c => c.id === m.id));
|
|
if (newItems.length > 0) setter(prev => [...prev, ...newItems]);
|
|
} catch (e) {
|
|
console.error('Failed to load more', e);
|
|
}
|
|
};
|
|
|
|
const playtimeDisplay = totalPlaytime === null
|
|
? t('statistics.computing')
|
|
: (playtimeCapped ? '≥ ' : '') + formatPlaytime(totalPlaytime);
|
|
|
|
const stats = [
|
|
{ label: t('statistics.statArtists'), value: artistCount?.toLocaleString() ?? '—' },
|
|
{ label: t('statistics.statAlbums'), value: totalAlbums?.toLocaleString() ?? '—' },
|
|
{ label: t('statistics.statSongs'), value: totalSongs?.toLocaleString() ?? '—' },
|
|
{ label: t('statistics.statPlaytime'), value: playtimeDisplay },
|
|
];
|
|
|
|
const topGenres = genres.slice(0, 10);
|
|
const maxGenreSongs = topGenres[0]?.songCount ?? 1;
|
|
|
|
return (
|
|
<div className="content-body animate-fade-in">
|
|
<h1 className="page-title">{t('statistics.title')}</h1>
|
|
|
|
{loading ? (
|
|
<div className="loading-center"><div className="spinner" /></div>
|
|
) : (
|
|
<div className="stats-page">
|
|
|
|
<div className="stats-overview">
|
|
{stats.map(s => (
|
|
<div key={s.label} className="stats-card">
|
|
<span className="stats-card-value">{s.value}</span>
|
|
<span className="stats-card-label">{s.label}</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
|
|
{/* Genre Insights + Format Distribution */}
|
|
{(topGenres.length > 0 || formatData) && (
|
|
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(280px, 1fr))', gap: '1rem', marginBottom: '0.5rem' }}>
|
|
|
|
{topGenres.length > 0 && (
|
|
<div style={{ background: 'var(--glass-bg)', border: '1px solid var(--glass-border)', borderRadius: '12px', padding: '1.25rem', backdropFilter: 'blur(8px)' }}>
|
|
<h3 style={{ fontSize: '0.7rem', fontWeight: 700, textTransform: 'uppercase', letterSpacing: '0.1em', color: 'var(--accent)', marginBottom: '1rem' }}>
|
|
{t('statistics.genreInsights')}
|
|
</h3>
|
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.6rem' }}>
|
|
{topGenres.map(g => (
|
|
<div key={g.value}>
|
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'baseline', marginBottom: '0.2rem' }}>
|
|
<span style={{ fontSize: '0.8rem', fontWeight: 500, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', maxWidth: '70%' }}>
|
|
{g.value}
|
|
</span>
|
|
<span style={{ fontSize: '0.7rem', color: 'var(--text-muted)', flexShrink: 0, marginLeft: '0.5rem' }}>
|
|
{g.songCount.toLocaleString()}
|
|
</span>
|
|
</div>
|
|
<div style={{ height: '4px', borderRadius: '2px', background: 'var(--glass-border)', overflow: 'hidden' }}>
|
|
<div style={{
|
|
height: '100%',
|
|
width: `${(g.songCount / maxGenreSongs) * 100}%`,
|
|
background: 'var(--accent)',
|
|
opacity: 0.7,
|
|
borderRadius: '2px',
|
|
transition: 'width 0.4s ease',
|
|
}} />
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{formatData && (
|
|
<div style={{ background: 'var(--glass-bg)', border: '1px solid var(--glass-border)', borderRadius: '12px', padding: '1.25rem', backdropFilter: 'blur(8px)' }}>
|
|
<h3 style={{ fontSize: '0.7rem', fontWeight: 700, textTransform: 'uppercase', letterSpacing: '0.1em', color: 'var(--accent)', marginBottom: '0.25rem' }}>
|
|
{t('statistics.formatDistribution')}
|
|
</h3>
|
|
<p style={{ fontSize: '0.7rem', color: 'var(--text-muted)', marginBottom: '1rem' }}>
|
|
{t('statistics.formatSample', { n: formatSampleSize.toLocaleString() })}
|
|
</p>
|
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.6rem' }}>
|
|
{formatData.map(f => {
|
|
const pct = formatSampleSize > 0 ? Math.round((f.count / formatSampleSize) * 100) : 0;
|
|
return (
|
|
<div key={f.format}>
|
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'baseline', marginBottom: '0.2rem' }}>
|
|
<span style={{ fontSize: '0.8rem', fontWeight: 600, fontFamily: 'monospace' }}>{f.format}</span>
|
|
<span style={{ fontSize: '0.7rem', color: 'var(--text-muted)' }}>{pct}%</span>
|
|
</div>
|
|
<div style={{ height: '4px', borderRadius: '2px', background: 'var(--glass-border)', overflow: 'hidden' }}>
|
|
<div style={{
|
|
height: '100%',
|
|
width: `${pct}%`,
|
|
background: 'var(--accent)',
|
|
opacity: 0.6,
|
|
borderRadius: '2px',
|
|
transition: 'width 0.4s ease',
|
|
}} />
|
|
</div>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
</div>
|
|
)}
|
|
|
|
{recent.length > 0 && (
|
|
<AlbumRow title={t('statistics.recentlyPlayed')} albums={recent} />
|
|
)}
|
|
|
|
<AlbumRow
|
|
title={t('statistics.mostPlayed')}
|
|
albums={frequent}
|
|
onLoadMore={() => loadMore('frequent', frequent, setFrequent)}
|
|
moreText={t('statistics.loadMore')}
|
|
/>
|
|
|
|
<AlbumRow
|
|
title={t('statistics.highestRated')}
|
|
albums={highest}
|
|
onLoadMore={() => loadMore('highest', highest, setHighest)}
|
|
moreText={t('statistics.loadMore')}
|
|
showRating
|
|
/>
|
|
|
|
{/* Top Rated Songs + Artists */}
|
|
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(280px, 1fr))', gap: '1rem', marginBottom: '0.5rem' }}>
|
|
<div style={{ background: 'var(--glass-bg)', border: '1px solid var(--glass-border)', borderRadius: '12px', padding: '1.25rem', backdropFilter: 'blur(8px)' }}>
|
|
<h3 style={{ fontSize: '0.7rem', fontWeight: 700, textTransform: 'uppercase', letterSpacing: '0.1em', color: 'var(--accent)', marginBottom: '1rem' }}>
|
|
{t('statistics.topRatedSongs')}
|
|
</h3>
|
|
{topSongs.length === 0 ? (
|
|
<p style={{ fontSize: '0.8rem', color: 'var(--text-muted)' }}>{t('statistics.noRatedSongs')}</p>
|
|
) : (
|
|
<ol style={{ listStyle: 'none', padding: 0, margin: 0, display: 'flex', flexDirection: 'column', gap: '0.75rem' }}>
|
|
{topSongs.map((song, i) => (
|
|
<li key={song.id} style={{ display: 'flex', alignItems: 'center', gap: '0.625rem', cursor: song.albumId ? 'pointer' : 'default' }}
|
|
onClick={() => song.albumId && navigate(`/album/${song.albumId}`)}>
|
|
<span style={{ fontSize: '1.1rem', fontWeight: 800, color: i === 0 ? 'var(--accent)' : 'var(--text-muted)', opacity: i === 0 ? 1 : 0.5, lineHeight: 1, flexShrink: 0, width: '1.5rem' }}>
|
|
{i + 1}
|
|
</span>
|
|
<div style={{ flex: 1, minWidth: 0 }}>
|
|
<div style={{ fontSize: '0.875rem', fontWeight: 500, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{song.title}</div>
|
|
<div style={{ fontSize: '0.75rem', color: 'var(--text-muted)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{song.artist}</div>
|
|
</div>
|
|
<span style={{ fontSize: '11px', color: 'var(--accent)', flexShrink: 0, letterSpacing: '1px' }}>
|
|
{'★'.repeat(song.userRating!)}{'☆'.repeat(5 - song.userRating!)}
|
|
</span>
|
|
</li>
|
|
))}
|
|
</ol>
|
|
)}
|
|
</div>
|
|
|
|
<div style={{ background: 'var(--glass-bg)', border: '1px solid var(--glass-border)', borderRadius: '12px', padding: '1.25rem', backdropFilter: 'blur(8px)' }}>
|
|
<h3 style={{ fontSize: '0.7rem', fontWeight: 700, textTransform: 'uppercase', letterSpacing: '0.1em', color: 'var(--accent)', marginBottom: '1rem' }}>
|
|
{t('statistics.topRatedArtists')}
|
|
</h3>
|
|
{topArtists.length === 0 ? (
|
|
<p style={{ fontSize: '0.8rem', color: 'var(--text-muted)' }}>{t('statistics.noRatedArtists')}</p>
|
|
) : (
|
|
<ol style={{ listStyle: 'none', padding: 0, margin: 0, display: 'flex', flexDirection: 'column', gap: '0.75rem' }}>
|
|
{topArtists.map((artist, i) => (
|
|
<li key={artist.id} style={{ display: 'flex', alignItems: 'center', gap: '0.625rem', cursor: 'pointer' }}
|
|
onClick={() => navigate(`/artist/${artist.id}`)}>
|
|
<span style={{ fontSize: '1.1rem', fontWeight: 800, color: i === 0 ? 'var(--accent)' : 'var(--text-muted)', opacity: i === 0 ? 1 : 0.5, lineHeight: 1, flexShrink: 0, width: '1.5rem' }}>
|
|
{i + 1}
|
|
</span>
|
|
<div style={{ flex: 1, minWidth: 0 }}>
|
|
<div style={{ fontSize: '0.875rem', fontWeight: 500, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{artist.name}</div>
|
|
{(artist.albumCount ?? 0) > 0 && (
|
|
<div style={{ fontSize: '0.75rem', color: 'var(--text-muted)' }}>
|
|
{t('artistDetail.albumCount_other', { count: artist.albumCount })}
|
|
</div>
|
|
)}
|
|
</div>
|
|
<span style={{ fontSize: '11px', color: 'var(--accent)', flexShrink: 0, letterSpacing: '1px' }}>
|
|
{'★'.repeat(artist.userRating!)}{'☆'.repeat(5 - artist.userRating!)}
|
|
</span>
|
|
</li>
|
|
))}
|
|
</ol>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Last.fm Stats */}
|
|
{lastfmIsConfigured() && (
|
|
<section style={{ marginTop: '2rem' }}>
|
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', flexWrap: 'wrap', gap: '0.75rem', marginBottom: '1rem' }}>
|
|
<h2 className="section-title" style={{ margin: 0 }}>{t('statistics.lfmTitle')}</h2>
|
|
{lastfmSessionKey && (
|
|
<div style={{ display: 'flex', gap: '0.375rem', flexWrap: 'wrap' }}>
|
|
{PERIODS.map(p => (
|
|
<button
|
|
key={p.key}
|
|
className={`btn btn-sm ${lfmPeriod === p.key ? 'btn-primary' : 'btn-surface'}`}
|
|
onClick={() => setLfmPeriod(p.key)}
|
|
style={{ padding: '0.25rem 0.625rem', fontSize: '0.75rem' }}
|
|
>
|
|
{t(`statistics.${p.label}`)}
|
|
</button>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{!lastfmSessionKey ? (
|
|
<p style={{ color: 'var(--text-muted)', fontSize: '0.875rem' }}>{t('statistics.lfmNotConnected')}</p>
|
|
) : lfmLoading ? (
|
|
<div style={{ display: 'flex', alignItems: 'center', gap: '0.75rem', color: 'var(--text-muted)', fontSize: '0.875rem', padding: '1rem 0' }}>
|
|
<div className="spinner" style={{ width: 16, height: 16, borderTopColor: 'currentColor' }} />
|
|
</div>
|
|
) : (
|
|
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(260px, 1fr))', gap: '1rem' }}>
|
|
{([
|
|
{ label: t('statistics.lfmTopArtists'), items: lfmTopArtists.map(a => ({ primary: a.name, secondary: null, playcount: a.playcount })) },
|
|
{ label: t('statistics.lfmTopAlbums'), items: lfmTopAlbums.map(a => ({ primary: a.name, secondary: a.artist, playcount: a.playcount })) },
|
|
{ label: t('statistics.lfmTopTracks'), items: lfmTopTracks.map(tr => ({ primary: tr.name, secondary: tr.artist, playcount: tr.playcount })) },
|
|
] as { label: string; items: { primary: string; secondary: string | null; playcount: string }[] }[]).map(col => {
|
|
const max = Math.max(...col.items.map(it => Number(it.playcount)), 1);
|
|
return (
|
|
<div key={col.label} style={{ background: 'var(--glass-bg)', border: '1px solid var(--glass-border)', borderRadius: '12px', padding: '1.25rem', backdropFilter: 'blur(8px)' }}>
|
|
<h3 style={{ fontSize: '0.7rem', fontWeight: 700, textTransform: 'uppercase', letterSpacing: '0.1em', color: 'var(--accent)', marginBottom: '1rem' }}>
|
|
{col.label}
|
|
</h3>
|
|
<ol style={{ listStyle: 'none', padding: 0, margin: 0, display: 'flex', flexDirection: 'column', gap: '0.875rem' }}>
|
|
{col.items.map((it, i) => (
|
|
<li key={`${it.primary}-${i}`}>
|
|
<div style={{ display: 'flex', alignItems: 'baseline', gap: '0.625rem', marginBottom: '0.25rem' }}>
|
|
<span style={{ fontSize: '1.1rem', fontWeight: 800, color: i === 0 ? 'var(--accent)' : 'var(--text-muted)', opacity: i === 0 ? 1 : 0.5, lineHeight: 1, flexShrink: 0, width: '1.5rem' }}>
|
|
{i + 1}
|
|
</span>
|
|
<div style={{ flex: 1, minWidth: 0 }}>
|
|
<div style={{ fontSize: '0.875rem', fontWeight: 500, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{it.primary}</div>
|
|
{it.secondary && (
|
|
<div style={{ fontSize: '0.75rem', color: 'var(--text-muted)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{it.secondary}</div>
|
|
)}
|
|
</div>
|
|
<span style={{ fontSize: '0.7rem', color: 'var(--text-muted)', flexShrink: 0 }}>{Number(it.playcount).toLocaleString()}</span>
|
|
</div>
|
|
<div style={{ height: '2px', borderRadius: '1px', background: 'var(--glass-border)', overflow: 'hidden', marginLeft: '2.125rem' }}>
|
|
<div style={{ height: '100%', width: `${(Number(it.playcount) / max) * 100}%`, background: i === 0 ? 'var(--accent)' : 'var(--text-muted)', opacity: i === 0 ? 0.8 : 0.3, borderRadius: '1px', transition: 'width 0.4s ease' }} />
|
|
</div>
|
|
</li>
|
|
))}
|
|
</ol>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
)}
|
|
</section>
|
|
)}
|
|
|
|
{/* Recent Scrobbles */}
|
|
{lastfmIsConfigured() && lastfmSessionKey && (
|
|
<section style={{ marginTop: '2rem' }}>
|
|
<h2 className="section-title" style={{ marginBottom: '1rem' }}>{t('statistics.lfmRecentTracks')}</h2>
|
|
{lfmRecentLoading ? (
|
|
<div style={{ display: 'flex', alignItems: 'center', gap: '0.75rem', color: 'var(--text-muted)', fontSize: '0.875rem' }}>
|
|
<div className="spinner" style={{ width: 16, height: 16, borderTopColor: 'currentColor' }} />
|
|
</div>
|
|
) : (
|
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.375rem' }}>
|
|
{lfmRecentTracks.slice(0, 3).map((track, i) => (
|
|
<div key={`${track.name}-${i}`} style={{ display: 'flex', alignItems: 'center', gap: '1rem', padding: '0.5rem 0.75rem', borderRadius: '8px', background: track.nowPlaying ? 'color-mix(in srgb, var(--accent) 8%, transparent)' : 'transparent', border: track.nowPlaying ? '1px solid color-mix(in srgb, var(--accent) 20%, transparent)' : '1px solid transparent' }}>
|
|
<div style={{ flex: 1, minWidth: 0 }}>
|
|
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
|
|
<span style={{ fontSize: '0.875rem', fontWeight: 500, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{track.name}</span>
|
|
{track.nowPlaying && (
|
|
<span style={{ fontSize: 10, fontWeight: 600, padding: '1px 6px', borderRadius: 4, background: 'var(--accent)', color: 'var(--ctp-base)', opacity: 0.85, letterSpacing: '0.04em', textTransform: 'uppercase', flexShrink: 0 }}>{t('statistics.lfmNowPlaying')}</span>
|
|
)}
|
|
</div>
|
|
<div style={{ fontSize: '0.75rem', color: 'var(--text-muted)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
|
{track.artist}{track.album ? ` · ${track.album}` : ''}
|
|
</div>
|
|
</div>
|
|
<span style={{ fontSize: '0.7rem', color: 'var(--text-muted)', flexShrink: 0 }}>
|
|
{track.nowPlaying ? '' : track.timestamp ? relativeTime(track.timestamp, t) : ''}
|
|
</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</section>
|
|
)}
|
|
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|