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 {