From 705c80ef07ba59ef36268cb5cd31b3f27a90b8cb Mon Sep 17 00:00:00 2001 From: Maxim Isaev Date: Wed, 8 Apr 2026 01:08:41 +0300 Subject: [PATCH 1/3] feat(ratings): Subsonic entity ratings and StarRating UX Probe OpenSubsonic after connect; persist album/artist ratings via setRating when supported. Add shared StarRating (repeat same star to clear, pulse and shrink animations, hover freeze after clear). Widen tracklist duration column and bump narrow saved widths in localStorage. Use StarRating in AlbumHeader, track lists, and playlist rows. --- src/App.tsx | 17 ++++-- src/api/subsonic.ts | 26 +++++++++ src/components/AlbumHeader.tsx | 18 ++++++ src/components/AlbumTrackList.tsx | 46 ++++++--------- src/components/StarRating.tsx | 94 +++++++++++++++++++++++++++++++ src/locales/de.ts | 7 +++ src/locales/en.ts | 7 +++ src/locales/fr.ts | 7 +++ src/locales/nb.ts | 7 +++ src/locales/nl.ts | 7 +++ src/locales/ru.ts | 7 +++ src/locales/zh.ts | 7 +++ src/pages/AlbumDetail.tsx | 41 ++++++++++++++ src/pages/ArtistDetail.tsx | 46 ++++++++++++++- src/pages/Favorites.tsx | 15 ++++- src/pages/PlaylistDetail.tsx | 35 +++++------- src/store/authStore.ts | 16 ++++++ src/styles/components.css | 45 ++++++++++++++- src/styles/theme.css | 68 ++++++++++++++++++++++ src/utils/useTracklistColumns.ts | 7 ++- 20 files changed, 462 insertions(+), 61 deletions(-) create mode 100644 src/components/StarRating.tsx diff --git a/src/App.tsx b/src/App.tsx index 7898f83f..487922f9 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -56,7 +56,7 @@ import { IS_LINUX } from './utils/platform'; import { version } from '../package.json'; import { useConnectionStatus } from './hooks/useConnectionStatus'; import { useAuthStore } from './store/authStore'; -import { getMusicFolders } from './api/subsonic'; +import { getMusicFolders, probeEntityRatingSupport } from './api/subsonic'; import { useOfflineStore } from './store/offlineStore'; import { initHotCachePrefetch } from './hotCachePrefetch'; import { usePlayerStore, initAudioListeners } from './store/playerStore'; @@ -101,6 +101,7 @@ function AppShell() { const activeServerId = useAuthStore(s => s.activeServerId); const setMusicFolders = useAuthStore(s => s.setMusicFolders); const useCustomTitlebar = useAuthStore(s => s.useCustomTitlebar); + const setEntityRatingSupport = useAuthStore(s => s.setEntityRatingSupport); const offlineAlbums = useOfflineStore(s => s.albums); const hasOfflineContent = Object.values(offlineAlbums).some(a => a.serverId === serverId); @@ -112,19 +113,27 @@ function AppShell() { useEffect(() => { if (!isLoggedIn || !activeServerId) return; + const serverAtStart = activeServerId; let cancelled = false; (async () => { + const stillThisServer = () => !cancelled && useAuthStore.getState().activeServerId === serverAtStart; try { const folders = await getMusicFolders(); - if (!cancelled) setMusicFolders(folders); + if (stillThisServer()) setMusicFolders(folders); } catch { - if (!cancelled) setMusicFolders([]); + if (stillThisServer()) setMusicFolders([]); + } + try { + const level = await probeEntityRatingSupport(); + if (stillThisServer()) setEntityRatingSupport(serverAtStart, level); + } catch { + if (stillThisServer()) setEntityRatingSupport(serverAtStart, 'track_only'); } })(); return () => { cancelled = true; }; - }, [isLoggedIn, activeServerId, setMusicFolders]); + }, [isLoggedIn, activeServerId, setMusicFolders, setEntityRatingSupport]); // Reset scroll position on route change useEffect(() => { diff --git a/src/api/subsonic.ts b/src/api/subsonic.ts index e92f75a7..68f2761c 100644 --- a/src/api/subsonic.ts +++ b/src/api/subsonic.ts @@ -64,6 +64,8 @@ export interface SubsonicAlbum { starred?: string; recordLabel?: string; created?: string; + /** Present on some servers (e.g. OpenSubsonic) for album-level rating. */ + userRating?: number; } export interface SubsonicSong { @@ -141,6 +143,8 @@ export interface SubsonicArtist { albumCount?: number; coverArt?: string; starred?: string; + /** Present on some servers (e.g. OpenSubsonic) for artist-level rating. */ + userRating?: number; } export interface SubsonicGenre { @@ -374,6 +378,28 @@ export async function setRating(id: string, rating: number): Promise { await api('setRating.view', { id, rating }); } +/** How aggressively we assume `setRating` accepts album/artist ids (OpenSubsonic-style). */ +export type EntityRatingSupportLevel = 'track_only' | 'full'; + +/** + * Probe server for OpenSubsonic extensions. When `openSubsonic: true`, we treat album/artist + * rating as supported (same `setRating.view` + entity id); otherwise track-only. + */ +export async function probeEntityRatingSupport(): Promise { + try { + const data = await api<{ openSubsonic?: boolean; openSubsonicExtensions?: unknown[] }>( + 'getOpenSubsonicExtensions.view', + {}, + 8000, + ); + if (data.openSubsonic === true) return 'full'; + if (Array.isArray(data.openSubsonicExtensions)) return 'full'; + return 'track_only'; + } catch { + return 'track_only'; + } +} + export async function scrobbleSong(id: string, time: number): Promise { try { await api('scrobble.view', { id, time, submission: true }); diff --git a/src/components/AlbumHeader.tsx b/src/components/AlbumHeader.tsx index f9159913..3cc60fec 100644 --- a/src/components/AlbumHeader.tsx +++ b/src/components/AlbumHeader.tsx @@ -6,6 +6,8 @@ import CachedImage from './CachedImage'; import CoverLightbox from './CoverLightbox'; import { useTranslation } from 'react-i18next'; import { useIsMobile } from '../hooks/useIsMobile'; +import StarRating from './StarRating'; +import type { EntityRatingSupportLevel } from '../api/subsonic'; function formatDuration(seconds: number): string { const h = Math.floor(seconds / 3600); @@ -85,6 +87,10 @@ interface AlbumHeaderProps { onEnqueueAll: () => void; onBio: () => void; onCloseBio: () => void; + entityRatingValue: number; + onEntityRatingChange: (rating: number) => void; + /** `unknown` = probe pending or not run; from `entityRatingSupportByServer`. */ + entityRatingSupport: EntityRatingSupportLevel | 'unknown'; } export default function AlbumHeader({ @@ -107,6 +113,9 @@ export default function AlbumHeader({ onEnqueueAll, onBio, onCloseBio, + entityRatingValue, + onEntityRatingChange, + entityRatingSupport, }: AlbumHeaderProps) { const { t } = useTranslation(); const navigate = useNavigate(); @@ -186,6 +195,15 @@ export default function AlbumHeader({ )} +
+ {t('entityRating.albumShort')} + +
{isMobile ? (
{/* Row 1 — Primary actions */} diff --git a/src/components/AlbumTrackList.tsx b/src/components/AlbumTrackList.tsx index 3b43acb4..a6b1f8cc 100644 --- a/src/components/AlbumTrackList.tsx +++ b/src/components/AlbumTrackList.tsx @@ -8,6 +8,7 @@ import { useNavigate } from 'react-router-dom'; import { useDragDrop } from '../contexts/DragDropContext'; import { AddToPlaylistSubmenu } from './ContextMenu'; import { useIsMobile } from '../hooks/useIsMobile'; +import StarRating from './StarRating'; function formatDuration(seconds: number): string { const h = Math.floor(seconds / 3600); @@ -24,29 +25,6 @@ function codecLabel(song: { suffix?: string; bitRate?: number }): string { return parts.join(' '); } -function StarRating({ value, onChange }: { value: number; onChange: (r: number) => void }) { - const { t } = useTranslation(); - const [hover, setHover] = React.useState(0); - return ( -
- {[1, 2, 3, 4, 5].map(n => ( - - ))} -
- ); -} - // ── Column configuration ────────────────────────────────────────────────────── // 'num' → always 60 px fixed, no resize handle // 'title' → minmax(150px, 1fr) via flex:true, absorbs window-resize changes @@ -58,14 +36,14 @@ const COLUMNS: readonly ColDef[] = [ { key: 'artist', i18nKey: 'trackArtist', minWidth: 80, defaultWidth: 180, required: false }, { key: 'favorite', i18nKey: 'trackFavorite', minWidth: 50, defaultWidth: 70, required: false }, { key: 'rating', i18nKey: 'trackRating', minWidth: 80, defaultWidth: 120, required: false }, - { key: 'duration', i18nKey: 'trackDuration', minWidth: 50, defaultWidth: 65, required: false }, + { key: 'duration', i18nKey: 'trackDuration', minWidth: 72, defaultWidth: 92, required: false }, { key: 'format', i18nKey: 'trackFormat', minWidth: 60, defaultWidth: 90, required: false }, { key: 'genre', i18nKey: 'trackGenre', minWidth: 60, defaultWidth: 90, required: false }, ]; type ColKey = 'num' | 'title' | 'artist' | 'favorite' | 'rating' | 'duration' | 'format' | 'genre'; -// Columns where cell content should be centred (both header and rows) +// Columns where header label is centred in the cell (matches row controls below) const CENTERED_COLS = new Set(['favorite', 'rating', 'duration']); // ── Props ───────────────────────────────────────────────────────────────────── @@ -193,13 +171,21 @@ export default function AlbumTrackList({ ); } - // px-width columns: centred or left-aligned label + right-edge divider (except last col) - // direction=1: drag right → this column grows, title (1fr) shrinks + // px-width columns: centred (compact controls) or left-aligned label + right-edge divider const isResizable = !isLastCol; return ( -
-
- {label} +
+
+ {label}
{isResizable && (
startResize(e, colIndex, 1)} /> diff --git a/src/components/StarRating.tsx b/src/components/StarRating.tsx new file mode 100644 index 00000000..eb27aee9 --- /dev/null +++ b/src/components/StarRating.tsx @@ -0,0 +1,94 @@ +import React from 'react'; +import { useTranslation } from 'react-i18next'; + +export default function StarRating({ + value, + onChange, + disabled = false, + labelKey = 'albumDetail.ratingLabel', + className = '', +}: { + value: number; + onChange: (rating: number) => void; + disabled?: boolean; + labelKey?: string; + className?: string; +}) { + const { t } = useTranslation(); + const [hover, setHover] = React.useState(0); + const [pulseStar, setPulseStar] = React.useState(null); + const [clearShrinkStar, setClearShrinkStar] = React.useState(null); + /** After clear: ignore hover so stars stay grey until pointer leaves widget or next click */ + const [suppressHoverPreview, setSuppressHoverPreview] = React.useState(false); + + React.useEffect(() => { + if (value > 0) setSuppressHoverPreview(false); + }, [value]); + + const effectiveHover = suppressHoverPreview ? 0 : hover; + const filled = (n: number) => (effectiveHover || value) >= n; + + const handleStarClick = (n: number) => { + if (disabled) return; + setSuppressHoverPreview(false); + + const next = value === n ? 0 : n; + onChange(next); + setHover(0); + + setPulseStar(null); + setClearShrinkStar(null); + + if (next === 0) { + setSuppressHoverPreview(true); + requestAnimationFrame(() => { + requestAnimationFrame(() => setClearShrinkStar(n)); + }); + } else { + requestAnimationFrame(() => { + requestAnimationFrame(() => setPulseStar(n)); + }); + } + }; + + const handleContainerLeave = () => { + setHover(0); + setSuppressHoverPreview(false); + }; + + return ( +
+ {[1, 2, 3, 4, 5].map(n => ( + + ))} +
+ ); +} diff --git a/src/locales/de.ts b/src/locales/de.ts index 9a74d3b3..f84486fd 100644 --- a/src/locales/de.ts +++ b/src/locales/de.ts @@ -145,6 +145,13 @@ export const deTranslation = { ratingLabel: 'Bewertung', enlargeCover: 'Vergrößern', }, + entityRating: { + albumShort: 'Albumbewertung', + artistShort: 'Künstlerbewertung', + albumAriaLabel: 'Albumbewertung', + artistAriaLabel: 'Künstlerbewertung', + saveFailed: 'Bewertung konnte nicht gespeichert werden.', + }, artistDetail: { back: 'Zurück', albums: 'Alben', diff --git a/src/locales/en.ts b/src/locales/en.ts index 5b1fa647..a3de0655 100644 --- a/src/locales/en.ts +++ b/src/locales/en.ts @@ -146,6 +146,13 @@ export const enTranslation = { ratingLabel: 'Rating', enlargeCover: 'Enlarge', }, + entityRating: { + albumShort: 'Album rating', + artistShort: 'Artist rating', + albumAriaLabel: 'Album rating', + artistAriaLabel: 'Artist rating', + saveFailed: 'Could not save rating.', + }, artistDetail: { back: 'Back', albums: 'Albums', diff --git a/src/locales/fr.ts b/src/locales/fr.ts index a596e643..3d62ff7e 100644 --- a/src/locales/fr.ts +++ b/src/locales/fr.ts @@ -145,6 +145,13 @@ export const frTranslation = { ratingLabel: 'Note', enlargeCover: 'Agrandir', }, + entityRating: { + albumShort: 'Note de l’album', + artistShort: 'Note de l’artiste', + albumAriaLabel: 'Note de l’album', + artistAriaLabel: 'Note de l’artiste', + saveFailed: 'Impossible d’enregistrer la note.', + }, artistDetail: { back: 'Retour', albums: 'Albums', diff --git a/src/locales/nb.ts b/src/locales/nb.ts index a5f8cf5e..532921dd 100644 --- a/src/locales/nb.ts +++ b/src/locales/nb.ts @@ -145,6 +145,13 @@ export const nbTranslation = { ratingLabel: 'Vurdering', enlargeCover: 'Forstørr', }, + entityRating: { + albumShort: 'Albumvurdering', + artistShort: 'Artistvurdering', + albumAriaLabel: 'Albumvurdering', + artistAriaLabel: 'Artistvurdering', + saveFailed: 'Kunne ikke lagre vurderingen.', + }, artistDetail: { back: 'Tilbake', albums: 'Album', diff --git a/src/locales/nl.ts b/src/locales/nl.ts index 5323109b..3dde3436 100644 --- a/src/locales/nl.ts +++ b/src/locales/nl.ts @@ -145,6 +145,13 @@ export const nlTranslation = { ratingLabel: 'Beoordeling', enlargeCover: 'Vergroten', }, + entityRating: { + albumShort: 'Albumbeoordeling', + artistShort: 'Artiestbeoordeling', + albumAriaLabel: 'Albumbeoordeling', + artistAriaLabel: 'Artiestbeoordeling', + saveFailed: 'Beoordeling opslaan mislukt.', + }, artistDetail: { back: 'Terug', albums: 'Albums', diff --git a/src/locales/ru.ts b/src/locales/ru.ts index 33c12fc9..679d48c9 100644 --- a/src/locales/ru.ts +++ b/src/locales/ru.ts @@ -147,6 +147,13 @@ export const ruTranslation = { ratingLabel: 'Оценка', enlargeCover: 'Увеличить обложку', }, + entityRating: { + albumShort: 'Оценка альбома', + artistShort: 'Оценка исполнителя', + albumAriaLabel: 'Оценка альбома', + artistAriaLabel: 'Оценка исполнителя', + saveFailed: 'Не удалось сохранить оценку.', + }, artistDetail: { back: 'Назад', albums: 'Альбомы', diff --git a/src/locales/zh.ts b/src/locales/zh.ts index 134ded62..f1f97674 100644 --- a/src/locales/zh.ts +++ b/src/locales/zh.ts @@ -145,6 +145,13 @@ export const zhTranslation = { ratingLabel: '评分', enlargeCover: '放大', }, + entityRating: { + albumShort: '专辑评分', + artistShort: '艺人评分', + albumAriaLabel: '专辑评分', + artistAriaLabel: '艺人评分', + saveFailed: '无法保存评分。', + }, artistDetail: { back: '返回', albums: '专辑', diff --git a/src/pages/AlbumDetail.tsx b/src/pages/AlbumDetail.tsx index 28033ae0..6477ad9f 100644 --- a/src/pages/AlbumDetail.tsx +++ b/src/pages/AlbumDetail.tsx @@ -13,6 +13,7 @@ import AlbumHeader from '../components/AlbumHeader'; import AlbumTrackList from '../components/AlbumTrackList'; import { useCachedUrl } from '../components/CachedImage'; import { useTranslation } from 'react-i18next'; +import { showToast } from '../utils/toast'; function sanitizeFilename(name: string): string { return name @@ -52,6 +53,11 @@ export default function AlbumDetail() { const offlineAlbums = useOfflineStore(s => s.albums); const offlineJobs = useOfflineStore(s => s.jobs); const serverId = auth.activeServerId ?? ''; + const entityRatingSupportByServer = useAuthStore(s => s.entityRatingSupportByServer); + const setEntityRatingSupport = useAuthStore(s => s.setEntityRatingSupport); + const albumEntityRatingSupport = entityRatingSupportByServer[serverId] ?? 'unknown'; + + const [albumEntityRating, setAlbumEntityRating] = useState(0); const offlineStatus: 'none' | 'downloading' | 'cached' = (() => { if (!album) return 'none'; @@ -90,6 +96,11 @@ export default function AlbumDetail() { }).catch(() => setLoading(false)); }, [id]); + useEffect(() => { + if (!id) return; + if (album && album.album.id === id) setAlbumEntityRating(album.album.userRating ?? 0); + }, [id, album?.album.id, album?.album.userRating]); + const handlePlayAll = () => { if (!album) return; const albumGenre = album.album.genre; @@ -129,6 +140,33 @@ const handleEnqueueAll = () => { await setRating(songId, rating); }; + const handleAlbumEntityRating = async (rating: number) => { + if (!album || album.album.id !== id) return; + const albumId = album.album.id; + const ratingAtStart = album.album.userRating ?? 0; + + setAlbumEntityRating(rating); + + if (albumEntityRatingSupport !== 'full') return; + + try { + await setRating(albumId, rating); + setAlbum(cur => + cur && cur.album.id === albumId + ? { ...cur, album: { ...cur.album, userRating: rating } } + : cur, + ); + } catch (err) { + setAlbumEntityRating(ratingAtStart); + setEntityRatingSupport(serverId, 'track_only'); + showToast( + typeof err === 'string' ? err : err instanceof Error ? err.message : t('entityRating.saveFailed'), + 4500, + 'error', + ); + } + }; + const handleBio = async () => { if (!album) return; if (bio) { setBioOpen(true); return; } @@ -270,6 +308,9 @@ const handleEnqueueAll = () => { offlineProgress={offlineProgress} onCacheOffline={handleCacheOffline} onRemoveOffline={handleRemoveOffline} + entityRatingValue={albumEntityRating} + onEntityRatingChange={handleAlbumEntityRating} + entityRatingSupport={albumEntityRatingSupport} /> {offlineStorageFull && (
diff --git a/src/pages/ArtistDetail.tsx b/src/pages/ArtistDetail.tsx index 5b852cce..11e91495 100644 --- a/src/pages/ArtistDetail.tsx +++ b/src/pages/ArtistDetail.tsx @@ -1,6 +1,6 @@ import { useEffect, useState, useRef } from 'react'; import { useParams, useNavigate } from 'react-router-dom'; -import { getArtist, getArtistInfo, getTopSongs, getSimilarSongs2, getAlbum, search, SubsonicArtist, SubsonicAlbum, SubsonicSong, SubsonicArtistInfo, buildCoverArtUrl, coverArtCacheKey, star, unstar, uploadArtistImage } from '../api/subsonic'; +import { getArtist, getArtistInfo, getTopSongs, getSimilarSongs2, getAlbum, search, setRating, SubsonicArtist, SubsonicAlbum, SubsonicSong, SubsonicArtistInfo, buildCoverArtUrl, coverArtCacheKey, star, unstar, uploadArtistImage } from '../api/subsonic'; import AlbumCard from '../components/AlbumCard'; import CachedImage from '../components/CachedImage'; import CoverLightbox from '../components/CoverLightbox'; @@ -14,6 +14,7 @@ import { lastfmGetSimilarArtists, lastfmIsConfigured } from '../api/lastfm'; import LastfmIcon from '../components/LastfmIcon'; import { invalidateCoverArt } from '../utils/imageCache'; import { showToast } from '../utils/toast'; +import StarRating from '../components/StarRating'; function formatDuration(seconds: number): string { const m = Math.floor(seconds / 60); @@ -71,6 +72,11 @@ export default function ArtistDetail() { const { downloadArtist, bulkProgress } = useOfflineStore(); const activeServerId = useAuthStore(s => s.activeServerId) ?? ''; const musicLibraryFilterVersion = useAuthStore(s => s.musicLibraryFilterVersion); + const entityRatingSupportByServer = useAuthStore(s => s.entityRatingSupportByServer); + const setEntityRatingSupport = useAuthStore(s => s.setEntityRatingSupport); + const artistEntityRatingSupport = entityRatingSupportByServer[activeServerId] ?? 'unknown'; + + const [artistEntityRating, setArtistEntityRating] = useState(0); useEffect(() => { if (!id) return; @@ -94,6 +100,34 @@ export default function ArtistDetail() { }); }, [id]); + useEffect(() => { + if (!id) return; + if (artist && artist.id === id) setArtistEntityRating(artist.userRating ?? 0); + }, [id, artist?.id, artist?.userRating]); + + const handleArtistEntityRating = async (rating: number) => { + if (!artist || artist.id !== id) return; + const artistId = artist.id; + const ratingAtStart = artist.userRating ?? 0; + + setArtistEntityRating(rating); + + if (artistEntityRatingSupport !== 'full') return; + + try { + await setRating(artistId, rating); + setArtist(a => (a && a.id === artistId ? { ...a, userRating: rating } : a)); + } catch (err) { + setArtistEntityRating(ratingAtStart); + setEntityRatingSupport(activeServerId, 'track_only'); + showToast( + typeof err === 'string' ? err : err instanceof Error ? err.message : t('entityRating.saveFailed'), + 4500, + 'error', + ); + } + }; + // "Also Featured On" — loaded in background after main content renders useEffect(() => { if (!id || !artist) return; @@ -351,6 +385,16 @@ export default function ArtistDetail() { {t('artistDetail.albumCount_other', { count: artist.albumCount ?? 0 })}
+
+ {t('entityRating.artistShort')} + +
+
{(info?.lastFmUrl || artist.name) && (
diff --git a/src/pages/Favorites.tsx b/src/pages/Favorites.tsx index daf90436..40f01a91 100644 --- a/src/pages/Favorites.tsx +++ b/src/pages/Favorites.tsx @@ -20,7 +20,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: 'duration', i18nKey: 'trackDuration', minWidth: 50, defaultWidth: 65, required: false }, + { key: 'duration', i18nKey: 'trackDuration', minWidth: 72, defaultWidth: 92, required: false }, { key: 'remove', i18nKey: null, minWidth: 36, defaultWidth: 36, required: true }, ]; @@ -182,8 +182,17 @@ export default function Favorites() { const isCentered = key === 'duration'; return (
-
- {label} +
+ {label}
{!isLastCol &&
startResize(e, colIndex, 1)} />}
diff --git a/src/pages/PlaylistDetail.tsx b/src/pages/PlaylistDetail.tsx index 31043159..f5ec4bed 100644 --- a/src/pages/PlaylistDetail.tsx +++ b/src/pages/PlaylistDetail.tsx @@ -21,6 +21,7 @@ import CachedImage, { useCachedUrl } from '../components/CachedImage'; import { coverArtCacheKey, buildCoverArtUrl } from '../api/subsonic'; import { useTranslation } from 'react-i18next'; import { showToast } from '../utils/toast'; +import StarRating from '../components/StarRating'; function sanitizeFilename(name: string): string { return name @@ -50,23 +51,6 @@ function codecLabel(song: SubsonicSong): string { return parts.join(' · '); } -function StarRating({ value, onChange }: { value: number; onChange: (r: number) => void }) { - const [hover, setHover] = React.useState(0); - return ( -
- {[1, 2, 3, 4, 5].map(n => ( - - ))} -
- ); -} - // ── Column configuration ────────────────────────────────────────────────────── const PL_COLUMNS: readonly ColDef[] = [ { key: 'num', i18nKey: null, minWidth: 60, defaultWidth: 60, required: true }, @@ -74,7 +58,7 @@ const PL_COLUMNS: readonly ColDef[] = [ { key: 'artist', i18nKey: 'trackArtist', minWidth: 80, defaultWidth: 180, required: false }, { key: 'favorite', i18nKey: 'trackFavorite', minWidth: 50, defaultWidth: 70, required: false }, { key: 'rating', i18nKey: 'trackRating', minWidth: 80, defaultWidth: 120, required: false }, - { key: 'duration', i18nKey: 'trackDuration', minWidth: 50, defaultWidth: 65, required: false }, + { key: 'duration', i18nKey: 'trackDuration', minWidth: 72, defaultWidth: 92, required: false }, { key: 'format', i18nKey: 'trackFormat', minWidth: 60, defaultWidth: 90, required: false }, { key: 'delete', i18nKey: null, minWidth: 36, defaultWidth: 36, required: true }, ]; @@ -750,8 +734,17 @@ export default function PlaylistDetail() { if (key === 'delete') return
; return (
-
- {label} +
+ {label}
{!isLastCol && key !== 'delete' && (
startResize(e, colIndex, 1)} /> @@ -917,7 +910,7 @@ export default function PlaylistDetail() { if (key === 'title') return
{label}
; if (key === 'delete') return
; if (key === 'favorite' || key === 'rating') return
; - return
{label}
; + return
{label}
; })}
diff --git a/src/store/authStore.ts b/src/store/authStore.ts index b1d9a9d8..6141f8be 100644 --- a/src/store/authStore.ts +++ b/src/store/authStore.ts @@ -1,6 +1,7 @@ import { create } from 'zustand'; import { persist, createJSONStorage } from 'zustand/middleware'; import { invoke } from '@tauri-apps/api/core'; +import type { EntityRatingSupportLevel } from '../api/subsonic'; import { usePlayerStore } from './playerStore'; export interface ServerProfile { @@ -69,6 +70,13 @@ interface AuthState { /** Bumps when `setMusicLibraryFilter` runs so pages refetch catalog data. */ musicLibraryFilterVersion: number; + /** + * Per server: whether `setRating` is assumed to work for album/artist ids (OpenSubsonic-style). + * Absent key = not probed yet (`unknown` in UI). + */ + entityRatingSupportByServer: Record; + setEntityRatingSupport: (serverId: string, level: EntityRatingSupportLevel) => void; + // Status isLoggedIn: boolean; isConnecting: boolean; @@ -172,6 +180,7 @@ export const useAuthStore = create()( musicFolders: [], musicLibraryFilterByServer: {}, musicLibraryFilterVersion: 0, + entityRatingSupportByServer: {}, isLoggedIn: false, isConnecting: false, connectionError: null, @@ -193,10 +202,12 @@ export const useAuthStore = create()( set(s => { const newServers = s.servers.filter(srv => srv.id !== id); const switchedAway = s.activeServerId === id; + const { [id]: _r, ...entityRatingRest } = s.entityRatingSupportByServer; return { servers: newServers, activeServerId: switchedAway ? (newServers[0]?.id ?? null) : s.activeServerId, isLoggedIn: switchedAway ? false : s.isLoggedIn, + entityRatingSupportByServer: entityRatingRest, }; }); }, @@ -279,6 +290,11 @@ export const useAuthStore = create()( })); }, + setEntityRatingSupport: (serverId, level) => + set(s => ({ + entityRatingSupportByServer: { ...s.entityRatingSupportByServer, [serverId]: level }, + })), + logout: () => set({ isLoggedIn: false, musicFolders: [] }), getBaseUrl: () => { diff --git a/src/styles/components.css b/src/styles/components.css index ef435a71..ea6bf0a6 100644 --- a/src/styles/components.css +++ b/src/styles/components.css @@ -997,7 +997,50 @@ gap: var(--space-2); color: var(--text-muted); font-size: 13px; - margin: var(--space-2) 0 var(--space-4); + margin: var(--space-2) 0 var(--space-2); +} + +.album-detail-entity-rating { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: var(--space-2) var(--space-3); + margin-bottom: var(--space-4); +} + +/* Inline with label: do not stretch to full row width */ +.album-detail-entity-rating .star-rating { + width: auto; + justify-content: flex-start; +} + +.album-detail-entity-rating-label { + font-size: 11px; + font-weight: 600; + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--text-muted); +} + +.artist-detail-entity-rating { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: var(--space-2) var(--space-3); + margin-bottom: var(--space-3); +} + +.artist-detail-entity-rating .star-rating { + width: auto; + justify-content: flex-start; +} + +.artist-detail-entity-rating-label { + font-size: 11px; + font-weight: 600; + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--text-muted); } .album-detail-actions { diff --git a/src/styles/theme.css b/src/styles/theme.css index 74db56cf..2e80499e 100644 --- a/src/styles/theme.css +++ b/src/styles/theme.css @@ -3800,6 +3800,74 @@ select.input.input:focus { cursor: pointer; } +/* While clearing: no yellow hover until pointer leaves or next click */ +.star-rating--suppress-hover .star:hover { + color: var(--ctp-overlay1); +} + +.star-rating--disabled { + opacity: 0.55; +} + +.star-rating--disabled .star:hover { + color: inherit; + cursor: not-allowed; +} + +.star-rating .star { + transform-origin: center center; +} + +@keyframes star-rating-star-pulse { + 0% { + transform: scale(1); + opacity: 1; + } + + 45% { + transform: scale(1.3); + opacity: 0.88; + } + + 100% { + transform: scale(1); + opacity: 1; + } +} + +.star-rating .star.star--pulse { + animation: star-rating-star-pulse 0.38s ease-out; +} + +@keyframes star-rating-star-clear-shrink { + 0% { + transform: scale(1); + opacity: 1; + } + + 55% { + transform: scale(0.58); + opacity: 0.28; + } + + 100% { + transform: scale(1); + opacity: 1; + } +} + +.star-rating .star.star--clear-shrink { + animation: star-rating-star-clear-shrink 0.44s ease-out; +} + +@media (prefers-reduced-motion: reduce) { + + .star-rating .star.star--pulse, + .star-rating .star.star--clear-shrink { + animation: none; + } +} + /* ─── Animations ─── */ @keyframes fadeIn { from { diff --git a/src/utils/useTracklistColumns.ts b/src/utils/useTracklistColumns.ts index 2121b41d..2fd7d6da 100644 --- a/src/utils/useTracklistColumns.ts +++ b/src/utils/useTracklistColumns.ts @@ -24,8 +24,13 @@ function loadPrefs( const parsed = JSON.parse(raw) as { widths?: Record; visible?: string[] }; const visible = new Set(parsed.visible ?? [...defaultVisible]); columns.filter(c => c.required).forEach(c => visible.add(c.key)); + const widths = { ...defaultWidths, ...(parsed.widths ?? {}) }; + const durationCol = columns.find(c => c.key === 'duration'); + if (durationCol && typeof widths.duration === 'number' && widths.duration < durationCol.minWidth) { + widths.duration = defaultWidths.duration; + } return { - widths: { ...defaultWidths, ...(parsed.widths ?? {}) }, + widths, visible, }; } catch { From 8a1d9421283884746906864c9c22fbcafe67738e Mon Sep 17 00:00:00 2001 From: Maxim Isaev Date: Wed, 8 Apr 2026 02:39:25 +0300 Subject: [PATCH 2/3] =?UTF-8?q?feat(ratings):=20skip-to-1=E2=98=85=20thres?= =?UTF-8?q?hold=20and=20mix=20min-rating=20filter?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add Ratings section under General: manual skip count before setting 1★, and per-axis minimum stars for random mixes/albums (song, album, artist). StarRating shows five stars with selection capped at three; shared max in authStore. Queue filtering via passesMixMinRatings; OpenSubsonic-style entity rating fields on types. --- src/api/subsonic.ts | 3 + src/components/StarRating.tsx | 79 +++++++++++++++--------- src/locales/de.ts | 14 +++++ src/locales/en.ts | 13 ++++ src/locales/fr.ts | 14 +++++ src/locales/nb.ts | 14 +++++ src/locales/nl.ts | 14 +++++ src/locales/ru.ts | 14 +++++ src/locales/zh.ts | 14 +++++ src/pages/RandomMix.tsx | 46 ++++++++++++-- src/pages/Settings.tsx | 110 +++++++++++++++++++++++++++++++++- src/store/authStore.ts | 53 ++++++++++++++++ src/store/playerStore.ts | 30 +++++++++- src/styles/theme.css | 10 ++++ src/utils/mixRatingFilter.ts | 27 +++++++++ 15 files changed, 418 insertions(+), 37 deletions(-) create mode 100644 src/utils/mixRatingFilter.ts diff --git a/src/api/subsonic.ts b/src/api/subsonic.ts index 68f2761c..9df3a3e3 100644 --- a/src/api/subsonic.ts +++ b/src/api/subsonic.ts @@ -81,6 +81,9 @@ export interface SubsonicSong { coverArt?: string; year?: number; userRating?: number; + /** Some OpenSubsonic responses attach parent ratings on child songs. */ + albumUserRating?: number; + artistUserRating?: number; // Audio technical info bitRate?: number; suffix?: string; diff --git a/src/components/StarRating.tsx b/src/components/StarRating.tsx index eb27aee9..baea128c 100644 --- a/src/components/StarRating.tsx +++ b/src/components/StarRating.tsx @@ -5,34 +5,50 @@ export default function StarRating({ value, onChange, disabled = false, + maxStars = 5, + maxSelectable: maxSelectableProp, labelKey = 'albumDetail.ratingLabel', + ariaLabel, className = '', }: { value: number; onChange: (rating: number) => void; disabled?: boolean; + /** Number of star buttons (1…maxStars). Default 5. */ + maxStars?: number; + /** Highest selectable star (inclusive); higher stars are shown but disabled. */ + maxSelectable?: number; labelKey?: string; + /** Overrides `t(labelKey)` for the radiogroup `aria-label` when set. */ + ariaLabel?: string; className?: string; }) { const { t } = useTranslation(); + const stars = React.useMemo( + () => Array.from({ length: Math.max(1, Math.min(5, maxStars)) }, (_, i) => i + 1), + [maxStars] + ); + const selectCap = Math.min(maxSelectableProp ?? stars.length, stars.length); const [hover, setHover] = React.useState(0); const [pulseStar, setPulseStar] = React.useState(null); const [clearShrinkStar, setClearShrinkStar] = React.useState(null); /** After clear: ignore hover so stars stay grey until pointer leaves widget or next click */ const [suppressHoverPreview, setSuppressHoverPreview] = React.useState(false); + const cappedValue = Math.min(Math.max(0, value), selectCap); + React.useEffect(() => { if (value > 0) setSuppressHoverPreview(false); }, [value]); - const effectiveHover = suppressHoverPreview ? 0 : hover; - const filled = (n: number) => (effectiveHover || value) >= n; + const effectiveHover = suppressHoverPreview ? 0 : Math.min(hover, selectCap); + const filled = (n: number) => (effectiveHover || cappedValue) >= n; const handleStarClick = (n: number) => { - if (disabled) return; + if (disabled || n > selectCap) return; setSuppressHoverPreview(false); - const next = value === n ? 0 : n; + const next = cappedValue === n ? 0 : n; onChange(next); setHover(0); @@ -60,35 +76,40 @@ export default function StarRating({
- {[1, 2, 3, 4, 5].map(n => ( - - ))} + onClick={() => handleStarClick(n)} + onAnimationEnd={e => { + if (e.currentTarget !== e.target) return; + const name = e.animationName; + if (name === 'star-rating-star-pulse') { + setPulseStar(s => (s === n ? null : s)); + } + if (name === 'star-rating-star-clear-shrink') { + setClearShrinkStar(s => (s === n ? null : s)); + } + }} + disabled={disabled || locked} + aria-label={`${n}`} + role="radio" + aria-checked={filled(n)} + > + ★ + + ); + })}
); } diff --git a/src/locales/de.ts b/src/locales/de.ts index f84486fd..301d086f 100644 --- a/src/locales/de.ts +++ b/src/locales/de.ts @@ -508,6 +508,20 @@ export const deTranslation = { shortcutNativeFullscreen: 'Nativer Vollbildmodus', tabSystem: 'System', tabGeneral: 'Allgemein', + ratingsSectionTitle: 'Bewertungen', + ratingsSkipStarTitle: 'Überspringen für 1 Stern', + ratingsSkipStarDesc: + 'Nach N manuellen Überspringen desselben Titels: Server-Bewertung 1 Stern, sofern noch unbewertet.', + ratingsSkipStarThresholdLabel: 'Überspringer bis 1★', + ratingsSkipStarThresholdHint: + 'Nur wenn Sie den Titel mit Weiter / Medientaste verlassen — nicht wenn er von selbst endet. Keine Änderung, wenn der Titel schon mindestens 1 Stern hat.', + ratingsMixFilterTitle: 'Filter nach Bewertung', + ratingsMixFilterDesc: + 'Inhalte mit niedriger Bewertung in Zufallsmixen und zufälligen Alben filtern. Gleichen Stern erneut klicken, um die Schwelle auszuschalten.', + ratingsMixMinSong: 'Titel (Titelbewertung)', + ratingsMixMinAlbum: 'Alben', + ratingsMixMinArtist: 'Interpreten', + ratingsMixMinThresholdAria: 'Mindest-Sterne ({{label}})', backupTitle: 'Backup & Wiederherstellung', backupExport: 'Einstellungen exportieren', backupExportDesc: 'Speichert alle Einstellungen, Serverprofile, Last.fm-Konfiguration, Theme, EQ und Tastenkürzel in eine .psybkp-Datei. Passwörter werden im Klartext gespeichert — Datei sicher aufbewahren.', diff --git a/src/locales/en.ts b/src/locales/en.ts index a3de0655..2636b519 100644 --- a/src/locales/en.ts +++ b/src/locales/en.ts @@ -493,6 +493,19 @@ export const enTranslation = { tabServer: 'Server', tabSystem: 'System', tabGeneral: 'General', + ratingsSectionTitle: 'Ratings', + ratingsSkipStarTitle: 'Skip for 1 star', + ratingsSkipStarDesc: + 'After N manual skips of the same track, set server rating to 1 star if the track is still unrated.', + ratingsSkipStarThresholdLabel: 'Skips before 1★', + ratingsSkipStarThresholdHint: 'Only counts when you leave the track with Next / media “next”, not when it ends on its own. Does nothing if the track is already rated 1 star or higher.', + ratingsMixFilterTitle: 'Filter by rating', + ratingsMixFilterDesc: + 'Filter low-rated items in random mixes and random albums. Click the same star again to turn off that column’s threshold.', + ratingsMixMinSong: 'Songs (track rating)', + ratingsMixMinAlbum: 'Albums', + ratingsMixMinArtist: 'Artists', + ratingsMixMinThresholdAria: 'Minimum stars ({{label}})', backupTitle: 'Backup & Restore', backupExport: 'Export settings', backupExportDesc: 'Saves all settings, server profiles, Last.fm config, theme, EQ and keybindings to a .psybkp file. Passwords are stored in plaintext — keep the file secure.', diff --git a/src/locales/fr.ts b/src/locales/fr.ts index 3d62ff7e..b625fcc9 100644 --- a/src/locales/fr.ts +++ b/src/locales/fr.ts @@ -506,6 +506,20 @@ export const frTranslation = { shortcutNativeFullscreen: 'Plein écran natif', tabSystem: 'Système', tabGeneral: 'Général', + ratingsSectionTitle: 'Notes', + ratingsSkipStarTitle: 'Passer pour 1 étoile', + ratingsSkipStarDesc: + 'Après N sauts manuels du même morceau : note serveur 1 étoile si encore sans note.', + ratingsSkipStarThresholdLabel: 'Sauts avant 1★', + ratingsSkipStarThresholdHint: + 'Uniquement si vous quittez le morceau avec Suivant / touche média « suivant », pas en fin de lecture. Aucun effet si le morceau a déjà au moins 1 étoile.', + ratingsMixFilterTitle: 'Filtrage par note', + ratingsMixFilterDesc: + 'Filtrer le contenu peu noté dans les mix aléatoires et les albums aléatoires. Cliquer de nouveau sur la même étoile désactive le seuil.', + ratingsMixMinSong: 'Morceaux (note du titre)', + ratingsMixMinAlbum: 'Albums', + ratingsMixMinArtist: 'Artistes', + ratingsMixMinThresholdAria: 'Étoiles minimum ({{label}})', backupTitle: 'Sauvegarde & Restauration', backupExport: 'Exporter les paramètres', backupExportDesc: 'Enregistre tous les paramètres, profils serveur, configuration Last.fm, thème, EQ et raccourcis dans un fichier .psybkp. Les mots de passe sont stockés en clair — conservez le fichier en sécurité.', diff --git a/src/locales/nb.ts b/src/locales/nb.ts index 532921dd..64cf36f5 100644 --- a/src/locales/nb.ts +++ b/src/locales/nb.ts @@ -489,6 +489,20 @@ export const nbTranslation = { tabServer: 'Tjener', tabSystem: 'System', tabGeneral: 'Generelt', + ratingsSectionTitle: 'Vurderinger', + ratingsSkipStarTitle: 'Hopp for 1 stjerne', + ratingsSkipStarDesc: + 'Etter N manuelle hopp på samme spor: servervurdering 1 stjerne hvis fortsatt uvurdert.', + ratingsSkipStarThresholdLabel: 'Hopp før 1★', + ratingsSkipStarThresholdHint: + 'Bare når du forlater sporet med Neste / medietast «neste», ikke når det slutter av seg selv. Ingenting skjer hvis sporet allerede har minst 1 stjerne.', + ratingsMixFilterTitle: 'Filtrering etter vurdering', + ratingsMixFilterDesc: + 'Filtrer innhold med lav vurdering i tilfeldige mikser og tilfeldige album. Klikk samme stjerne igjen for å slå av terskelen.', + ratingsMixMinSong: 'Spor (sporvurdering)', + ratingsMixMinAlbum: 'Album', + ratingsMixMinArtist: 'Artister', + ratingsMixMinThresholdAria: 'Minimum stjerner ({{label}})', backupTitle: 'Sikkerhetskopiering og gjenoppretting', backupExport: 'Eksporter innstillinger', backupExportDesc: 'Lagrer alle innstillinger, tjenerprofiler, Last.fm-konfigurasjon, tema, jevnstiller og tastebindinger til en .psybkp-fil. Passordet lagres i klartekst – hold filen sikker.', diff --git a/src/locales/nl.ts b/src/locales/nl.ts index 3dde3436..254efcda 100644 --- a/src/locales/nl.ts +++ b/src/locales/nl.ts @@ -506,6 +506,20 @@ export const nlTranslation = { shortcutNativeFullscreen: 'Systeemvolledig scherm', tabSystem: 'Systeem', tabGeneral: 'Algemeen', + ratingsSectionTitle: 'Beoordelingen', + ratingsSkipStarTitle: 'Overslaan voor 1 ster', + ratingsSkipStarDesc: + 'Na N handmatige overslagen van hetzelfde nummer: serverbeoordeling 1 ster als nog niet beoordeeld.', + ratingsSkipStarThresholdLabel: 'Overslagen voor 1★', + ratingsSkipStarThresholdHint: + 'Alleen als je het nummer verlaat met Volgende / mediatoets «volgende», niet als het vanzelf stopt. Geen wijziging als het nummer al minstens 1 ster heeft.', + ratingsMixFilterTitle: 'Filter op beoordeling', + ratingsMixFilterDesc: + 'Content met lage beoordeling filteren in willekeurige mixen en willekeurige albums. Klik dezelfde ster opnieuw om de drempel uit te zetten.', + ratingsMixMinSong: 'Nummers (nummerbeoordeling)', + ratingsMixMinAlbum: 'Albums', + ratingsMixMinArtist: 'Artiesten', + ratingsMixMinThresholdAria: 'Minimum sterren ({{label}})', backupTitle: 'Back-up & Herstel', backupExport: 'Instellingen exporteren', backupExportDesc: 'Slaat alle instellingen, serverprofielen, Last.fm-configuratie, thema, EQ en sneltoetsen op in een .psybkp-bestand. Wachtwoorden worden opgeslagen als leesbare tekst — bewaar het bestand veilig.', diff --git a/src/locales/ru.ts b/src/locales/ru.ts index 679d48c9..7ba51955 100644 --- a/src/locales/ru.ts +++ b/src/locales/ru.ts @@ -511,6 +511,20 @@ export const ruTranslation = { tabServer: 'Сервер', tabSystem: 'Система', tabGeneral: 'Общие', + ratingsSectionTitle: 'Рейтинги', + ratingsSkipStarTitle: 'Скипнуть для 1 звезды', + ratingsSkipStarDesc: + 'N ручных скипов трека — 1 звезда рейтинга, если трек ещё без оценки.', + ratingsSkipStarThresholdLabel: 'Скипов', + ratingsSkipStarThresholdHint: + 'Считаются только переходы «Далее» / медиаклавиша «следующий», не окончание трека само по себе. Не меняет оценку, если у трека уже не ниже 1★.', + ratingsMixFilterTitle: 'Фильтрация по рейтингу', + ratingsMixFilterDesc: + 'Фильтровать с низким рейтингом в Случайных миксах и Случайных альбомах. Повторный клик по выбранной звезде отключает порог.', + ratingsMixMinSong: 'Песни', + ratingsMixMinAlbum: 'Альбомы', + ratingsMixMinArtist: 'Исполнители', + ratingsMixMinThresholdAria: 'Минимум звёзд: {{label}}', backupTitle: 'Резервная копия', backupExport: 'Экспорт настроек', backupExportDesc: diff --git a/src/locales/zh.ts b/src/locales/zh.ts index f1f97674..6b91a4bb 100644 --- a/src/locales/zh.ts +++ b/src/locales/zh.ts @@ -486,6 +486,20 @@ export const zhTranslation = { tabServer: '服务器', tabSystem: '系统', tabGeneral: '通用', + ratingsSectionTitle: '评分', + ratingsSkipStarTitle: '跳过以评 1 星', + ratingsSkipStarDesc: + '同一曲目手动跳过 N 次后,若尚未评分则在服务器上设为 1 星。', + ratingsSkipStarThresholdLabel: '跳过次数(至 1★)', + ratingsSkipStarThresholdHint: + '仅统计用“下一曲”或媒体键离开该曲的情况;自然播放结束不计入。曲目已有 1 星或以上时不改变。', + ratingsMixFilterTitle: '按评分筛选', + ratingsMixFilterDesc: + '在随机混合与随机专辑中筛选低评分内容。再次点击同一颗星可关闭该列阈值。', + ratingsMixMinSong: '歌曲(曲目评分)', + ratingsMixMinAlbum: '专辑', + ratingsMixMinArtist: '艺人', + ratingsMixMinThresholdAria: '最低星数({{label}})', backupTitle: '备份与恢复', backupExport: '导出设置', backupExportDesc: '将所有设置、服务器配置、Last.fm 配置、主题、均衡器和快捷键保存到 .psybkp 文件。密码以明文存储——请妥善保管该文件。', diff --git a/src/pages/RandomMix.tsx b/src/pages/RandomMix.tsx index 23e85088..56e6be7d 100644 --- a/src/pages/RandomMix.tsx +++ b/src/pages/RandomMix.tsx @@ -1,10 +1,11 @@ -import React, { useEffect, useState } from 'react'; +import React, { useEffect, useMemo, useState } from 'react'; import { getRandomSongs, getGenres, SubsonicSong, SubsonicGenre, star, unstar } from '../api/subsonic'; import { usePlayerStore, songToTrack } from '../store/playerStore'; import { useAuthStore } from '../store/authStore'; import { Play, RefreshCw, ChevronDown, ChevronUp, Heart } from 'lucide-react'; import { useTranslation } from 'react-i18next'; import { useDragDrop } from '../contexts/DragDropContext'; +import { passesMixMinRatings } from '../utils/mixRatingFilter'; const AUDIOBOOK_GENRES = [ 'hörbuch', 'hoerbuch', 'hörspiel', 'hoerspiel', @@ -35,7 +36,26 @@ export default function RandomMix() { const [contextMenuSongId, setContextMenuSongId] = useState(null); const psyDrag = useDragDrop(); const [starredSongs, setStarredSongs] = useState>(new Set()); - const { excludeAudiobooks, setExcludeAudiobooks, customGenreBlacklist, setCustomGenreBlacklist } = useAuthStore(); + const { + excludeAudiobooks, + setExcludeAudiobooks, + customGenreBlacklist, + setCustomGenreBlacklist, + mixMinRatingFilterEnabled, + mixMinRatingSong, + mixMinRatingAlbum, + mixMinRatingArtist, + } = useAuthStore(); + + const mixRatingCfg = useMemo( + () => ({ + enabled: mixMinRatingFilterEnabled, + minSong: mixMinRatingSong, + minAlbum: mixMinRatingAlbum, + minArtist: mixMinRatingArtist, + }), + [mixMinRatingFilterEnabled, mixMinRatingSong, mixMinRatingAlbum, mixMinRatingArtist] + ); const musicLibraryFilterVersion = useAuthStore(s => s.musicLibraryFilterVersion); const [addedGenre, setAddedGenre] = useState(null); const [addedArtist, setAddedArtist] = useState(null); @@ -58,9 +78,17 @@ export default function RandomMix() { setSongs([]); getRandomSongs(50) .then(fetched => { - setSongs(fetched); + const cfg = useAuthStore.getState(); + const mixCfg = { + enabled: cfg.mixMinRatingFilterEnabled, + minSong: cfg.mixMinRatingSong, + minAlbum: cfg.mixMinRatingAlbum, + minArtist: cfg.mixMinRatingArtist, + }; + const filtered = fetched.filter(s => passesMixMinRatings(s, mixCfg)); + setSongs(filtered); const st = new Set(); - fetched.forEach(s => { if (s.starred) st.add(s.id); }); + filtered.forEach(s => { if (s.starred) st.add(s.id); }); setStarredSongs(st); setLoading(false); }) @@ -97,6 +125,7 @@ export default function RandomMix() { if (song.title && checkText(song.title)) return false; if (song.album && checkText(song.album)) return false; if (song.artist && checkText(song.artist)) return false; + if (!passesMixMinRatings(song, mixRatingCfg)) return false; return true; }); @@ -133,7 +162,14 @@ export default function RandomMix() { setGenreMixSongs([]); try { const fetched = await getRandomSongs(50, genre, 45000); - setGenreMixSongs(fetched); + const cfg = useAuthStore.getState(); + const mixCfg = { + enabled: cfg.mixMinRatingFilterEnabled, + minSong: cfg.mixMinRatingSong, + minAlbum: cfg.mixMinRatingAlbum, + minArtist: cfg.mixMinRatingArtist, + }; + setGenreMixSongs(fetched.filter(s => passesMixMinRatings(s, mixCfg))); } catch {} setGenreMixLoading(false); setGenreMixComplete(true); diff --git a/src/pages/Settings.tsx b/src/pages/Settings.tsx index d6fc9bad..56267442 100644 --- a/src/pages/Settings.tsx +++ b/src/pages/Settings.tsx @@ -5,7 +5,7 @@ import { useNavigate, useLocation } from 'react-router-dom'; import { Wifi, WifiOff, Globe, Music2, Sliders, LogOut, CheckCircle2, FolderOpen, Palette, Server, Plus, Trash2, Eye, EyeOff, Info, ExternalLink, Shuffle, X, Play, Type, Keyboard, ChevronDown, - GripVertical, PanelLeft, RotateCcw, LayoutGrid, AppWindow, HardDrive, Upload, Download, Waves + GripVertical, PanelLeft, RotateCcw, LayoutGrid, AppWindow, HardDrive, Upload, Download, Waves, Star } from 'lucide-react'; import { exportBackup, importBackup } from '../utils/backup'; import { showToast } from '../utils/toast'; @@ -18,7 +18,7 @@ import { lastfmGetToken, lastfmAuthUrl, lastfmGetSession, lastfmGetUserInfo, Las import LastfmIcon from '../components/LastfmIcon'; import CustomSelect from '../components/CustomSelect'; import ThemePicker from '../components/ThemePicker'; -import { useAuthStore, ServerProfile } from '../store/authStore'; +import { useAuthStore, ServerProfile, MIX_MIN_RATING_FILTER_MAX_STARS } from '../store/authStore'; import { IS_LINUX } from '../utils/platform'; import { useThemeStore } from '../store/themeStore'; import { useFontStore, FontId } from '../store/fontStore'; @@ -32,6 +32,7 @@ import { pingWithCredentials } from '../api/subsonic'; import { open as openDialog } from '@tauri-apps/plugin-dialog'; import { useTranslation } from 'react-i18next'; import Equalizer from '../components/Equalizer'; +import StarRating from '../components/StarRating'; const AUDIOBOOK_GENRES_DISPLAY = ['Hörbuch', 'Hoerbuch', 'Hörspiel', 'Hoerspiel', 'Audiobook', 'Audio Book', 'Spoken Word', 'Spokenword', 'Podcast', 'Kapitel', 'Thriller', 'Krimi', 'Speech', 'Fantasy', 'Comedy', 'Literature']; @@ -777,6 +778,111 @@ export default function Settings() {
+ {/* Ratings (single block under Random Mix) */} +
+
+ +

{t('settings.ratingsSectionTitle')}

+
+
+
+
+
{t('settings.ratingsSkipStarTitle')}
+
{t('settings.ratingsSkipStarDesc')}
+
+
+ {auth.skipStarOnManualSkipsEnabled && ( + <> + + auth.setSkipStarManualSkipThreshold(Number(e.target.value))} + style={{ width: 72, padding: '6px 10px', fontSize: 13 }} + aria-label={t('settings.ratingsSkipStarThresholdLabel')} + /> + + )} + +
+
+ {auth.skipStarOnManualSkipsEnabled && ( +

+ {t('settings.ratingsSkipStarThresholdHint')} +

+ )} + +
+ +
+
+
{t('settings.ratingsMixFilterTitle')}
+
{t('settings.ratingsMixFilterDesc')}
+
+ +
+ {auth.mixMinRatingFilterEnabled && ( + <> +
+
+ {([ + { key: 'song', label: t('settings.ratingsMixMinSong'), value: auth.mixMinRatingSong, set: auth.setMixMinRatingSong }, + { key: 'album', label: t('settings.ratingsMixMinAlbum'), value: auth.mixMinRatingAlbum, set: auth.setMixMinRatingAlbum }, + { key: 'artist', label: t('settings.ratingsMixMinArtist'), value: auth.mixMinRatingArtist, set: auth.setMixMinRatingArtist }, + ] as const).map(row => ( +
+ {row.label} + +
+ ))} +
+ + )} +
+
+ )} diff --git a/src/store/authStore.ts b/src/store/authStore.ts index 6141f8be..e9b29646 100644 --- a/src/store/authStore.ts +++ b/src/store/authStore.ts @@ -60,6 +60,19 @@ interface AuthState { /** Parent directory; actual cache is `/psysonic-hot-cache/`. Empty = app data. */ hotCacheDownloadDir: string; + /** After this many manual skips of the same track, set track rating to 1 if still unrated (below 1 star). */ + skipStarOnManualSkipsEnabled: boolean; + /** Manual skips per track before applying rating 1 (when enabled). */ + skipStarManualSkipThreshold: number; + + /** Planned / active filter: random mixes (and later album flows) by min stars per axis. */ + mixMinRatingFilterEnabled: boolean; + /** 0 = off; 1–3 = require at least that many stars on the song (UI capped at 3). */ + mixMinRatingSong: number; + /** 0–3; uses `albumUserRating` on song payload when present (OpenSubsonic). */ + mixMinRatingAlbum: number; + mixMinRatingArtist: number; + /** Subsonic music folders for the active server (not persisted; refetched on login / server change). */ musicFolders: Array<{ id: string; name: string }>; /** @@ -125,6 +138,12 @@ interface AuthState { setHotCacheMaxMb: (v: number) => void; setHotCacheDebounceSec: (v: number) => void; setHotCacheDownloadDir: (v: string) => void; + setSkipStarOnManualSkipsEnabled: (v: boolean) => void; + setSkipStarManualSkipThreshold: (v: number) => void; + setMixMinRatingFilterEnabled: (v: boolean) => void; + setMixMinRatingSong: (v: number) => void; + setMixMinRatingAlbum: (v: number) => void; + setMixMinRatingArtist: (v: number) => void; setMusicFolders: (folders: Array<{ id: string; name: string }>) => void; setMusicLibraryFilter: (folderId: 'all' | string) => void; logout: () => void; @@ -138,6 +157,19 @@ function generateId(): string { return Date.now().toString(36) + Math.random().toString(36).slice(2); } +/** Upper bound for mix min-rating thresholds (UI shows five stars, only 1…this many are selectable). */ +export const MIX_MIN_RATING_FILTER_MAX_STARS = 3; + +function clampMixFilterMinStars(v: number): number { + if (!Number.isFinite(v)) return 0; + return Math.max(0, Math.min(MIX_MIN_RATING_FILTER_MAX_STARS, Math.round(v))); +} + +function clampSkipStarThreshold(v: number): number { + if (!Number.isFinite(v)) return 3; + return Math.max(1, Math.min(99, Math.round(v))); +} + export const useAuthStore = create()( persist( (set, get) => ({ @@ -177,6 +209,12 @@ export const useAuthStore = create()( hotCacheMaxMb: 256, hotCacheDebounceSec: 30, hotCacheDownloadDir: '', + skipStarOnManualSkipsEnabled: false, + skipStarManualSkipThreshold: 3, + mixMinRatingFilterEnabled: false, + mixMinRatingSong: 0, + mixMinRatingAlbum: 0, + mixMinRatingArtist: 0, musicFolders: [], musicLibraryFilterByServer: {}, musicLibraryFilterVersion: 0, @@ -267,6 +305,13 @@ export const useAuthStore = create()( setHotCacheDebounceSec: (v) => set({ hotCacheDebounceSec: v }), setHotCacheDownloadDir: (v) => set({ hotCacheDownloadDir: v }), + setSkipStarOnManualSkipsEnabled: (v) => set({ skipStarOnManualSkipsEnabled: v }), + setSkipStarManualSkipThreshold: (v) => set({ skipStarManualSkipThreshold: clampSkipStarThreshold(v) }), + setMixMinRatingFilterEnabled: (v) => set({ mixMinRatingFilterEnabled: v }), + setMixMinRatingSong: (v) => set({ mixMinRatingSong: clampMixFilterMinStars(v) }), + setMixMinRatingAlbum: (v) => set({ mixMinRatingAlbum: clampMixFilterMinStars(v) }), + setMixMinRatingArtist: (v) => set({ mixMinRatingArtist: clampMixFilterMinStars(v) }), + setMusicFolders: (folders) => { const sid = get().activeServerId; set(s => { @@ -316,6 +361,14 @@ export const useAuthStore = create()( const { musicFolders: _mf, musicLibraryFilterVersion: _fv, ...rest } = state; return rest; }, + onRehydrateStorage: () => (state, error) => { + if (error || !state) return; + useAuthStore.setState({ + mixMinRatingSong: clampMixFilterMinStars(state.mixMinRatingSong as number), + mixMinRatingAlbum: clampMixFilterMinStars(state.mixMinRatingAlbum as number), + mixMinRatingArtist: clampMixFilterMinStars(state.mixMinRatingArtist as number), + }); + }, } ) ); diff --git a/src/store/playerStore.ts b/src/store/playerStore.ts index 6c82fb1c..ab13f65b 100644 --- a/src/store/playerStore.ts +++ b/src/store/playerStore.ts @@ -3,7 +3,7 @@ import { persist, createJSONStorage } from 'zustand/middleware'; import { invoke } from '@tauri-apps/api/core'; import { listen } from '@tauri-apps/api/event'; import { showToast } from '../utils/toast'; -import { buildCoverArtUrl, getPlayQueue, savePlayQueue, reportNowPlaying, scrobbleSong, SubsonicSong, getSong, getRandomSongs, getSimilarSongs2, getTopSongs, InternetRadioStation } from '../api/subsonic'; +import { buildCoverArtUrl, getPlayQueue, savePlayQueue, reportNowPlaying, scrobbleSong, SubsonicSong, getSong, getRandomSongs, getSimilarSongs2, getTopSongs, InternetRadioStation, setRating } from '../api/subsonic'; import { resolvePlaybackUrl } from '../utils/resolvePlaybackUrl'; import { setDeferHotCachePrefetch } from '../utils/hotCacheGate'; import { lastfmScrobble, lastfmUpdateNowPlaying, lastfmLoveTrack, lastfmUnloveTrack, lastfmGetTrackLoved, lastfmGetAllLovedTracks } from '../api/lastfm'; @@ -161,6 +161,32 @@ let seekTarget: number | null = null; // to the Rust backend before it has finished the previous one. let togglePlayLock = false; +/** Manual skip counts per track id toward optional “skip → rating 1” (in-memory). */ +const manualSkipStarCounts = new Map(); + +function applySkipStarOnManualNext(skippedTrack: Track | null, manual: boolean): void { + if (!manual || !skippedTrack) return; + const { skipStarOnManualSkipsEnabled, skipStarManualSkipThreshold } = useAuthStore.getState(); + if (!skipStarOnManualSkipsEnabled || skipStarManualSkipThreshold < 1) return; + const id = skippedTrack.id; + const n = (manualSkipStarCounts.get(id) ?? 0) + 1; + if (n >= skipStarManualSkipThreshold) { + manualSkipStarCounts.delete(id); + const cur = skippedTrack.userRating ?? 0; + if (cur >= 1) return; + setRating(id, 1) + .then(() => { + usePlayerStore.setState(s => ({ + queue: s.queue.map(t => (t.id === id ? { ...t, userRating: 1 } : t)), + currentTrack: s.currentTrack?.id === id ? { ...s.currentTrack, userRating: 1 } : s.currentTrack, + })); + }) + .catch(() => {}); + } else { + manualSkipStarCounts.set(id, n); + } +} + // ── HTML5 Radio Player ──────────────────────────────────────────────────────── // Internet radio streams are played via a native
diff --git a/src/locales/de.ts b/src/locales/de.ts index 301d086f..c0e48a3b 100644 --- a/src/locales/de.ts +++ b/src/locales/de.ts @@ -511,17 +511,15 @@ export const deTranslation = { ratingsSectionTitle: 'Bewertungen', ratingsSkipStarTitle: 'Überspringen für 1 Stern', ratingsSkipStarDesc: - 'Nach N manuellen Überspringen desselben Titels: Server-Bewertung 1 Stern, sofern noch unbewertet.', + 'Bei N Überspringen hintereinander: Titel auf 1 Stern setzen. Nur für zuvor unbewertete Titel.', ratingsSkipStarThresholdLabel: 'Überspringer bis 1★', - ratingsSkipStarThresholdHint: - 'Nur wenn Sie den Titel mit Weiter / Medientaste verlassen — nicht wenn er von selbst endet. Keine Änderung, wenn der Titel schon mindestens 1 Stern hat.', ratingsMixFilterTitle: 'Filter nach Bewertung', ratingsMixFilterDesc: - 'Inhalte mit niedriger Bewertung in Zufallsmixen und zufälligen Alben filtern. Gleichen Stern erneut klicken, um die Schwelle auszuschalten.', - ratingsMixMinSong: 'Titel (Titelbewertung)', + 'Inhalte mit niedriger Bewertung in {{mix}} und {{albums}} filtern. Erneut auf den gewählten Stern klicken, um die Schwelle zu deaktivieren.', + ratingsMixMinSong: 'Titel', ratingsMixMinAlbum: 'Alben', ratingsMixMinArtist: 'Interpreten', - ratingsMixMinThresholdAria: 'Mindest-Sterne ({{label}})', + ratingsMixMinThresholdAria: 'Mindest-Sterne: {{label}}', backupTitle: 'Backup & Wiederherstellung', backupExport: 'Einstellungen exportieren', backupExportDesc: 'Speichert alle Einstellungen, Serverprofile, Last.fm-Konfiguration, Theme, EQ und Tastenkürzel in eine .psybkp-Datei. Passwörter werden im Klartext gespeichert — Datei sicher aufbewahren.', diff --git a/src/locales/en.ts b/src/locales/en.ts index 2636b519..721dec3f 100644 --- a/src/locales/en.ts +++ b/src/locales/en.ts @@ -496,16 +496,15 @@ export const enTranslation = { ratingsSectionTitle: 'Ratings', ratingsSkipStarTitle: 'Skip for 1 star', ratingsSkipStarDesc: - 'After N manual skips of the same track, set server rating to 1 star if the track is still unrated.', + 'After N skips in a row, set the track to 1★. Only for tracks not rated before.', ratingsSkipStarThresholdLabel: 'Skips before 1★', - ratingsSkipStarThresholdHint: 'Only counts when you leave the track with Next / media “next”, not when it ends on its own. Does nothing if the track is already rated 1 star or higher.', ratingsMixFilterTitle: 'Filter by rating', ratingsMixFilterDesc: - 'Filter low-rated items in random mixes and random albums. Click the same star again to turn off that column’s threshold.', - ratingsMixMinSong: 'Songs (track rating)', + 'Filter low-rated items in {{mix}} and {{albums}}. Click the selected star again to turn off the threshold.', + ratingsMixMinSong: 'Songs', ratingsMixMinAlbum: 'Albums', ratingsMixMinArtist: 'Artists', - ratingsMixMinThresholdAria: 'Minimum stars ({{label}})', + ratingsMixMinThresholdAria: 'Minimum stars: {{label}}', backupTitle: 'Backup & Restore', backupExport: 'Export settings', backupExportDesc: 'Saves all settings, server profiles, Last.fm config, theme, EQ and keybindings to a .psybkp file. Passwords are stored in plaintext — keep the file secure.', diff --git a/src/locales/fr.ts b/src/locales/fr.ts index b625fcc9..75389410 100644 --- a/src/locales/fr.ts +++ b/src/locales/fr.ts @@ -509,17 +509,15 @@ export const frTranslation = { ratingsSectionTitle: 'Notes', ratingsSkipStarTitle: 'Passer pour 1 étoile', ratingsSkipStarDesc: - 'Après N sauts manuels du même morceau : note serveur 1 étoile si encore sans note.', + 'Après N sauts d’affilée : mettre le morceau à 1 étoile. Uniquement s’il n’était pas encore noté.', ratingsSkipStarThresholdLabel: 'Sauts avant 1★', - ratingsSkipStarThresholdHint: - 'Uniquement si vous quittez le morceau avec Suivant / touche média « suivant », pas en fin de lecture. Aucun effet si le morceau a déjà au moins 1 étoile.', ratingsMixFilterTitle: 'Filtrage par note', ratingsMixFilterDesc: - 'Filtrer le contenu peu noté dans les mix aléatoires et les albums aléatoires. Cliquer de nouveau sur la même étoile désactive le seuil.', - ratingsMixMinSong: 'Morceaux (note du titre)', + 'Filtrer le contenu peu noté dans {{mix}} et {{albums}}. Cliquer de nouveau sur l’étoile choisie désactive le seuil.', + ratingsMixMinSong: 'Morceaux', ratingsMixMinAlbum: 'Albums', ratingsMixMinArtist: 'Artistes', - ratingsMixMinThresholdAria: 'Étoiles minimum ({{label}})', + ratingsMixMinThresholdAria: 'Étoiles minimum : {{label}}', backupTitle: 'Sauvegarde & Restauration', backupExport: 'Exporter les paramètres', backupExportDesc: 'Enregistre tous les paramètres, profils serveur, configuration Last.fm, thème, EQ et raccourcis dans un fichier .psybkp. Les mots de passe sont stockés en clair — conservez le fichier en sécurité.', diff --git a/src/locales/nb.ts b/src/locales/nb.ts index 64cf36f5..4a4f0587 100644 --- a/src/locales/nb.ts +++ b/src/locales/nb.ts @@ -492,17 +492,15 @@ export const nbTranslation = { ratingsSectionTitle: 'Vurderinger', ratingsSkipStarTitle: 'Hopp for 1 stjerne', ratingsSkipStarDesc: - 'Etter N manuelle hopp på samme spor: servervurdering 1 stjerne hvis fortsatt uvurdert.', + 'Etter N hopp på rad: sett sporet til 1 stjerne. Bare for spor som ikke var vurdert før.', ratingsSkipStarThresholdLabel: 'Hopp før 1★', - ratingsSkipStarThresholdHint: - 'Bare når du forlater sporet med Neste / medietast «neste», ikke når det slutter av seg selv. Ingenting skjer hvis sporet allerede har minst 1 stjerne.', ratingsMixFilterTitle: 'Filtrering etter vurdering', ratingsMixFilterDesc: - 'Filtrer innhold med lav vurdering i tilfeldige mikser og tilfeldige album. Klikk samme stjerne igjen for å slå av terskelen.', - ratingsMixMinSong: 'Spor (sporvurdering)', + 'Filtrer innhold med lav vurdering i {{mix}} og {{albums}}. Klikk på den valgte stjerna igjen for å slå av terskelen.', + ratingsMixMinSong: 'Spor', ratingsMixMinAlbum: 'Album', ratingsMixMinArtist: 'Artister', - ratingsMixMinThresholdAria: 'Minimum stjerner ({{label}})', + ratingsMixMinThresholdAria: 'Minimum stjerner: {{label}}', backupTitle: 'Sikkerhetskopiering og gjenoppretting', backupExport: 'Eksporter innstillinger', backupExportDesc: 'Lagrer alle innstillinger, tjenerprofiler, Last.fm-konfigurasjon, tema, jevnstiller og tastebindinger til en .psybkp-fil. Passordet lagres i klartekst – hold filen sikker.', diff --git a/src/locales/nl.ts b/src/locales/nl.ts index 254efcda..cf59b459 100644 --- a/src/locales/nl.ts +++ b/src/locales/nl.ts @@ -509,17 +509,15 @@ export const nlTranslation = { ratingsSectionTitle: 'Beoordelingen', ratingsSkipStarTitle: 'Overslaan voor 1 ster', ratingsSkipStarDesc: - 'Na N handmatige overslagen van hetzelfde nummer: serverbeoordeling 1 ster als nog niet beoordeeld.', + 'Na N overslagen op rij: nummer op 1 ster zetten. Alleen voor nummers die nog niet beoordeeld waren.', ratingsSkipStarThresholdLabel: 'Overslagen voor 1★', - ratingsSkipStarThresholdHint: - 'Alleen als je het nummer verlaat met Volgende / mediatoets «volgende», niet als het vanzelf stopt. Geen wijziging als het nummer al minstens 1 ster heeft.', ratingsMixFilterTitle: 'Filter op beoordeling', ratingsMixFilterDesc: - 'Content met lage beoordeling filteren in willekeurige mixen en willekeurige albums. Klik dezelfde ster opnieuw om de drempel uit te zetten.', - ratingsMixMinSong: 'Nummers (nummerbeoordeling)', + 'Content met lage beoordeling filteren in {{mix}} en {{albums}}. Klik opnieuw op de gekozen ster om de drempel uit te zetten.', + ratingsMixMinSong: 'Nummers', ratingsMixMinAlbum: 'Albums', ratingsMixMinArtist: 'Artiesten', - ratingsMixMinThresholdAria: 'Minimum sterren ({{label}})', + ratingsMixMinThresholdAria: 'Minimum sterren: {{label}}', backupTitle: 'Back-up & Herstel', backupExport: 'Instellingen exporteren', backupExportDesc: 'Slaat alle instellingen, serverprofielen, Last.fm-configuratie, thema, EQ en sneltoetsen op in een .psybkp-bestand. Wachtwoorden worden opgeslagen als leesbare tekst — bewaar het bestand veilig.', diff --git a/src/locales/ru.ts b/src/locales/ru.ts index 7ba51955..cf9ec20c 100644 --- a/src/locales/ru.ts +++ b/src/locales/ru.ts @@ -460,6 +460,9 @@ export const ruTranslation = { showTrayIconDesc: 'Показывать Psysonic в области уведомлений / строке меню.', minimizeToTray: 'Сворачивать в трей', minimizeToTrayDesc: 'При закрытии окна не выходить из приложения, а оставаться в трее.', + useCustomTitlebar: 'Своя строка заголовка', + useCustomTitlebarDesc: + 'Заменить системную строку заголовка встроенной, в стиле темы приложения. Отключите, чтобы использовать родную строку GNOME/GTK.', discordRichPresence: 'Статус в Discord', discordRichPresenceDesc: 'Показывать текущий трек в профиле и статусе Discord. Нужен запущенный клиент Discord.', @@ -514,13 +517,11 @@ export const ruTranslation = { ratingsSectionTitle: 'Рейтинги', ratingsSkipStarTitle: 'Скипнуть для 1 звезды', ratingsSkipStarDesc: - 'N ручных скипов трека — 1 звезда рейтинга, если трек ещё без оценки.', + 'При N скипов подряд ставить 1★ треку. Только для не оцененных ранее.', ratingsSkipStarThresholdLabel: 'Скипов', - ratingsSkipStarThresholdHint: - 'Считаются только переходы «Далее» / медиаклавиша «следующий», не окончание трека само по себе. Не меняет оценку, если у трека уже не ниже 1★.', ratingsMixFilterTitle: 'Фильтрация по рейтингу', ratingsMixFilterDesc: - 'Фильтровать с низким рейтингом в Случайных миксах и Случайных альбомах. Повторный клик по выбранной звезде отключает порог.', + 'Фильтровать с низким рейтингом в «{{mix}}» и «{{albums}}». Повторный клик по выбранной звезде отключает порог.', ratingsMixMinSong: 'Песни', ratingsMixMinAlbum: 'Альбомы', ratingsMixMinArtist: 'Исполнители', diff --git a/src/locales/zh.ts b/src/locales/zh.ts index 6b91a4bb..35b6e3c8 100644 --- a/src/locales/zh.ts +++ b/src/locales/zh.ts @@ -489,17 +489,15 @@ export const zhTranslation = { ratingsSectionTitle: '评分', ratingsSkipStarTitle: '跳过以评 1 星', ratingsSkipStarDesc: - '同一曲目手动跳过 N 次后,若尚未评分则在服务器上设为 1 星。', + '连续跳过 N 次后将曲目设为 1 星。仅适用于此前未评分的曲目。', ratingsSkipStarThresholdLabel: '跳过次数(至 1★)', - ratingsSkipStarThresholdHint: - '仅统计用“下一曲”或媒体键离开该曲的情况;自然播放结束不计入。曲目已有 1 星或以上时不改变。', ratingsMixFilterTitle: '按评分筛选', ratingsMixFilterDesc: - '在随机混合与随机专辑中筛选低评分内容。再次点击同一颗星可关闭该列阈值。', - ratingsMixMinSong: '歌曲(曲目评分)', + '在{{mix}}与{{albums}}中筛选低评分内容。再次点击所选星标可关闭阈值。', + ratingsMixMinSong: '歌曲', ratingsMixMinAlbum: '专辑', ratingsMixMinArtist: '艺人', - ratingsMixMinThresholdAria: '最低星数({{label}})', + ratingsMixMinThresholdAria: '最低星数:{{label}}', backupTitle: '备份与恢复', backupExport: '导出设置', backupExportDesc: '将所有设置、服务器配置、Last.fm 配置、主题、均衡器和快捷键保存到 .psybkp 文件。密码以明文存储——请妥善保管该文件。', diff --git a/src/pages/AlbumDetail.tsx b/src/pages/AlbumDetail.tsx index 6477ad9f..f28355b7 100644 --- a/src/pages/AlbumDetail.tsx +++ b/src/pages/AlbumDetail.tsx @@ -34,6 +34,7 @@ export default function AlbumDetail() { const openContextMenu = usePlayerStore(s => s.openContextMenu); const starredOverrides = usePlayerStore(s => s.starredOverrides); const setStarredOverride = usePlayerStore(s => s.setStarredOverride); + const userRatingOverrides = usePlayerStore(s => s.userRatingOverrides); const currentTrack = usePlayerStore(s => s.currentTrack); const isPlaying = usePlayerStore(s => s.isPlaying); @@ -137,6 +138,7 @@ const handleEnqueueAll = () => { const handleRate = async (songId: string, rating: number) => { setRatings(r => ({ ...r, [songId]: rating })); + usePlayerStore.getState().setUserRatingOverride(songId, rating); await setRating(songId, rating); }; @@ -331,6 +333,7 @@ const handleEnqueueAll = () => { currentTrack={currentTrack} isPlaying={isPlaying} ratings={ratings} + userRatingOverrides={userRatingOverrides} starredSongs={new Set([ ...[...starredSongs].filter(id => starredOverrides[id] !== false), ...Object.entries(starredOverrides).filter(([, v]) => v).map(([k]) => k), diff --git a/src/pages/Home.tsx b/src/pages/Home.tsx index 8ba36968..383ee1e8 100644 --- a/src/pages/Home.tsx +++ b/src/pages/Home.tsx @@ -7,10 +7,19 @@ import { NavLink, useNavigate } from 'react-router-dom'; import { ChevronRight } from 'lucide-react'; import { useHomeStore } from '../store/homeStore'; import { useAuthStore } from '../store/authStore'; +import { filterAlbumsByMixRatings, getMixMinRatingsConfigFromAuth } from '../utils/mixRatingFilter'; + +/** Match Random Albums overshoot when mix filter uses album/artist axes so hero + discover row can still fill. */ +const HOME_RANDOM_FETCH = 100; +const HOME_HERO_COUNT = 8; +const HOME_DISCOVER_SLICE = 20; export default function Home() { const homeSections = useHomeStore(s => s.sections); const musicLibraryFilterVersion = useAuthStore(s => s.musicLibraryFilterVersion); + const mixMinRatingFilterEnabled = useAuthStore(s => s.mixMinRatingFilterEnabled); + const mixMinRatingAlbum = useAuthStore(s => s.mixMinRatingAlbum); + const mixMinRatingArtist = useAuthStore(s => s.mixMinRatingArtist); const isVisible = (id: string) => homeSections.find(s => s.id === id)?.visible ?? true; const [starred, setStarred] = useState([]); @@ -23,30 +32,50 @@ export default function Home() { const [loading, setLoading] = useState(true); useEffect(() => { - Promise.all([ - getAlbumList('starred', 12).catch(() => []), - getAlbumList('newest', 12).catch(() => []), - getAlbumList('random', 20).catch(() => []), - getAlbumList('frequent', 12).catch(() => []), - getAlbumList('recent', 12).catch(() => []), - isVisible('discoverArtists') ? getArtists().catch(() => []) : Promise.resolve([]), - ]).then(([s, n, r, f, rp, artists]) => { - setStarred(s); - setRecent(n); - setHeroAlbums(r.slice(0, 8)); - setRandom(r.slice(8)); - setMostPlayed(f); - setRecentlyPlayed(rp); - // Pick 16 random artists via Fisher-Yates shuffle - const shuffled = [...artists]; - for (let i = shuffled.length - 1; i > 0; i--) { - const j = Math.floor(Math.random() * (i + 1)); - [shuffled[i], shuffled[j]] = [shuffled[j], shuffled[i]]; + let cancelled = false; + setLoading(true); + (async () => { + try { + const mixCfg = getMixMinRatingsConfigFromAuth(); + const albumMix = + mixCfg.enabled && (mixCfg.minAlbum > 0 || mixCfg.minArtist > 0); + const randomSize = albumMix ? HOME_RANDOM_FETCH : HOME_DISCOVER_SLICE; + const [s, n, rRaw, f, rp, artists] = await Promise.all([ + getAlbumList('starred', 12).catch(() => []), + getAlbumList('newest', 12).catch(() => []), + getAlbumList('random', randomSize).catch(() => []), + getAlbumList('frequent', 12).catch(() => []), + getAlbumList('recent', 12).catch(() => []), + isVisible('discoverArtists') ? getArtists().catch(() => []) : Promise.resolve([]), + ]); + if (cancelled) return; + const r = await filterAlbumsByMixRatings(rRaw, mixCfg); + setStarred(s); + setRecent(n); + setHeroAlbums(r.slice(0, HOME_HERO_COUNT)); + setRandom(r.slice(HOME_HERO_COUNT, HOME_DISCOVER_SLICE)); + setMostPlayed(f); + setRecentlyPlayed(rp); + const shuffled = [...artists]; + for (let i = shuffled.length - 1; i > 0; i--) { + const j = Math.floor(Math.random() * (i + 1)); + [shuffled[i], shuffled[j]] = [shuffled[j], shuffled[i]]; + } + setRandomArtists(shuffled.slice(0, 16)); + } catch { + /* ignore */ + } finally { + if (!cancelled) setLoading(false); } - setRandomArtists(shuffled.slice(0, 16)); - setLoading(false); - }).catch(() => setLoading(false)); - }, [musicLibraryFilterVersion, homeSections]); + })(); + return () => { cancelled = true; }; + }, [ + musicLibraryFilterVersion, + homeSections, + mixMinRatingFilterEnabled, + mixMinRatingAlbum, + mixMinRatingArtist, + ]); const loadMore = async ( type: 'starred' | 'newest' | 'random' | 'frequent' | 'recent', @@ -55,7 +84,10 @@ export default function Home() { ) => { try { const more = await getAlbumList(type, 12, currentList.length); - const newItems = more.filter(m => !currentList.find(c => c.id === m.id)); + const mixCfg = getMixMinRatingsConfigFromAuth(); + const batch = + type === 'random' ? await filterAlbumsByMixRatings(more, mixCfg) : more; + const newItems = batch.filter(m => !currentList.find(c => c.id === m.id)); if (newItems.length > 0) setter(prev => [...prev, ...newItems]); } catch (e) { console.error('Failed to load more', e); diff --git a/src/pages/NowPlaying.tsx b/src/pages/NowPlaying.tsx index 6d2defbe..cdfd6ca1 100644 --- a/src/pages/NowPlaying.tsx +++ b/src/pages/NowPlaying.tsx @@ -211,6 +211,7 @@ export default function NowPlaying() { const navigate = useNavigate(); const currentTrack = usePlayerStore(s => s.currentTrack); + const userRatingOverrides = usePlayerStore(s => s.userRatingOverrides); const isPlaying = usePlayerStore(s => s.isPlaying); const showLyrics = useLyricsStore(s => s.showLyrics); const activeTab = useLyricsStore(s => s.activeTab); @@ -292,7 +293,7 @@ export default function NowPlaying() { {currentTrack.suffix && {currentTrack.suffix.toUpperCase()}} {currentTrack.bitRate && {currentTrack.bitRate} kbps} {currentTrack.duration && {formatTime(currentTrack.duration)}} - {renderStars(currentTrack.userRating)} + {renderStars(userRatingOverrides[currentTrack.id] ?? currentTrack.userRating)}
); - case 'rating': return handleRate(song.id, r)} />; + case 'rating': return handleRate(song.id, r)} />; case 'duration': return
{formatDuration(song.duration ?? 0)}
; case 'format': return (
diff --git a/src/pages/RandomAlbums.tsx b/src/pages/RandomAlbums.tsx index bb939c9f..e4d2f220 100644 --- a/src/pages/RandomAlbums.tsx +++ b/src/pages/RandomAlbums.tsx @@ -5,8 +5,13 @@ import AlbumCard from '../components/AlbumCard'; import GenreFilterBar from '../components/GenreFilterBar'; import { useTranslation } from 'react-i18next'; import { useAuthStore } from '../store/authStore'; +import { filterAlbumsByMixRatings, getMixMinRatingsConfigFromAuth } from '../utils/mixRatingFilter'; const ALBUM_COUNT = 30; +/** Extra pool when mix rating filter is on so we can still fill the grid after filtering. */ +const ALBUM_FETCH_OVERSHOOT = 100; +/** Cap genre-union size before rating prefetch (avoids hundreds of `getArtist` calls). */ +const GENRE_UNION_PREFILTER_CAP = 250; async function fetchByGenres(genres: string[]): Promise { const results = await Promise.all(genres.map(g => getAlbumsByGenre(g, 500, 0))); @@ -17,12 +22,17 @@ async function fetchByGenres(genres: string[]): Promise { const j = Math.floor(Math.random() * (i + 1)); [union[i], union[j]] = [union[j], union[i]]; } - return union.slice(0, ALBUM_COUNT); + const pool = union.slice(0, GENRE_UNION_PREFILTER_CAP); + const filtered = await filterAlbumsByMixRatings(pool, getMixMinRatingsConfigFromAuth()); + return filtered.slice(0, ALBUM_COUNT); } export default function RandomAlbums() { const { t } = useTranslation(); const musicLibraryFilterVersion = useAuthStore(s => s.musicLibraryFilterVersion); + const mixMinRatingFilterEnabled = useAuthStore(s => s.mixMinRatingFilterEnabled); + const mixMinRatingAlbum = useAuthStore(s => s.mixMinRatingAlbum); + const mixMinRatingArtist = useAuthStore(s => s.mixMinRatingArtist); const [albums, setAlbums] = useState([]); const [loading, setLoading] = useState(true); const [selectedGenres, setSelectedGenres] = useState([]); @@ -34,9 +44,13 @@ export default function RandomAlbums() { loadingRef.current = true; setLoading(true); try { + const mixCfg = getMixMinRatingsConfigFromAuth(); + const albumMixActive = + mixCfg.enabled && (mixCfg.minAlbum > 0 || mixCfg.minArtist > 0); + const randomSize = albumMixActive ? Math.max(ALBUM_COUNT * 3, ALBUM_FETCH_OVERSHOOT) : ALBUM_COUNT; const data = genres.length > 0 ? await fetchByGenres(genres) - : await getAlbumList('random', ALBUM_COUNT); + : (await filterAlbumsByMixRatings(await getAlbumList('random', randomSize), mixCfg)).slice(0, ALBUM_COUNT); setAlbums(data); } catch (e) { console.error(e); @@ -44,7 +58,12 @@ export default function RandomAlbums() { loadingRef.current = false; setLoading(false); } - }, [musicLibraryFilterVersion]); + }, [ + musicLibraryFilterVersion, + mixMinRatingFilterEnabled, + mixMinRatingAlbum, + mixMinRatingArtist, + ]); useEffect(() => { load(selectedGenres); }, [selectedGenres, load]); diff --git a/src/pages/RandomMix.tsx b/src/pages/RandomMix.tsx index 56e6be7d..09c35d04 100644 --- a/src/pages/RandomMix.tsx +++ b/src/pages/RandomMix.tsx @@ -1,11 +1,15 @@ import React, { useEffect, useMemo, useState } from 'react'; -import { getRandomSongs, getGenres, SubsonicSong, SubsonicGenre, star, unstar } from '../api/subsonic'; +import { getGenres, SubsonicSong, SubsonicGenre, star, unstar } from '../api/subsonic'; import { usePlayerStore, songToTrack } from '../store/playerStore'; import { useAuthStore } from '../store/authStore'; import { Play, RefreshCw, ChevronDown, ChevronUp, Heart } from 'lucide-react'; import { useTranslation } from 'react-i18next'; import { useDragDrop } from '../contexts/DragDropContext'; -import { passesMixMinRatings } from '../utils/mixRatingFilter'; +import { + fetchRandomMixSongsUntilFull, + getMixMinRatingsConfigFromAuth, + passesMixMinRatings, +} from '../utils/mixRatingFilter'; const AUDIOBOOK_GENRES = [ 'hörbuch', 'hoerbuch', 'hörspiel', 'hoerspiel', @@ -76,19 +80,11 @@ export default function RandomMix() { const fetchSongs = () => { setLoading(true); setSongs([]); - getRandomSongs(50) - .then(fetched => { - const cfg = useAuthStore.getState(); - const mixCfg = { - enabled: cfg.mixMinRatingFilterEnabled, - minSong: cfg.mixMinRatingSong, - minAlbum: cfg.mixMinRatingAlbum, - minArtist: cfg.mixMinRatingArtist, - }; - const filtered = fetched.filter(s => passesMixMinRatings(s, mixCfg)); - setSongs(filtered); + fetchRandomMixSongsUntilFull(getMixMinRatingsConfigFromAuth()) + .then(list => { + setSongs(list); const st = new Set(); - filtered.forEach(s => { if (s.starred) st.add(s.id); }); + list.forEach(s => { if (s.starred) st.add(s.id); }); setStarredSongs(st); setLoading(false); }) @@ -161,15 +157,11 @@ export default function RandomMix() { setGenreMixComplete(false); setGenreMixSongs([]); try { - const fetched = await getRandomSongs(50, genre, 45000); - const cfg = useAuthStore.getState(); - const mixCfg = { - enabled: cfg.mixMinRatingFilterEnabled, - minSong: cfg.mixMinRatingSong, - minAlbum: cfg.mixMinRatingAlbum, - minArtist: cfg.mixMinRatingArtist, - }; - setGenreMixSongs(fetched.filter(s => passesMixMinRatings(s, mixCfg))); + const list = await fetchRandomMixSongsUntilFull(getMixMinRatingsConfigFromAuth(), { + genre, + timeout: 45000, + }); + setGenreMixSongs(list); } catch {} setGenreMixLoading(false); setGenreMixComplete(true); diff --git a/src/pages/Settings.tsx b/src/pages/Settings.tsx index 56267442..75955615 100644 --- a/src/pages/Settings.tsx +++ b/src/pages/Settings.tsx @@ -819,18 +819,18 @@ export default function Settings() {
- {auth.skipStarOnManualSkipsEnabled && ( -

- {t('settings.ratingsSkipStarThresholdHint')} -

- )}
{t('settings.ratingsMixFilterTitle')}
-
{t('settings.ratingsMixFilterDesc')}
+
+ {t('settings.ratingsMixFilterDesc', { + mix: t('sidebar.randomMix'), + albums: t('sidebar.randomAlbums'), + })} +