diff --git a/CHANGELOG.md b/CHANGELOG.md index b611ef9c..601de7f7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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/), 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 (1–5 ★) 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 ### Added diff --git a/package.json b/package.json index 87d2a711..6ee1e3f4 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "psysonic", - "version": "1.34.3", + "version": "1.34.4", "private": true, "scripts": { "dev": "vite", diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 1f23c740..fa5dbdae 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -3339,7 +3339,7 @@ dependencies = [ [[package]] name = "psysonic" -version = "1.34.3" +version = "1.34.4" dependencies = [ "biquad", "discord-rich-presence", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 4cac4658..f78e8308 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "psysonic" -version = "1.34.3" +version = "1.34.4" description = "Psysonic Desktop Music Player" authors = [] license = "" diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index ad55f757..0260445b 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "Psysonic", - "version": "1.34.3", + "version": "1.34.4", "identifier": "dev.psysonic.player", "build": { "beforeDevCommand": "npm run dev", diff --git a/src/components/AlbumCard.tsx b/src/components/AlbumCard.tsx index d768e306..154b9175 100644 --- a/src/components/AlbumCard.tsx +++ b/src/components/AlbumCard.tsx @@ -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}

{album.year &&

{album.year}

} + {showRating && (album.userRating ?? 0) > 0 && ( +
+ + {'★'.repeat(album.userRating!)}{'☆'.repeat(5 - album.userRating!)} + +
+ )} ); diff --git a/src/components/AlbumRow.tsx b/src/components/AlbumRow.tsx index 36bbea25..041e021a 100644 --- a/src/components/AlbumRow.tsx +++ b/src/components/AlbumRow.tsx @@ -12,9 +12,10 @@ interface Props { moreLink?: string; moreText?: string; onLoadMore?: () => Promise; + 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(null); const navigate = useNavigate(); @@ -89,7 +90,7 @@ export default function AlbumRow({ title, titleLink, albums, moreLink, moreText,
- {albums.map(a => )} + {albums.map(a => )} {loadingMore && (
diff --git a/src/components/ContextMenu.tsx b/src/components/ContextMenu.tsx index 53bc724e..4518932b 100644 --- a/src/components/ContextMenu.tsx +++ b/src/components/ContextMenu.tsx @@ -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() { ); })()}
+
e.stopPropagation()}> + { setUserRatingOverride(song.id, r); setRating(song.id, r).catch(() => {}); }} + ariaLabel={t('albumDetail.ratingLabel')} + /> +
handleAction(() => openSongInfo(song.id))}> {t('contextMenu.songInfo')}
@@ -501,6 +509,13 @@ export default function ContextMenu() { {t('contextMenu.startRadio')}
+
e.stopPropagation()}> + { setUserRatingOverride(song.id, r); setRating(song.id, r).catch(() => {}); }} + ariaLabel={t('albumDetail.ratingLabel')} + /> +
handleAction(() => openSongInfo(song.id))}> {t('contextMenu.songInfo')}
diff --git a/src/components/PlayerBar.tsx b/src/components/PlayerBar.tsx index a92a0a56..b2677740 100644 --- a/src/components/PlayerBar.tsx +++ b/src/components/PlayerBar.tsx @@ -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 && ( + { setUserRatingOverride(currentTrack.id, r); setRating(currentTrack.id, r).catch(() => {}); }} + className="player-track-rating" + ariaLabel={t('albumDetail.ratingLabel')} + /> + )}
{currentTrack && !isRadio && (