mirror of
https://github.com/kilyabin/psysonic.git
synced 2026-07-22 06:25:41 +00:00
release: bump to v1.34.4
Song ratings in context menu + player bar, entity ratings (PR #130), 5 new seekbar styles, custom Linux title bar, album multi-select, mix rating filter, top-rated stats, compilation filter, scroll reset. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -14,9 +14,10 @@ interface AlbumCardProps {
|
||||
selected?: boolean;
|
||||
selectionMode?: boolean;
|
||||
onToggleSelect?: (id: string) => void;
|
||||
showRating?: boolean;
|
||||
}
|
||||
|
||||
function AlbumCard({ album, selected, selectionMode, onToggleSelect }: AlbumCardProps) {
|
||||
function AlbumCard({ album, selected, selectionMode, onToggleSelect, showRating = false }: AlbumCardProps) {
|
||||
const navigate = useNavigate();
|
||||
const openContextMenu = usePlayerStore(s => s.openContextMenu);
|
||||
const serverId = useAuthStore(s => s.activeServerId ?? '');
|
||||
@@ -103,6 +104,13 @@ function AlbumCard({ album, selected, selectionMode, onToggleSelect }: AlbumCard
|
||||
onClick={e => { if (album.artistId) { e.stopPropagation(); navigate(`/artist/${album.artistId}`); } }}
|
||||
>{album.artist}</p>
|
||||
{album.year && <p className="album-card-year">{album.year}</p>}
|
||||
{showRating && (album.userRating ?? 0) > 0 && (
|
||||
<div className="album-card-rating-row">
|
||||
<span className="album-card-rating-stars">
|
||||
{'★'.repeat(album.userRating!)}{'☆'.repeat(5 - album.userRating!)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -12,9 +12,10 @@ interface Props {
|
||||
moreLink?: string;
|
||||
moreText?: string;
|
||||
onLoadMore?: () => Promise<void>;
|
||||
showRating?: boolean;
|
||||
}
|
||||
|
||||
export default function AlbumRow({ title, titleLink, albums, moreLink, moreText, onLoadMore }: Props) {
|
||||
export default function AlbumRow({ title, titleLink, albums, moreLink, moreText, onLoadMore, showRating }: Props) {
|
||||
const { t } = useTranslation();
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const navigate = useNavigate();
|
||||
@@ -89,7 +90,7 @@ export default function AlbumRow({ title, titleLink, albums, moreLink, moreText,
|
||||
|
||||
<div className="album-grid-wrapper">
|
||||
<div className="album-grid" ref={scrollRef} onScroll={handleScroll}>
|
||||
{albums.map(a => <AlbumCard key={a.id} album={a} />)}
|
||||
{albums.map(a => <AlbumCard key={a.id} album={a} showRating={showRating} />)}
|
||||
{loadingMore && (
|
||||
<div className="album-card-more" style={{ cursor: 'default' }}>
|
||||
<div style={{ padding: '1rem', background: 'var(--bg-app)', borderRadius: '50%' }}>
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import React, { useEffect, useLayoutEffect, useRef, useState } from 'react';
|
||||
import { Play, ListPlus, Radio, Heart, Download, ChevronRight, User, Disc3, ListMusic, Plus, Info } from 'lucide-react';
|
||||
import LastfmIcon from './LastfmIcon';
|
||||
import StarRating from './StarRating';
|
||||
import { lastfmLoveTrack, lastfmUnloveTrack } from '../api/lastfm';
|
||||
import { usePlayerStore, Track, songToTrack } from '../store/playerStore';
|
||||
import { SubsonicAlbum, SubsonicArtist, star, unstar, getSimilarSongs2, getTopSongs, buildDownloadUrl, getAlbum, getPlaylists, getPlaylist, createPlaylist, updatePlaylist, SubsonicPlaylist } from '../api/subsonic';
|
||||
import { SubsonicAlbum, SubsonicArtist, star, unstar, getSimilarSongs2, getTopSongs, buildDownloadUrl, getAlbum, getPlaylists, getPlaylist, createPlaylist, updatePlaylist, SubsonicPlaylist, setRating } from '../api/subsonic';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useAuthStore } from '../store/authStore';
|
||||
import { useDownloadModalStore } from '../store/downloadModalStore';
|
||||
@@ -163,7 +164,7 @@ function AlbumToPlaylistSubmenu({ albumId, onDone }: { albumId: string; onDone:
|
||||
|
||||
export default function ContextMenu() {
|
||||
const { t } = useTranslation();
|
||||
const { contextMenu, closeContextMenu, playTrack, enqueue, queue, currentTrack, removeTrack, lastfmLovedCache, setLastfmLovedForSong, starredOverrides, setStarredOverride, openSongInfo } = usePlayerStore();
|
||||
const { contextMenu, closeContextMenu, playTrack, enqueue, queue, currentTrack, removeTrack, lastfmLovedCache, setLastfmLovedForSong, starredOverrides, setStarredOverride, openSongInfo, userRatingOverrides, setUserRatingOverride } = usePlayerStore();
|
||||
const auth = useAuthStore();
|
||||
const requestDownloadFolder = useDownloadModalStore(s => s.requestFolder);
|
||||
const navigate = useNavigate();
|
||||
@@ -381,6 +382,13 @@ export default function ContextMenu() {
|
||||
);
|
||||
})()}
|
||||
<div className="context-menu-divider" />
|
||||
<div className="context-menu-rating-row" onClick={e => e.stopPropagation()}>
|
||||
<StarRating
|
||||
value={userRatingOverrides[song.id] ?? song.userRating ?? 0}
|
||||
onChange={r => { setUserRatingOverride(song.id, r); setRating(song.id, r).catch(() => {}); }}
|
||||
ariaLabel={t('albumDetail.ratingLabel')}
|
||||
/>
|
||||
</div>
|
||||
<div className="context-menu-item" onClick={() => handleAction(() => openSongInfo(song.id))}>
|
||||
<Info size={14} /> {t('contextMenu.songInfo')}
|
||||
</div>
|
||||
@@ -501,6 +509,13 @@ export default function ContextMenu() {
|
||||
<Radio size={14} /> {t('contextMenu.startRadio')}
|
||||
</div>
|
||||
<div className="context-menu-divider" />
|
||||
<div className="context-menu-rating-row" onClick={e => e.stopPropagation()}>
|
||||
<StarRating
|
||||
value={userRatingOverrides[song.id] ?? song.userRating ?? 0}
|
||||
onChange={r => { setUserRatingOverride(song.id, r); setRating(song.id, r).catch(() => {}); }}
|
||||
ariaLabel={t('albumDetail.ratingLabel')}
|
||||
/>
|
||||
</div>
|
||||
<div className="context-menu-item" onClick={() => handleAction(() => openSongInfo(song.id))}>
|
||||
<Info size={14} /> {t('contextMenu.songInfo')}
|
||||
</div>
|
||||
|
||||
@@ -6,10 +6,11 @@ import {
|
||||
} from 'lucide-react';
|
||||
import { usePlayerStore } from '../store/playerStore';
|
||||
import { useAuthStore } from '../store/authStore';
|
||||
import { buildCoverArtUrl, coverArtCacheKey, star, unstar } from '../api/subsonic';
|
||||
import { buildCoverArtUrl, coverArtCacheKey, star, unstar, setRating } from '../api/subsonic';
|
||||
import CachedImage from './CachedImage';
|
||||
import WaveformSeek from './WaveformSeek';
|
||||
import Equalizer from './Equalizer';
|
||||
import StarRating from './StarRating';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useLyricsStore } from '../store/lyricsStore';
|
||||
@@ -37,6 +38,7 @@ export default function PlayerBar() {
|
||||
lastfmLoved, toggleLastfmLove,
|
||||
isQueueVisible, toggleQueue,
|
||||
starredOverrides, setStarredOverride,
|
||||
userRatingOverrides, setUserRatingOverride,
|
||||
} = usePlayerStore();
|
||||
const { lastfmSessionKey } = useAuthStore();
|
||||
|
||||
@@ -138,6 +140,14 @@ export default function PlayerBar() {
|
||||
style={{ cursor: !isRadio && currentTrack?.artistId ? 'pointer' : 'default' }}
|
||||
onClick={() => !isRadio && currentTrack?.artistId && navigate(`/artist/${currentTrack.artistId}`)}
|
||||
/>
|
||||
{currentTrack && !isRadio && (
|
||||
<StarRating
|
||||
value={userRatingOverrides[currentTrack.id] ?? currentTrack.userRating ?? 0}
|
||||
onChange={r => { setUserRatingOverride(currentTrack.id, r); setRating(currentTrack.id, r).catch(() => {}); }}
|
||||
className="player-track-rating"
|
||||
ariaLabel={t('albumDetail.ratingLabel')}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{currentTrack && !isRadio && (
|
||||
<button
|
||||
|
||||
@@ -729,6 +729,10 @@ export const deTranslation = {
|
||||
lfmMinutesAgo: 'vor {{n}} Min.',
|
||||
lfmHoursAgo: 'vor {{n}} Std.',
|
||||
lfmDaysAgo: 'vor {{n}} Tagen',
|
||||
topRatedSongs: 'Bestbewertete Songs',
|
||||
topRatedArtists: 'Bestbewertete Künstler',
|
||||
noRatedSongs: 'Noch keine bewerteten Songs. Songs in Album- oder Playlist-Ansicht bewerten.',
|
||||
noRatedArtists: 'Noch keine bewerteten Künstler.',
|
||||
},
|
||||
player: {
|
||||
regionLabel: 'Musikplayer',
|
||||
|
||||
@@ -730,6 +730,10 @@ export const enTranslation = {
|
||||
lfmMinutesAgo: '{{n}}m ago',
|
||||
lfmHoursAgo: '{{n}}h ago',
|
||||
lfmDaysAgo: '{{n}}d ago',
|
||||
topRatedSongs: 'Top Rated Songs',
|
||||
topRatedArtists: 'Top Rated Artists',
|
||||
noRatedSongs: 'No rated songs yet. Rate songs in album or playlist view.',
|
||||
noRatedArtists: 'No rated artists yet.',
|
||||
},
|
||||
player: {
|
||||
regionLabel: 'Music Player',
|
||||
|
||||
@@ -727,6 +727,10 @@ export const frTranslation = {
|
||||
lfmMinutesAgo: 'il y a {{n}} min',
|
||||
lfmHoursAgo: 'il y a {{n}}h',
|
||||
lfmDaysAgo: 'il y a {{n}}j',
|
||||
topRatedSongs: 'Morceaux les mieux notés',
|
||||
topRatedArtists: 'Artistes les mieux notés',
|
||||
noRatedSongs: 'Aucun morceau noté. Notez des morceaux dans la vue album ou playlist.',
|
||||
noRatedArtists: 'Aucun artiste noté.',
|
||||
},
|
||||
player: {
|
||||
regionLabel: 'Lecteur de musique',
|
||||
|
||||
@@ -726,6 +726,10 @@ export const nbTranslation = {
|
||||
lfmMinutesAgo: 'For {{n}}m siden',
|
||||
lfmHoursAgo: 'For {{n}}t siden',
|
||||
lfmDaysAgo: 'For {{n}}d siden',
|
||||
topRatedSongs: 'Høyest vurderte spor',
|
||||
topRatedArtists: 'Høyest vurderte artister',
|
||||
noRatedSongs: 'Ingen vurderte spor ennå. Vurder spor i album- eller spillelistevisning.',
|
||||
noRatedArtists: 'Ingen vurderte artister ennå.',
|
||||
},
|
||||
player: {
|
||||
regionLabel: 'Musikkspiller',
|
||||
|
||||
@@ -727,6 +727,10 @@ export const nlTranslation = {
|
||||
lfmMinutesAgo: '{{n}} min geleden',
|
||||
lfmHoursAgo: '{{n}} uur geleden',
|
||||
lfmDaysAgo: '{{n}} dagen geleden',
|
||||
topRatedSongs: 'Best beoordeelde nummers',
|
||||
topRatedArtists: 'Best beoordeelde artiesten',
|
||||
noRatedSongs: 'Nog geen beoordeelde nummers. Beoordeel nummers in album- of afspeellijstweergave.',
|
||||
noRatedArtists: 'Nog geen beoordeelde artiesten.',
|
||||
},
|
||||
player: {
|
||||
regionLabel: 'Muziekspeler',
|
||||
|
||||
@@ -787,6 +787,10 @@ export const ruTranslation = {
|
||||
lfmMinutesAgo: '{{n}} мин назад',
|
||||
lfmHoursAgo: '{{n}} ч назад',
|
||||
lfmDaysAgo: '{{n}} дн. назад',
|
||||
topRatedSongs: 'Лучшие по рейтингу треки',
|
||||
topRatedArtists: 'Лучшие по рейтингу исполнители',
|
||||
noRatedSongs: 'Нет оценённых треков. Ставьте оценки в альбомах или плейлистах.',
|
||||
noRatedArtists: 'Нет оценённых исполнителей.',
|
||||
},
|
||||
player: {
|
||||
regionLabel: 'Плеер',
|
||||
|
||||
@@ -723,6 +723,10 @@ export const zhTranslation = {
|
||||
lfmMinutesAgo: '{{n}} 分钟前',
|
||||
lfmHoursAgo: '{{n}} 小时前',
|
||||
lfmDaysAgo: '{{n}} 天前',
|
||||
topRatedSongs: '最高评分歌曲',
|
||||
topRatedArtists: '最高评分艺人',
|
||||
noRatedSongs: '暂无已评分歌曲。请在专辑或播放列表中为歌曲评分。',
|
||||
noRatedArtists: '暂无已评分艺人。',
|
||||
},
|
||||
player: {
|
||||
regionLabel: '音乐播放器',
|
||||
|
||||
+20
-2
@@ -4,11 +4,12 @@ import AlbumRow from '../components/AlbumRow';
|
||||
import ArtistRow from '../components/ArtistRow';
|
||||
import CachedImage from '../components/CachedImage';
|
||||
import {
|
||||
getStarred, getInternetRadioStations,
|
||||
getStarred, getInternetRadioStations, setRating,
|
||||
SubsonicAlbum, SubsonicArtist, SubsonicSong, InternetRadioStation,
|
||||
buildCoverArtUrl, coverArtCacheKey,
|
||||
} from '../api/subsonic';
|
||||
import { usePlayerStore, songToTrack } from '../store/playerStore';
|
||||
import StarRating from '../components/StarRating';
|
||||
import { Cast, ChevronDown, ChevronLeft, ChevronRight, Check, Heart, ListPlus, Play, Star, X } from 'lucide-react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
@@ -20,6 +21,7 @@ const FAV_COLUMNS: readonly ColDef[] = [
|
||||
{ key: 'num', i18nKey: null, minWidth: 60, defaultWidth: 60, required: true },
|
||||
{ key: 'title', i18nKey: 'trackTitle', minWidth: 150, defaultWidth: 0, required: true, flex: true },
|
||||
{ key: 'artist', i18nKey: 'trackArtist', minWidth: 80, defaultWidth: 180, required: false },
|
||||
{ key: 'rating', i18nKey: 'trackRating', minWidth: 80, defaultWidth: 120, required: false },
|
||||
{ key: 'duration', i18nKey: 'trackDuration', minWidth: 72, defaultWidth: 92, required: false },
|
||||
{ key: 'remove', i18nKey: null, minWidth: 36, defaultWidth: 36, required: true },
|
||||
];
|
||||
@@ -39,14 +41,23 @@ export default function Favorites() {
|
||||
pickerOpen, setPickerOpen, pickerRef, tracklistRef,
|
||||
} = useTracklistColumns(FAV_COLUMNS, 'psysonic_favorites_columns');
|
||||
|
||||
const [ratings, setRatings] = useState<Record<string, number>>({});
|
||||
|
||||
const { playTrack, enqueue, playRadio, stop } = usePlayerStore();
|
||||
const currentTrack = usePlayerStore(s => s.currentTrack);
|
||||
const currentRadio = usePlayerStore(s => s.currentRadio);
|
||||
const isPlaying = usePlayerStore(s => s.isPlaying);
|
||||
const starredOverrides = usePlayerStore(s => s.starredOverrides);
|
||||
const setStarredOverride = usePlayerStore(s => s.setStarredOverride);
|
||||
const userRatingOverrides = usePlayerStore(s => s.userRatingOverrides);
|
||||
const psyDrag = useDragDrop();
|
||||
|
||||
const handleRate = (songId: string, rating: number) => {
|
||||
setRatings(r => ({ ...r, [songId]: rating }));
|
||||
usePlayerStore.getState().setUserRatingOverride(songId, rating);
|
||||
setRating(songId, rating).catch(() => {});
|
||||
};
|
||||
|
||||
function removeSong(id: string) {
|
||||
unstar(id, 'song').catch(() => {});
|
||||
setStarredOverride(id, false);
|
||||
@@ -179,7 +190,7 @@ export default function Favorites() {
|
||||
);
|
||||
}
|
||||
if (key === 'remove') return <div key="remove" />;
|
||||
const isCentered = key === 'duration';
|
||||
const isCentered = key === 'duration' || key === 'rating';
|
||||
return (
|
||||
<div key={key} style={{ position: 'relative', padding: 0, margin: 0, minWidth: 0, overflow: 'hidden' }}>
|
||||
<div
|
||||
@@ -264,6 +275,13 @@ export default function Favorites() {
|
||||
<span className={`track-artist${song.artistId ? ' track-artist-link' : ''}`} style={{ cursor: song.artistId ? 'pointer' : 'default' }} onClick={() => song.artistId && navigate(`/artist/${song.artistId}`)}>{song.artist}</span>
|
||||
</div>
|
||||
);
|
||||
case 'rating': return (
|
||||
<StarRating
|
||||
key="rating"
|
||||
value={ratings[song.id] ?? userRatingOverrides[song.id] ?? song.userRating ?? 0}
|
||||
onChange={r => handleRate(song.id, r)}
|
||||
/>
|
||||
);
|
||||
case 'duration': return (
|
||||
<div key="duration" className="track-duration">
|
||||
{Math.floor(song.duration / 60)}:{(song.duration % 60).toString().padStart(2, '0')}
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { getAlbumList, getArtists, getGenres, getRandomSongs, SubsonicAlbum, SubsonicGenre } from '../api/subsonic';
|
||||
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
|
||||
@@ -32,11 +34,33 @@ const PERIODS: { key: LastfmPeriod; label: string }[] = [
|
||||
|
||||
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(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);
|
||||
@@ -63,7 +87,8 @@ export default function Statistics() {
|
||||
getAlbumList('highest', 12).catch(() => []),
|
||||
getArtists().catch(() => []),
|
||||
getGenres().catch(() => []),
|
||||
]).then(([rc, fr, hi, a, g]) => {
|
||||
getStarred().catch(() => ({ albums: [], artists: [], songs: [] })),
|
||||
]).then(([rc, fr, hi, a, g, starred]) => {
|
||||
setRecent(rc);
|
||||
setFrequent(fr);
|
||||
setHighest(hi);
|
||||
@@ -72,6 +97,13 @@ export default function Statistics() {
|
||||
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]);
|
||||
@@ -282,8 +314,70 @@ export default function Statistics() {
|
||||
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' }}>
|
||||
@@ -363,7 +457,7 @@ export default function Statistics() {
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.375rem' }}>
|
||||
{lfmRecentTracks.map((track, i) => (
|
||||
{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' }}>
|
||||
|
||||
@@ -589,6 +589,20 @@
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.album-card-rating-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.album-card-rating-stars {
|
||||
font-size: 11px;
|
||||
color: var(--accent);
|
||||
letter-spacing: 1px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
/* ─ Live Search ─ */
|
||||
.live-search {
|
||||
position: relative;
|
||||
@@ -3621,6 +3635,15 @@
|
||||
margin: var(--space-1) 0;
|
||||
}
|
||||
|
||||
.context-menu-rating-row {
|
||||
padding: 6px 12px;
|
||||
}
|
||||
|
||||
.context-menu-rating-row .star-rating {
|
||||
width: auto;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
/* ─ CSS Tooltips ─ */
|
||||
/* Tooltips are handled by TooltipPortal (React portal) — no CSS pseudo-elements needed */
|
||||
[data-tooltip] {
|
||||
|
||||
@@ -934,6 +934,17 @@
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.player-track-rating {
|
||||
margin-top: 4px;
|
||||
width: auto;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.player-track-rating .star {
|
||||
font-size: 11px;
|
||||
padding: 0 1px;
|
||||
}
|
||||
|
||||
/* ── Marquee (PlayerBar track name / artist) ── */
|
||||
.marquee-wrap {
|
||||
overflow: hidden;
|
||||
|
||||
Reference in New Issue
Block a user