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.
This commit is contained in:
Maxim Isaev
2026-04-08 01:08:41 +03:00
parent c1624342d3
commit 705c80ef07
20 changed files with 462 additions and 61 deletions
+41
View File
@@ -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 && (
<div className="offline-storage-full-banner" role="alert">
+45 -1
View File
@@ -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 })}
</div>
<div className="artist-detail-entity-rating">
<span className="artist-detail-entity-rating-label">{t('entityRating.artistShort')}</span>
<StarRating
value={artistEntityRating}
onChange={handleArtistEntityRating}
disabled={artistEntityRatingSupport === 'track_only'}
labelKey="entityRating.artistAriaLabel"
/>
</div>
<div style={{ display: 'flex', gap: '8px', flexWrap: 'wrap' }}>
{(info?.lastFmUrl || artist.name) && (
<div className="artist-detail-links">
+12 -3
View File
@@ -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 (
<div key={key} style={{ position: 'relative', padding: 0, margin: 0, minWidth: 0, overflow: 'hidden' }}>
<div style={{ display: 'flex', width: '100%', height: '100%', alignItems: 'center', justifyContent: isCentered ? 'center' : 'flex-start', paddingLeft: isCentered ? 0 : 12 }}>
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{label}</span>
<div
style={{
display: 'flex',
width: '100%',
height: '100%',
alignItems: 'center',
justifyContent: isCentered ? 'center' : 'flex-start',
paddingLeft: isCentered ? 0 : 12,
}}
>
<span style={{ whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{label}</span>
</div>
{!isLastCol && <div className="col-resize-handle" onMouseDown={e => startResize(e, colIndex, 1)} />}
</div>
+14 -21
View File
@@ -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 (
<div className="star-rating">
{[1, 2, 3, 4, 5].map(n => (
<button
key={n}
className={`star ${(hover || value) >= n ? 'filled' : ''}`}
onMouseEnter={() => setHover(n)}
onMouseLeave={() => setHover(0)}
onClick={() => onChange(n)}
></button>
))}
</div>
);
}
// ── 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 <div key="delete" />;
return (
<div key={key} style={{ position: 'relative', padding: 0, margin: 0, minWidth: 0, overflow: 'hidden' }}>
<div style={{ display: 'flex', width: '100%', height: '100%', alignItems: 'center', justifyContent: isCentered ? 'center' : 'flex-start', paddingLeft: isCentered ? 0 : 12 }}>
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{label}</span>
<div
style={{
display: 'flex',
width: '100%',
height: '100%',
alignItems: 'center',
justifyContent: isCentered ? 'center' : 'flex-start',
paddingLeft: isCentered ? 0 : 12,
}}
>
<span style={{ whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{label}</span>
</div>
{!isLastCol && key !== 'delete' && (
<div className="col-resize-handle" onMouseDown={e => startResize(e, colIndex, 1)} />
@@ -917,7 +910,7 @@ export default function PlaylistDetail() {
if (key === 'title') return <div key="title" style={{ paddingLeft: 12 }}>{label}</div>;
if (key === 'delete') return <div key="delete" />;
if (key === 'favorite' || key === 'rating') return <div key={key} />;
return <div key={key} className={isCentered ? 'col-center' : ''}>{label}</div>;
return <div key={key} className={isCentered ? 'col-center' : ''} style={!isCentered ? { paddingLeft: 12 } : undefined}>{label}</div>;
})}
</div>