From 705c80ef07ba59ef36268cb5cd31b3f27a90b8cb Mon Sep 17 00:00:00 2001 From: Maxim Isaev Date: Wed, 8 Apr 2026 01:08:41 +0300 Subject: [PATCH] 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 {