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:
Psychotoxical
2026-04-08 11:13:23 +02:00
parent 737b057d4a
commit dbb53bfa70
20 changed files with 258 additions and 16 deletions
+34
View File
@@ -5,6 +5,40 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [1.34.4] - 2026-04-08
### Added
- **Entity ratings** *(PR [#130](https://github.com/Psychotoxical/psysonic/pull/130))*: Full star-rating support (15 ★) for songs, albums, and artists via the OpenSubsonic `setRating` API. Ratings are shown and editable in the album track list, artist detail page, and the Favorites song list. A new shared `StarRating` component is used consistently across all surfaces. Requires an OpenSubsonic-compatible server (e.g. Navidrome ≥ 0.53).
- **Song ratings — context menu & player bar**: Songs can additionally be rated directly from the **right-click context menu** and from the **player bar** (below the artist name), with optimistic updates reflected immediately across all views.
- **Skip-to-1★** *(PR [#130](https://github.com/Psychotoxical/psysonic/pull/130))*: Automatically assigns a 1-star rating when a song is manually skipped before a configurable playback threshold (default: 20 s). Can be enabled and adjusted in Settings → Ratings.
- **Mix minimum rating filter** *(PR [#130](https://github.com/Psychotoxical/psysonic/pull/130))*: Random Mix and Home Quick Mix can now be filtered by minimum rating per entity type (song / album / artist). Configure thresholds in Settings → Ratings.
- **Statistics — Top Rated Songs & Artists**: New "Top Rated Songs" and "Top Rated Artists" sections on the Statistics page, derived from starred items with a `userRating > 0`. Lists update live as ratings are changed without a page reload.
- **Seekbar styles — 5 new styles**: Added Neon Glow, Pulse Wave, Particle Trail, Liquid Fill, and Retro Tape. Animated styles run a dedicated `requestAnimationFrame` loop. The style picker in Settings shows an animated live preview for each style.
- **Custom title bar (Linux)**: Optional custom title bar with now-playing display (song title + artist, live-updating). Replaces the native GTK decoration when enabled. Automatically hides in native fullscreen (F11). Can be toggled in Settings → Appearance.
- **Album multi-select**: Albums, New Releases, and Random Albums pages now support multi-select mode. Selected albums can be batch-queued or enqueued.
- **Most Played — compilation filter**: New toggle on the Most Played page to hide compilation artists from the Top Artists list.
- **Scroll reset on navigation**: The content area now scrolls back to the top automatically on every route change.
### Fixed
- **Backup**: The `psysonic_home` key is now included in the settings backup export.
### i18n
- New keys for seekbar styles, entity ratings, rating sections (Settings + Statistics), and entity rating support added to all 7 languages (EN, DE, FR, NL, ZH, NB, RU).
---
## [1.34.3] - 2026-04-07 ## [1.34.3] - 2026-04-07
### Added ### Added
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "psysonic", "name": "psysonic",
"version": "1.34.3", "version": "1.34.4",
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
+1 -1
View File
@@ -3339,7 +3339,7 @@ dependencies = [
[[package]] [[package]]
name = "psysonic" name = "psysonic"
version = "1.34.3" version = "1.34.4"
dependencies = [ dependencies = [
"biquad", "biquad",
"discord-rich-presence", "discord-rich-presence",
+1 -1
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "psysonic" name = "psysonic"
version = "1.34.3" version = "1.34.4"
description = "Psysonic Desktop Music Player" description = "Psysonic Desktop Music Player"
authors = [] authors = []
license = "" license = ""
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"$schema": "https://schema.tauri.app/config/2", "$schema": "https://schema.tauri.app/config/2",
"productName": "Psysonic", "productName": "Psysonic",
"version": "1.34.3", "version": "1.34.4",
"identifier": "dev.psysonic.player", "identifier": "dev.psysonic.player",
"build": { "build": {
"beforeDevCommand": "npm run dev", "beforeDevCommand": "npm run dev",
+9 -1
View File
@@ -14,9 +14,10 @@ interface AlbumCardProps {
selected?: boolean; selected?: boolean;
selectionMode?: boolean; selectionMode?: boolean;
onToggleSelect?: (id: string) => void; 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 navigate = useNavigate();
const openContextMenu = usePlayerStore(s => s.openContextMenu); const openContextMenu = usePlayerStore(s => s.openContextMenu);
const serverId = useAuthStore(s => s.activeServerId ?? ''); 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}`); } }} onClick={e => { if (album.artistId) { e.stopPropagation(); navigate(`/artist/${album.artistId}`); } }}
>{album.artist}</p> >{album.artist}</p>
{album.year && <p className="album-card-year">{album.year}</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>
</div> </div>
); );
+3 -2
View File
@@ -12,9 +12,10 @@ interface Props {
moreLink?: string; moreLink?: string;
moreText?: string; moreText?: string;
onLoadMore?: () => Promise<void>; 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 { t } = useTranslation();
const scrollRef = useRef<HTMLDivElement>(null); const scrollRef = useRef<HTMLDivElement>(null);
const navigate = useNavigate(); 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-wrapper">
<div className="album-grid" ref={scrollRef} onScroll={handleScroll}> <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 && ( {loadingMore && (
<div className="album-card-more" style={{ cursor: 'default' }}> <div className="album-card-more" style={{ cursor: 'default' }}>
<div style={{ padding: '1rem', background: 'var(--bg-app)', borderRadius: '50%' }}> <div style={{ padding: '1rem', background: 'var(--bg-app)', borderRadius: '50%' }}>
+17 -2
View File
@@ -1,9 +1,10 @@
import React, { useEffect, useLayoutEffect, useRef, useState } from 'react'; import React, { useEffect, useLayoutEffect, useRef, useState } from 'react';
import { Play, ListPlus, Radio, Heart, Download, ChevronRight, User, Disc3, ListMusic, Plus, Info } from 'lucide-react'; import { Play, ListPlus, Radio, Heart, Download, ChevronRight, User, Disc3, ListMusic, Plus, Info } from 'lucide-react';
import LastfmIcon from './LastfmIcon'; import LastfmIcon from './LastfmIcon';
import StarRating from './StarRating';
import { lastfmLoveTrack, lastfmUnloveTrack } from '../api/lastfm'; import { lastfmLoveTrack, lastfmUnloveTrack } from '../api/lastfm';
import { usePlayerStore, Track, songToTrack } from '../store/playerStore'; 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 { useNavigate } from 'react-router-dom';
import { useAuthStore } from '../store/authStore'; import { useAuthStore } from '../store/authStore';
import { useDownloadModalStore } from '../store/downloadModalStore'; import { useDownloadModalStore } from '../store/downloadModalStore';
@@ -163,7 +164,7 @@ function AlbumToPlaylistSubmenu({ albumId, onDone }: { albumId: string; onDone:
export default function ContextMenu() { export default function ContextMenu() {
const { t } = useTranslation(); 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 auth = useAuthStore();
const requestDownloadFolder = useDownloadModalStore(s => s.requestFolder); const requestDownloadFolder = useDownloadModalStore(s => s.requestFolder);
const navigate = useNavigate(); const navigate = useNavigate();
@@ -381,6 +382,13 @@ export default function ContextMenu() {
); );
})()} })()}
<div className="context-menu-divider" /> <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))}> <div className="context-menu-item" onClick={() => handleAction(() => openSongInfo(song.id))}>
<Info size={14} /> {t('contextMenu.songInfo')} <Info size={14} /> {t('contextMenu.songInfo')}
</div> </div>
@@ -501,6 +509,13 @@ export default function ContextMenu() {
<Radio size={14} /> {t('contextMenu.startRadio')} <Radio size={14} /> {t('contextMenu.startRadio')}
</div> </div>
<div className="context-menu-divider" /> <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))}> <div className="context-menu-item" onClick={() => handleAction(() => openSongInfo(song.id))}>
<Info size={14} /> {t('contextMenu.songInfo')} <Info size={14} /> {t('contextMenu.songInfo')}
</div> </div>
+11 -1
View File
@@ -6,10 +6,11 @@ import {
} from 'lucide-react'; } from 'lucide-react';
import { usePlayerStore } from '../store/playerStore'; import { usePlayerStore } from '../store/playerStore';
import { useAuthStore } from '../store/authStore'; 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 CachedImage from './CachedImage';
import WaveformSeek from './WaveformSeek'; import WaveformSeek from './WaveformSeek';
import Equalizer from './Equalizer'; import Equalizer from './Equalizer';
import StarRating from './StarRating';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { useLyricsStore } from '../store/lyricsStore'; import { useLyricsStore } from '../store/lyricsStore';
@@ -37,6 +38,7 @@ export default function PlayerBar() {
lastfmLoved, toggleLastfmLove, lastfmLoved, toggleLastfmLove,
isQueueVisible, toggleQueue, isQueueVisible, toggleQueue,
starredOverrides, setStarredOverride, starredOverrides, setStarredOverride,
userRatingOverrides, setUserRatingOverride,
} = usePlayerStore(); } = usePlayerStore();
const { lastfmSessionKey } = useAuthStore(); const { lastfmSessionKey } = useAuthStore();
@@ -138,6 +140,14 @@ export default function PlayerBar() {
style={{ cursor: !isRadio && currentTrack?.artistId ? 'pointer' : 'default' }} style={{ cursor: !isRadio && currentTrack?.artistId ? 'pointer' : 'default' }}
onClick={() => !isRadio && currentTrack?.artistId && navigate(`/artist/${currentTrack.artistId}`)} 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> </div>
{currentTrack && !isRadio && ( {currentTrack && !isRadio && (
<button <button
+4
View File
@@ -729,6 +729,10 @@ export const deTranslation = {
lfmMinutesAgo: 'vor {{n}} Min.', lfmMinutesAgo: 'vor {{n}} Min.',
lfmHoursAgo: 'vor {{n}} Std.', lfmHoursAgo: 'vor {{n}} Std.',
lfmDaysAgo: 'vor {{n}} Tagen', 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: { player: {
regionLabel: 'Musikplayer', regionLabel: 'Musikplayer',
+4
View File
@@ -730,6 +730,10 @@ export const enTranslation = {
lfmMinutesAgo: '{{n}}m ago', lfmMinutesAgo: '{{n}}m ago',
lfmHoursAgo: '{{n}}h ago', lfmHoursAgo: '{{n}}h ago',
lfmDaysAgo: '{{n}}d 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: { player: {
regionLabel: 'Music Player', regionLabel: 'Music Player',
+4
View File
@@ -727,6 +727,10 @@ export const frTranslation = {
lfmMinutesAgo: 'il y a {{n}} min', lfmMinutesAgo: 'il y a {{n}} min',
lfmHoursAgo: 'il y a {{n}}h', lfmHoursAgo: 'il y a {{n}}h',
lfmDaysAgo: 'il y a {{n}}j', 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: { player: {
regionLabel: 'Lecteur de musique', regionLabel: 'Lecteur de musique',
+4
View File
@@ -726,6 +726,10 @@ export const nbTranslation = {
lfmMinutesAgo: 'For {{n}}m siden', lfmMinutesAgo: 'For {{n}}m siden',
lfmHoursAgo: 'For {{n}}t siden', lfmHoursAgo: 'For {{n}}t siden',
lfmDaysAgo: 'For {{n}}d 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: { player: {
regionLabel: 'Musikkspiller', regionLabel: 'Musikkspiller',
+4
View File
@@ -727,6 +727,10 @@ export const nlTranslation = {
lfmMinutesAgo: '{{n}} min geleden', lfmMinutesAgo: '{{n}} min geleden',
lfmHoursAgo: '{{n}} uur geleden', lfmHoursAgo: '{{n}} uur geleden',
lfmDaysAgo: '{{n}} dagen 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: { player: {
regionLabel: 'Muziekspeler', regionLabel: 'Muziekspeler',
+4
View File
@@ -787,6 +787,10 @@ export const ruTranslation = {
lfmMinutesAgo: '{{n}} мин назад', lfmMinutesAgo: '{{n}} мин назад',
lfmHoursAgo: '{{n}} ч назад', lfmHoursAgo: '{{n}} ч назад',
lfmDaysAgo: '{{n}} дн. назад', lfmDaysAgo: '{{n}} дн. назад',
topRatedSongs: 'Лучшие по рейтингу треки',
topRatedArtists: 'Лучшие по рейтингу исполнители',
noRatedSongs: 'Нет оценённых треков. Ставьте оценки в альбомах или плейлистах.',
noRatedArtists: 'Нет оценённых исполнителей.',
}, },
player: { player: {
regionLabel: 'Плеер', regionLabel: 'Плеер',
+4
View File
@@ -723,6 +723,10 @@ export const zhTranslation = {
lfmMinutesAgo: '{{n}} 分钟前', lfmMinutesAgo: '{{n}} 分钟前',
lfmHoursAgo: '{{n}} 小时前', lfmHoursAgo: '{{n}} 小时前',
lfmDaysAgo: '{{n}} 天前', lfmDaysAgo: '{{n}} 天前',
topRatedSongs: '最高评分歌曲',
topRatedArtists: '最高评分艺人',
noRatedSongs: '暂无已评分歌曲。请在专辑或播放列表中为歌曲评分。',
noRatedArtists: '暂无已评分艺人。',
}, },
player: { player: {
regionLabel: '音乐播放器', regionLabel: '音乐播放器',
+20 -2
View File
@@ -4,11 +4,12 @@ import AlbumRow from '../components/AlbumRow';
import ArtistRow from '../components/ArtistRow'; import ArtistRow from '../components/ArtistRow';
import CachedImage from '../components/CachedImage'; import CachedImage from '../components/CachedImage';
import { import {
getStarred, getInternetRadioStations, getStarred, getInternetRadioStations, setRating,
SubsonicAlbum, SubsonicArtist, SubsonicSong, InternetRadioStation, SubsonicAlbum, SubsonicArtist, SubsonicSong, InternetRadioStation,
buildCoverArtUrl, coverArtCacheKey, buildCoverArtUrl, coverArtCacheKey,
} from '../api/subsonic'; } from '../api/subsonic';
import { usePlayerStore, songToTrack } from '../store/playerStore'; 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 { Cast, ChevronDown, ChevronLeft, ChevronRight, Check, Heart, ListPlus, Play, Star, X } from 'lucide-react';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next'; 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: 'num', i18nKey: null, minWidth: 60, defaultWidth: 60, required: true },
{ key: 'title', i18nKey: 'trackTitle', minWidth: 150, defaultWidth: 0, required: true, flex: true }, { key: 'title', i18nKey: 'trackTitle', minWidth: 150, defaultWidth: 0, required: true, flex: true },
{ key: 'artist', i18nKey: 'trackArtist', minWidth: 80, defaultWidth: 180, required: false }, { 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: 'duration', i18nKey: 'trackDuration', minWidth: 72, defaultWidth: 92, required: false },
{ key: 'remove', i18nKey: null, minWidth: 36, defaultWidth: 36, required: true }, { key: 'remove', i18nKey: null, minWidth: 36, defaultWidth: 36, required: true },
]; ];
@@ -39,14 +41,23 @@ export default function Favorites() {
pickerOpen, setPickerOpen, pickerRef, tracklistRef, pickerOpen, setPickerOpen, pickerRef, tracklistRef,
} = useTracklistColumns(FAV_COLUMNS, 'psysonic_favorites_columns'); } = useTracklistColumns(FAV_COLUMNS, 'psysonic_favorites_columns');
const [ratings, setRatings] = useState<Record<string, number>>({});
const { playTrack, enqueue, playRadio, stop } = usePlayerStore(); const { playTrack, enqueue, playRadio, stop } = usePlayerStore();
const currentTrack = usePlayerStore(s => s.currentTrack); const currentTrack = usePlayerStore(s => s.currentTrack);
const currentRadio = usePlayerStore(s => s.currentRadio); const currentRadio = usePlayerStore(s => s.currentRadio);
const isPlaying = usePlayerStore(s => s.isPlaying); const isPlaying = usePlayerStore(s => s.isPlaying);
const starredOverrides = usePlayerStore(s => s.starredOverrides); const starredOverrides = usePlayerStore(s => s.starredOverrides);
const setStarredOverride = usePlayerStore(s => s.setStarredOverride); const setStarredOverride = usePlayerStore(s => s.setStarredOverride);
const userRatingOverrides = usePlayerStore(s => s.userRatingOverrides);
const psyDrag = useDragDrop(); 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) { function removeSong(id: string) {
unstar(id, 'song').catch(() => {}); unstar(id, 'song').catch(() => {});
setStarredOverride(id, false); setStarredOverride(id, false);
@@ -179,7 +190,7 @@ export default function Favorites() {
); );
} }
if (key === 'remove') return <div key="remove" />; if (key === 'remove') return <div key="remove" />;
const isCentered = key === 'duration'; const isCentered = key === 'duration' || key === 'rating';
return ( return (
<div key={key} style={{ position: 'relative', padding: 0, margin: 0, minWidth: 0, overflow: 'hidden' }}> <div key={key} style={{ position: 'relative', padding: 0, margin: 0, minWidth: 0, overflow: 'hidden' }}>
<div <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> <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> </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 ( case 'duration': return (
<div key="duration" className="track-duration"> <div key="duration" className="track-duration">
{Math.floor(song.duration / 60)}:{(song.duration % 60).toString().padStart(2, '0')} {Math.floor(song.duration / 60)}:{(song.duration % 60).toString().padStart(2, '0')}
+98 -4
View File
@@ -1,8 +1,10 @@
import React, { useEffect, useState } from 'react'; import React, { useEffect, useMemo, useState } from 'react';
import { getAlbumList, getArtists, getGenres, getRandomSongs, SubsonicAlbum, SubsonicGenre } from '../api/subsonic'; import { getAlbumList, getArtists, getGenres, getRandomSongs, getStarred, SubsonicAlbum, SubsonicArtist, SubsonicGenre, SubsonicSong } from '../api/subsonic';
import AlbumRow from '../components/AlbumRow'; import AlbumRow from '../components/AlbumRow';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { useAuthStore } from '../store/authStore'; 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'; import { lastfmIsConfigured, lastfmGetTopArtists, lastfmGetTopAlbums, lastfmGetTopTracks, lastfmGetRecentTracks, LastfmPeriod, LastfmTopArtist, LastfmTopAlbum, LastfmTopTrack, LastfmRecentTrack } from '../api/lastfm';
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
@@ -32,11 +34,33 @@ const PERIODS: { key: LastfmPeriod; label: string }[] = [
export default function Statistics() { export default function Statistics() {
const { t } = useTranslation(); const { t } = useTranslation();
const navigate = useNavigate();
const { lastfmSessionKey, lastfmUsername } = useAuthStore(); const { lastfmSessionKey, lastfmUsername } = useAuthStore();
const musicLibraryFilterVersion = useAuthStore(s => s.musicLibraryFilterVersion); const musicLibraryFilterVersion = useAuthStore(s => s.musicLibraryFilterVersion);
const [recent, setRecent] = useState<SubsonicAlbum[]>([]); const [recent, setRecent] = useState<SubsonicAlbum[]>([]);
const [frequent, setFrequent] = useState<SubsonicAlbum[]>([]); const [frequent, setFrequent] = useState<SubsonicAlbum[]>([]);
const [highest, setHighest] = 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 [artistCount, setArtistCount] = useState<number | null>(null);
const [totalSongs, setTotalSongs] = useState<number | null>(null); const [totalSongs, setTotalSongs] = useState<number | null>(null);
const [totalAlbums, setTotalAlbums] = useState<number | null>(null); const [totalAlbums, setTotalAlbums] = useState<number | null>(null);
@@ -63,7 +87,8 @@ export default function Statistics() {
getAlbumList('highest', 12).catch(() => []), getAlbumList('highest', 12).catch(() => []),
getArtists().catch(() => []), getArtists().catch(() => []),
getGenres().catch(() => []), getGenres().catch(() => []),
]).then(([rc, fr, hi, a, g]) => { getStarred().catch(() => ({ albums: [], artists: [], songs: [] })),
]).then(([rc, fr, hi, a, g, starred]) => {
setRecent(rc); setRecent(rc);
setFrequent(fr); setFrequent(fr);
setHighest(hi); setHighest(hi);
@@ -72,6 +97,13 @@ export default function Statistics() {
setTotalAlbums(g.reduce((acc: number, genre: SubsonicGenre) => acc + genre.albumCount, 0)); setTotalAlbums(g.reduce((acc: number, genre: SubsonicGenre) => acc + genre.albumCount, 0));
const sorted = [...g].sort((a, b) => b.songCount - a.songCount); const sorted = [...g].sort((a, b) => b.songCount - a.songCount);
setGenres(sorted); 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); setLoading(false);
}).catch(() => setLoading(false)); }).catch(() => setLoading(false));
}, [musicLibraryFilterVersion]); }, [musicLibraryFilterVersion]);
@@ -282,8 +314,70 @@ export default function Statistics() {
albums={highest} albums={highest}
onLoadMore={() => loadMore('highest', highest, setHighest)} onLoadMore={() => loadMore('highest', highest, setHighest)}
moreText={t('statistics.loadMore')} 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 */} {/* Last.fm Stats */}
{lastfmIsConfigured() && ( {lastfmIsConfigured() && (
<section style={{ marginTop: '2rem' }}> <section style={{ marginTop: '2rem' }}>
@@ -363,7 +457,7 @@ export default function Statistics() {
</div> </div>
) : ( ) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.375rem' }}> <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 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={{ flex: 1, minWidth: 0 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}> <div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
+23
View File
@@ -589,6 +589,20 @@
margin-top: 2px; 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 ─ */
.live-search { .live-search {
position: relative; position: relative;
@@ -3621,6 +3635,15 @@
margin: var(--space-1) 0; 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 ─ */ /* ─ CSS Tooltips ─ */
/* Tooltips are handled by TooltipPortal (React portal) — no CSS pseudo-elements needed */ /* Tooltips are handled by TooltipPortal (React portal) — no CSS pseudo-elements needed */
[data-tooltip] { [data-tooltip] {
+11
View File
@@ -934,6 +934,17 @@
margin-top: 2px; 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 (PlayerBar track name / artist) ── */
.marquee-wrap { .marquee-wrap {
overflow: hidden; overflow: hidden;