mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-22 07:15:47 +00:00
feat: Last.fm beta, Similar Artists, Statistics Last.fm stats, TooltipPortal, CustomSelect, Psychowave theme (v1.7.0)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -7,6 +7,8 @@ import { ArrowLeft, Users, ExternalLink, Star, Play, Shuffle, Radio } from 'luci
|
||||
import { open } from '@tauri-apps/plugin-shell';
|
||||
import { usePlayerStore } from '../store/playerStore';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { lastfmGetSimilarArtists, lastfmIsConfigured } from '../api/lastfm';
|
||||
import LastfmIcon from '../components/LastfmIcon';
|
||||
|
||||
function formatDuration(seconds: number): string {
|
||||
const m = Math.floor(seconds / 60);
|
||||
@@ -31,13 +33,6 @@ function sanitizeHtml(html: string): string {
|
||||
return doc.body.innerHTML;
|
||||
}
|
||||
|
||||
function LastfmIcon({ size = 16 }: { size?: number }) {
|
||||
return (
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
|
||||
<path d="M11.344 16.143l-.917-2.494s-1.485 1.662-3.716 1.662c-1.97 0-3.373-1.714-3.373-4.46 0-3.514 1.773-4.777 3.52-4.777 2.508 0 3.306 1.625 3.997 3.714l.918 2.88c.918 2.8 2.642 5.047 7.615 5.047 3.563 0 5.98-1.094 5.98-3.972 0-2.326-1.327-3.53-3.797-4.11l-1.836-.41c-1.27-.29-1.645-.82-1.645-1.693 0-.987.778-1.56 2.047-1.56 1.384 0 2.132.52 2.245 1.756l2.878-.347C24.883 5.116 23.3 4 20.5 4c-3.26 0-4.945 1.537-4.945 3.824 0 1.843.91 3.008 3.2 3.562l1.947.46c1.404.327 1.97.874 1.97 1.894 0 1.13-.988 1.593-2.948 1.593-2.858 0-4.052-1.497-4.742-3.634l-.943-2.887C13.22 6.162 11.73 4 7.897 4 3.847 4 1 6.61 1 11.022c0 4.235 2.617 6.638 6.19 6.638 2.566 0 4.154-1.517 4.154-1.517z"/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export default function ArtistDetail() {
|
||||
const { t } = useTranslation();
|
||||
@@ -52,6 +47,8 @@ export default function ArtistDetail() {
|
||||
const [radioLoading, setRadioLoading] = useState(false);
|
||||
const [isStarred, setIsStarred] = useState(false);
|
||||
const [openedLink, setOpenedLink] = useState<string | null>(null);
|
||||
const [similarArtists, setSimilarArtists] = useState<SubsonicArtist[]>([]);
|
||||
const [similarLoading, setSimilarLoading] = useState(false);
|
||||
|
||||
const playTrack = usePlayerStore(state => state.playTrack);
|
||||
const enqueue = usePlayerStore(state => state.enqueue);
|
||||
@@ -107,6 +104,32 @@ export default function ArtistDetail() {
|
||||
});
|
||||
}, [id]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!artist || !lastfmIsConfigured()) return;
|
||||
setSimilarArtists([]);
|
||||
setSimilarLoading(true);
|
||||
lastfmGetSimilarArtists(artist.name).then(async names => {
|
||||
if (names.length === 0) { setSimilarLoading(false); return; }
|
||||
const results = await Promise.all(
|
||||
names.slice(0, 30).map(name =>
|
||||
search(name, { artistCount: 3, albumCount: 0, songCount: 0 }).catch(() => ({ artists: [], albums: [], songs: [] }))
|
||||
)
|
||||
);
|
||||
const seen = new Set<string>([artist.id]);
|
||||
const found: SubsonicArtist[] = [];
|
||||
for (let i = 0; i < results.length; i++) {
|
||||
const targetName = names[i].toLowerCase();
|
||||
const match = results[i].artists.find(a => a.name.toLowerCase() === targetName);
|
||||
if (match && !seen.has(match.id)) {
|
||||
seen.add(match.id);
|
||||
found.push(match);
|
||||
}
|
||||
}
|
||||
setSimilarArtists(found);
|
||||
setSimilarLoading(false);
|
||||
}).catch(() => setSimilarLoading(false));
|
||||
}, [artist?.id]);
|
||||
|
||||
const openLink = (url: string, key: string) => {
|
||||
open(url);
|
||||
setOpenedLink(key);
|
||||
@@ -325,8 +348,35 @@ export default function ArtistDetail() {
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Similar Artists (Last.fm) */}
|
||||
{lastfmIsConfigured() && (similarLoading || similarArtists.length > 0) && (
|
||||
<>
|
||||
<h2 className="section-title" style={{ marginTop: '2rem', marginBottom: '1rem' }}>
|
||||
{t('artistDetail.similarArtists')}
|
||||
</h2>
|
||||
{similarLoading ? (
|
||||
<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' }} />
|
||||
{t('artistDetail.loading')}
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.5rem' }}>
|
||||
{similarArtists.map(a => (
|
||||
<button
|
||||
key={a.id}
|
||||
className="artist-ext-link"
|
||||
onClick={() => navigate(`/artist/${a.id}`)}
|
||||
>
|
||||
{a.name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Albums */}
|
||||
<h2 className="section-title" style={{ marginTop: (info?.biography || topSongs.length > 0) ? '2rem' : '0', marginBottom: '1rem' }}>
|
||||
<h2 className="section-title" style={{ marginTop: (info?.biography || topSongs.length > 0 || lastfmIsConfigured()) ? '2rem' : '0', marginBottom: '1rem' }}>
|
||||
{t('artistDetail.albumsBy', { name: artist.name })}
|
||||
</h2>
|
||||
|
||||
|
||||
@@ -71,9 +71,6 @@ const NpBg = memo(function NpBg({ url }: { url: string }) {
|
||||
style={{ backgroundImage: `url(${l.url})`, opacity: l.visible ? 1 : 0 }}
|
||||
/>
|
||||
))}
|
||||
<div className="np-orb np-orb-1" />
|
||||
<div className="np-orb np-orb-2" />
|
||||
<div className="np-orb np-orb-3" />
|
||||
<div className="np-bg-overlay" />
|
||||
</div>
|
||||
);
|
||||
@@ -319,7 +316,7 @@ export default function NowPlaying() {
|
||||
<button
|
||||
onClick={toggleStar}
|
||||
className="np-star-btn"
|
||||
title={starred ? t('contextMenu.unfavorite') : t('contextMenu.favorite')}
|
||||
data-tooltip={starred ? t('contextMenu.unfavorite') : t('contextMenu.favorite')}
|
||||
>
|
||||
<Star size={18} fill={starred ? 'var(--ctp-yellow)' : 'none'} color={starred ? 'var(--ctp-yellow)' : 'white'} />
|
||||
</button>
|
||||
@@ -347,16 +344,16 @@ export default function NowPlaying() {
|
||||
)}
|
||||
</div>
|
||||
<div className="np-queue-actions">
|
||||
<button onClick={() => shuffleQueue()} className="np-action-btn" title={t('queue.shuffle')} disabled={queue.length < 2}>
|
||||
<button onClick={() => shuffleQueue()} className="np-action-btn" data-tooltip={t('queue.shuffle')} disabled={queue.length < 2}>
|
||||
<Shuffle size={15} />
|
||||
</button>
|
||||
<button onClick={() => setSaveModalOpen(true)} className="np-action-btn" title={t('queue.save')} disabled={queue.length === 0}>
|
||||
<button onClick={() => setSaveModalOpen(true)} className="np-action-btn" data-tooltip={t('queue.save')} disabled={queue.length === 0}>
|
||||
<Save size={15} />
|
||||
</button>
|
||||
<button onClick={() => setLoadModalOpen(true)} className="np-action-btn" title={t('queue.load')}>
|
||||
<button onClick={() => setLoadModalOpen(true)} className="np-action-btn" data-tooltip={t('queue.load')}>
|
||||
<FolderOpen size={15} />
|
||||
</button>
|
||||
<button onClick={clearQueue} className="np-action-btn" title={t('queue.clear')} disabled={queue.length === 0}>
|
||||
<button onClick={clearQueue} className="np-action-btn" data-tooltip={t('queue.clear')} disabled={queue.length === 0}>
|
||||
<Trash2 size={15} />
|
||||
</button>
|
||||
</div>
|
||||
@@ -412,7 +409,7 @@ export default function NowPlaying() {
|
||||
<button
|
||||
className="np-queue-remove"
|
||||
onClick={e => { e.stopPropagation(); removeTrack(idx); }}
|
||||
title={t('queue.remove')}
|
||||
data-tooltip={t('queue.remove')}
|
||||
><X size={13} /></button>
|
||||
</div>
|
||||
);
|
||||
|
||||
+141
-41
@@ -1,4 +1,4 @@
|
||||
import React, { useState, useMemo } from 'react';
|
||||
import React, { useState, useMemo, useCallback, useEffect } from 'react';
|
||||
import { version as appVersion } from '../../package.json';
|
||||
import changelogRaw from '../../CHANGELOG.md?raw';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
@@ -7,6 +7,9 @@ import {
|
||||
Palette, Server, Plus, Trash2, Eye, EyeOff, Info, ExternalLink, Shuffle, X, Play
|
||||
} from 'lucide-react';
|
||||
import { open as openUrl } from '@tauri-apps/plugin-shell';
|
||||
import { lastfmGetToken, lastfmAuthUrl, lastfmGetSession, lastfmIsConfigured, lastfmGetUserInfo, LastfmUserInfo } from '../api/lastfm';
|
||||
import LastfmIcon from '../components/LastfmIcon';
|
||||
import CustomSelect from '../components/CustomSelect';
|
||||
import { useAuthStore, ServerProfile } from '../store/authStore';
|
||||
import { useThemeStore } from '../store/themeStore';
|
||||
import { pingWithCredentials } from '../api/subsonic';
|
||||
@@ -86,6 +89,57 @@ export default function Settings() {
|
||||
const [connStatus, setConnStatus] = useState<Record<string, 'idle' | 'testing' | 'ok' | 'error'>>({});
|
||||
const [showAddForm, setShowAddForm] = useState(false);
|
||||
const [newGenre, setNewGenre] = useState('');
|
||||
const [lfmState, setLfmState] = useState<'idle' | 'waiting' | 'error'>('idle');
|
||||
const [lfmPendingToken, setLfmPendingToken] = useState<string | null>(null);
|
||||
const [lfmError, setLfmError] = useState<string | null>(null);
|
||||
const [lfmUserInfo, setLfmUserInfo] = useState<LastfmUserInfo | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!auth.lastfmSessionKey || !auth.lastfmUsername) { setLfmUserInfo(null); return; }
|
||||
lastfmGetUserInfo(auth.lastfmUsername, auth.lastfmSessionKey).then(setLfmUserInfo).catch(() => {});
|
||||
}, [auth.lastfmSessionKey, auth.lastfmUsername]);
|
||||
|
||||
const startLastfmConnect = useCallback(async () => {
|
||||
setLfmError(null);
|
||||
let token: string;
|
||||
try {
|
||||
token = await lastfmGetToken();
|
||||
setLfmPendingToken(token);
|
||||
setLfmState('waiting');
|
||||
await openUrl(lastfmAuthUrl(token));
|
||||
} catch (e: any) {
|
||||
setLfmError(e.message ?? 'Unknown error');
|
||||
setLfmState('error');
|
||||
return;
|
||||
}
|
||||
|
||||
// Poll every 2 s until the user authorises or we time out (2 min)
|
||||
const deadline = Date.now() + 120_000;
|
||||
const poll = async () => {
|
||||
if (Date.now() > deadline) {
|
||||
setLfmState('error');
|
||||
setLfmError('Timed out — please try again.');
|
||||
setLfmPendingToken(null);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const { key, name } = await lastfmGetSession(token);
|
||||
auth.connectLastfm(key, name);
|
||||
setLfmState('idle');
|
||||
setLfmPendingToken(null);
|
||||
} catch (e: any) {
|
||||
// Error 14 = not yet authorised, keep polling
|
||||
if (e.message?.includes('14')) {
|
||||
setTimeout(poll, 2000);
|
||||
} else {
|
||||
setLfmState('error');
|
||||
setLfmError(e.message ?? 'Unknown error');
|
||||
setLfmPendingToken(null);
|
||||
}
|
||||
}
|
||||
};
|
||||
setTimeout(poll, 2000);
|
||||
}, [auth]);
|
||||
|
||||
const testConnection = async (server: ServerProfile) => {
|
||||
setConnStatus(s => ({ ...s, [server.id]: 'testing' }));
|
||||
@@ -275,31 +329,78 @@ export default function Settings() {
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Scrobbling */}
|
||||
{/* Last.fm */}
|
||||
<section className="settings-section">
|
||||
<div className="settings-section-header">
|
||||
<Music2 size={18} />
|
||||
<h2>{t('settings.lfmTitle')}</h2>
|
||||
</div>
|
||||
<div className="settings-card">
|
||||
<div style={{ fontSize: 13, color: 'var(--text-secondary)', marginBottom: '1rem', lineHeight: 1.5 }}>
|
||||
<p style={{ marginBottom: '0.5rem' }}>
|
||||
{t('settings.lfmDesc1')}{' '}
|
||||
<strong>{t('settings.lfmDesc1NavidromeWebplayer')}</strong>
|
||||
{' '}{t('settings.lfmDesc1b')}
|
||||
</p>
|
||||
<p>{t('settings.lfmDesc2')}</p>
|
||||
</div>
|
||||
<div className="settings-toggle-row" style={{ marginTop: '1rem' }}>
|
||||
<div>
|
||||
<div style={{ fontWeight: 500 }}>{t('settings.scrobbleEnabled')}</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>{t('settings.scrobbleDesc')}</div>
|
||||
{auth.lastfmSessionKey ? (
|
||||
/* ── Connected state ── */
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '1rem' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.75rem', padding: '0.75rem 1rem', borderRadius: '10px', background: 'color-mix(in srgb, var(--accent) 8%, transparent)', border: '1px solid color-mix(in srgb, var(--accent) 20%, transparent)' }}>
|
||||
<div style={{ flexShrink: 0, color: '#e31c23' }}><LastfmIcon size={20} /></div>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{ fontWeight: 600, fontSize: 14 }}>@{auth.lastfmUsername}</div>
|
||||
{lfmUserInfo && (
|
||||
<div style={{ fontSize: 12, color: 'var(--text-muted)', marginTop: 2, display: 'flex', gap: '0.75rem' }}>
|
||||
<span>{t('settings.lfmScrobbles', { count: lfmUserInfo.playcount.toLocaleString() })}</span>
|
||||
<span>{t('settings.lfmMemberSince', { year: new Date(lfmUserInfo.registeredAt * 1000).getFullYear() })}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
className="btn btn-ghost"
|
||||
style={{ fontSize: 12, padding: '4px 10px', flexShrink: 0 }}
|
||||
onClick={() => auth.disconnectLastfm()}
|
||||
>
|
||||
{t('settings.lfmDisconnect')}
|
||||
</button>
|
||||
</div>
|
||||
<div className="settings-toggle-row">
|
||||
<div>
|
||||
<div style={{ fontWeight: 500 }}>{t('settings.scrobbleEnabled')}</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>{t('settings.scrobbleDesc')}</div>
|
||||
</div>
|
||||
<label className="toggle-switch" aria-label={t('settings.scrobbleEnabled')}>
|
||||
<input type="checkbox" checked={auth.scrobblingEnabled} onChange={e => auth.setScrobblingEnabled(e.target.checked)} id="scrobbling-toggle" />
|
||||
<span className="toggle-track" />
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<label className="toggle-switch" aria-label={t('settings.scrobbleEnabled')}>
|
||||
<input type="checkbox" checked={auth.scrobblingEnabled} onChange={e => auth.setScrobblingEnabled(e.target.checked)} id="scrobbling-toggle" />
|
||||
<span className="toggle-track" />
|
||||
</label>
|
||||
</div>
|
||||
) : lfmState === 'waiting' ? (
|
||||
/* ── Waiting for browser auth — auto-polling ── */
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.75rem' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.6rem', fontSize: 13, color: 'var(--text-secondary)' }}>
|
||||
<div className="spinner" style={{ width: 16, height: 16, borderWidth: 2 }} />
|
||||
{t('settings.lfmConnecting')}
|
||||
</div>
|
||||
<button className="btn btn-ghost" style={{ alignSelf: 'flex-start', fontSize: 12 }}
|
||||
onClick={() => { setLfmState('idle'); setLfmPendingToken(null); }}>
|
||||
{t('common.cancel')}
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
/* ── Not connected ── */
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.75rem' }}>
|
||||
<p style={{ fontSize: 13, color: 'var(--text-secondary)', lineHeight: 1.5 }}>
|
||||
{t('settings.lfmConnectDesc')}
|
||||
</p>
|
||||
{lfmState === 'error' && (
|
||||
<p style={{ fontSize: 12, color: 'var(--danger)' }}>{lfmError}</p>
|
||||
)}
|
||||
{lastfmIsConfigured() ? (
|
||||
<button className="btn btn-primary" style={{ alignSelf: 'flex-start' }} onClick={startLastfmConnect}>
|
||||
{t('settings.lfmConnect')}
|
||||
</button>
|
||||
) : (
|
||||
<p style={{ fontSize: 12, color: 'var(--warning)' }}>
|
||||
VITE_LASTFM_API_KEY / VITE_LASTFM_API_SECRET not set in build environment.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
</>
|
||||
@@ -430,21 +531,21 @@ export default function Settings() {
|
||||
</div>
|
||||
<div className="settings-card">
|
||||
<div className="form-group" style={{ maxWidth: '300px' }}>
|
||||
<select
|
||||
className="input"
|
||||
<CustomSelect
|
||||
value={theme.theme}
|
||||
onChange={(e) => theme.setTheme(e.target.value as any)}
|
||||
aria-label={t('settings.theme')}
|
||||
>
|
||||
<option value="mocha">Catppuccin Mocha</option>
|
||||
<option value="macchiato">Catppuccin Macchiato</option>
|
||||
<option value="frappe">Catppuccin Frappé</option>
|
||||
<option value="latte">Catppuccin Latte</option>
|
||||
<option value="nord">Nord · Polar Night</option>
|
||||
<option value="nord-snowstorm">Nord · Snowstorm</option>
|
||||
<option value="nord-frost">Nord · Frost</option>
|
||||
<option value="nord-aurora">Nord · Aurora</option>
|
||||
</select>
|
||||
onChange={v => theme.setTheme(v as any)}
|
||||
options={[
|
||||
{ value: 'mocha', label: 'Catppuccin Mocha' },
|
||||
{ value: 'macchiato', label: 'Catppuccin Macchiato' },
|
||||
{ value: 'frappe', label: 'Catppuccin Frappé' },
|
||||
{ value: 'latte', label: 'Catppuccin Latte' },
|
||||
{ value: 'nord', label: 'Nord · Polar Night' },
|
||||
{ value: 'nord-snowstorm', label: 'Nord · Snowstorm' },
|
||||
{ value: 'nord-frost', label: 'Nord · Frost' },
|
||||
{ value: 'nord-aurora', label: 'Nord · Aurora' },
|
||||
{ value: 'psychowave', label: 'Psychowave' },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
@@ -456,15 +557,14 @@ export default function Settings() {
|
||||
</div>
|
||||
<div className="settings-card">
|
||||
<div className="form-group" style={{ maxWidth: '300px' }}>
|
||||
<select
|
||||
className="input"
|
||||
<CustomSelect
|
||||
value={i18n.language}
|
||||
onChange={(e) => i18n.changeLanguage(e.target.value)}
|
||||
aria-label={t('settings.language')}
|
||||
>
|
||||
<option value="en">{t('settings.languageEn')}</option>
|
||||
<option value="de">{t('settings.languageDe')}</option>
|
||||
</select>
|
||||
onChange={v => i18n.changeLanguage(v)}
|
||||
options={[
|
||||
{ value: 'en', label: t('settings.languageEn') },
|
||||
{ value: 'de', label: t('settings.languageDe') },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
+157
-33
@@ -1,34 +1,87 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { getAlbumList, getArtists, getGenres, SubsonicAlbum, SubsonicGenre } from '../api/subsonic';
|
||||
import { getAlbumList, getArtists, getGenres, SubsonicAlbum } from '../api/subsonic';
|
||||
import AlbumRow from '../components/AlbumRow';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useAuthStore } from '../store/authStore';
|
||||
import { lastfmIsConfigured, lastfmGetTopArtists, lastfmGetTopAlbums, lastfmGetTopTracks, lastfmGetRecentTracks, LastfmPeriod, LastfmTopArtist, LastfmTopAlbum, LastfmTopTrack, LastfmRecentTrack } from '../api/lastfm';
|
||||
|
||||
function relativeTime(timestamp: number, t: (key: string, opts?: object) => 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) });
|
||||
}
|
||||
|
||||
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 { lastfmSessionKey, lastfmUsername } = useAuthStore();
|
||||
const [recent, setRecent] = useState<SubsonicAlbum[]>([]);
|
||||
const [frequent, setFrequent] = useState<SubsonicAlbum[]>([]);
|
||||
const [highest, setHighest] = useState<SubsonicAlbum[]>([]);
|
||||
const [genres, setGenres] = useState<SubsonicGenre[]>([]);
|
||||
const [artistCount, setArtistCount] = useState<number | null>(null);
|
||||
const [totalSongs, setTotalSongs] = useState<number | null>(null);
|
||||
const [totalAlbums, setTotalAlbums] = useState<number | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
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(() => []),
|
||||
getGenres().catch(() => []),
|
||||
getArtists().catch(() => []),
|
||||
]).then(([rc, fr, hi, g, a]) => {
|
||||
getGenres().catch(() => []),
|
||||
]).then(([rc, fr, hi, a, g]) => {
|
||||
setRecent(rc);
|
||||
setFrequent(fr);
|
||||
setHighest(hi);
|
||||
setGenres(g.sort((a, b) => b.songCount - a.songCount).slice(0, 20));
|
||||
setArtistCount(a.length);
|
||||
setTotalSongs(g.reduce((acc: number, genre: any) => acc + genre.songCount, 0));
|
||||
setTotalAlbums(g.reduce((acc: number, genre: any) => acc + genre.albumCount, 0));
|
||||
setLoading(false);
|
||||
}).catch(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
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[],
|
||||
@@ -43,15 +96,10 @@ export default function Statistics() {
|
||||
}
|
||||
};
|
||||
|
||||
const totalSongs = genres.reduce((acc, g) => acc + g.songCount, 0);
|
||||
const totalAlbums = genres.reduce((acc, g) => acc + g.albumCount, 0);
|
||||
const maxGenreCount = Math.max(...genres.map(g => g.songCount), 1);
|
||||
|
||||
const stats = [
|
||||
{ label: t('statistics.statArtists'), value: artistCount },
|
||||
{ label: t('statistics.statAlbums'), value: totalAlbums || null },
|
||||
{ label: t('statistics.statSongs'), value: totalSongs || null },
|
||||
{ label: t('statistics.statGenres'), value: genres.length || null },
|
||||
{ label: t('statistics.statAlbums'), value: totalAlbums },
|
||||
{ label: t('statistics.statSongs'), value: totalSongs },
|
||||
];
|
||||
|
||||
return (
|
||||
@@ -90,29 +138,105 @@ export default function Statistics() {
|
||||
moreText={t('statistics.loadMore')}
|
||||
/>
|
||||
|
||||
{genres.length > 0 && (
|
||||
<section>
|
||||
<h2 className="section-title">{t('statistics.genreDistribution')}</h2>
|
||||
<div className="genre-chart">
|
||||
{genres.map(genre => (
|
||||
<div key={genre.value} className="genre-row">
|
||||
<div className="genre-row-header">
|
||||
<span className="genre-name">{genre.value}</span>
|
||||
<span className="genre-counts">
|
||||
{t('statistics.genreSongs', { count: genre.songCount })}
|
||||
{' · '}
|
||||
{t('statistics.genreAlbums', { count: genre.albumCount })}
|
||||
{/* 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.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: '0.65rem', fontWeight: 700, textTransform: 'uppercase', letterSpacing: '0.08em', color: 'var(--accent)', 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 className="genre-bar-track">
|
||||
<div
|
||||
className="genre-bar-fill"
|
||||
style={{ width: `${(genre.songCount / maxGenreCount) * 100}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
)}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user