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
+13 -4
View File
@@ -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(() => {
+26
View File
@@ -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<void> {
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<EntityRatingSupportLevel> {
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<void> {
try {
await api('scrobble.view', { id, time, submission: true });
+18
View File
@@ -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({
</>
)}
</div>
<div className="album-detail-entity-rating">
<span className="album-detail-entity-rating-label">{t('entityRating.albumShort')}</span>
<StarRating
value={entityRatingValue}
onChange={onEntityRatingChange}
disabled={entityRatingSupport === 'track_only'}
labelKey="entityRating.albumAriaLabel"
/>
</div>
{isMobile ? (
<div className="album-detail-actions-mobile">
{/* Row 1 — Primary actions */}
+16 -30
View File
@@ -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 (
<div className="star-rating" role="radiogroup" aria-label={t('albumDetail.ratingLabel')}>
{[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)}
aria-label={`${n}`}
role="radio"
aria-checked={(hover || value) >= n}
>
</button>
))}
</div>
);
}
// ── 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<ColKey>(['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 (
<div key={key} data-align={isCentered ? 'center' : 'start'} 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 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={{ whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{label}</span>
</div>
{isResizable && (
<div className="col-resize-handle" onMouseDown={e => startResize(e, colIndex, 1)} />
+94
View File
@@ -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<number | null>(null);
const [clearShrinkStar, setClearShrinkStar] = React.useState<number | null>(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 (
<div
className={`star-rating${disabled ? ' star-rating--disabled' : ''}${suppressHoverPreview ? ' star-rating--suppress-hover' : ''} ${className}`.trim()}
role="radiogroup"
aria-label={t(labelKey)}
aria-disabled={disabled}
onMouseLeave={disabled ? undefined : handleContainerLeave}
>
{[1, 2, 3, 4, 5].map(n => (
<button
key={n}
type="button"
className={`star ${filled(n) ? 'filled' : ''}${pulseStar === n ? ' star--pulse' : ''}${clearShrinkStar === n ? ' star--clear-shrink' : ''}`}
onMouseEnter={() => !disabled && !suppressHoverPreview && setHover(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}
aria-label={`${n}`}
role="radio"
aria-checked={filled(n)}
>
</button>
))}
</div>
);
}
+7
View File
@@ -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',
+7
View File
@@ -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',
+7
View File
@@ -145,6 +145,13 @@ export const frTranslation = {
ratingLabel: 'Note',
enlargeCover: 'Agrandir',
},
entityRating: {
albumShort: 'Note de lalbum',
artistShort: 'Note de lartiste',
albumAriaLabel: 'Note de lalbum',
artistAriaLabel: 'Note de lartiste',
saveFailed: 'Impossible denregistrer la note.',
},
artistDetail: {
back: 'Retour',
albums: 'Albums',
+7
View File
@@ -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',
+7
View File
@@ -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',
+7
View File
@@ -147,6 +147,13 @@ export const ruTranslation = {
ratingLabel: 'Оценка',
enlargeCover: 'Увеличить обложку',
},
entityRating: {
albumShort: 'Оценка альбома',
artistShort: 'Оценка исполнителя',
albumAriaLabel: 'Оценка альбома',
artistAriaLabel: 'Оценка исполнителя',
saveFailed: 'Не удалось сохранить оценку.',
},
artistDetail: {
back: 'Назад',
albums: 'Альбомы',
+7
View File
@@ -145,6 +145,13 @@ export const zhTranslation = {
ratingLabel: '评分',
enlargeCover: '放大',
},
entityRating: {
albumShort: '专辑评分',
artistShort: '艺人评分',
albumAriaLabel: '专辑评分',
artistAriaLabel: '艺人评分',
saveFailed: '无法保存评分。',
},
artistDetail: {
back: '返回',
albums: '专辑',
+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>
+16
View File
@@ -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<string, EntityRatingSupportLevel>;
setEntityRatingSupport: (serverId: string, level: EntityRatingSupportLevel) => void;
// Status
isLoggedIn: boolean;
isConnecting: boolean;
@@ -172,6 +180,7 @@ export const useAuthStore = create<AuthState>()(
musicFolders: [],
musicLibraryFilterByServer: {},
musicLibraryFilterVersion: 0,
entityRatingSupportByServer: {},
isLoggedIn: false,
isConnecting: false,
connectionError: null,
@@ -193,10 +202,12 @@ export const useAuthStore = create<AuthState>()(
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<AuthState>()(
}));
},
setEntityRatingSupport: (serverId, level) =>
set(s => ({
entityRatingSupportByServer: { ...s.entityRatingSupportByServer, [serverId]: level },
})),
logout: () => set({ isLoggedIn: false, musicFolders: [] }),
getBaseUrl: () => {
+44 -1
View File
@@ -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 {
+68
View File
@@ -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 {
+6 -1
View File
@@ -24,8 +24,13 @@ function loadPrefs(
const parsed = JSON.parse(raw) as { widths?: Record<string, number>; visible?: string[] };
const visible = new Set<string>(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 {