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 { version } from '../package.json';
import { useConnectionStatus } from './hooks/useConnectionStatus'; import { useConnectionStatus } from './hooks/useConnectionStatus';
import { useAuthStore } from './store/authStore'; import { useAuthStore } from './store/authStore';
import { getMusicFolders } from './api/subsonic'; import { getMusicFolders, probeEntityRatingSupport } from './api/subsonic';
import { useOfflineStore } from './store/offlineStore'; import { useOfflineStore } from './store/offlineStore';
import { initHotCachePrefetch } from './hotCachePrefetch'; import { initHotCachePrefetch } from './hotCachePrefetch';
import { usePlayerStore, initAudioListeners } from './store/playerStore'; import { usePlayerStore, initAudioListeners } from './store/playerStore';
@@ -101,6 +101,7 @@ function AppShell() {
const activeServerId = useAuthStore(s => s.activeServerId); const activeServerId = useAuthStore(s => s.activeServerId);
const setMusicFolders = useAuthStore(s => s.setMusicFolders); const setMusicFolders = useAuthStore(s => s.setMusicFolders);
const useCustomTitlebar = useAuthStore(s => s.useCustomTitlebar); const useCustomTitlebar = useAuthStore(s => s.useCustomTitlebar);
const setEntityRatingSupport = useAuthStore(s => s.setEntityRatingSupport);
const offlineAlbums = useOfflineStore(s => s.albums); const offlineAlbums = useOfflineStore(s => s.albums);
const hasOfflineContent = Object.values(offlineAlbums).some(a => a.serverId === serverId); const hasOfflineContent = Object.values(offlineAlbums).some(a => a.serverId === serverId);
@@ -112,19 +113,27 @@ function AppShell() {
useEffect(() => { useEffect(() => {
if (!isLoggedIn || !activeServerId) return; if (!isLoggedIn || !activeServerId) return;
const serverAtStart = activeServerId;
let cancelled = false; let cancelled = false;
(async () => { (async () => {
const stillThisServer = () => !cancelled && useAuthStore.getState().activeServerId === serverAtStart;
try { try {
const folders = await getMusicFolders(); const folders = await getMusicFolders();
if (!cancelled) setMusicFolders(folders); if (stillThisServer()) setMusicFolders(folders);
} catch { } 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 () => { return () => {
cancelled = true; cancelled = true;
}; };
}, [isLoggedIn, activeServerId, setMusicFolders]); }, [isLoggedIn, activeServerId, setMusicFolders, setEntityRatingSupport]);
// Reset scroll position on route change // Reset scroll position on route change
useEffect(() => { useEffect(() => {
+26
View File
@@ -64,6 +64,8 @@ export interface SubsonicAlbum {
starred?: string; starred?: string;
recordLabel?: string; recordLabel?: string;
created?: string; created?: string;
/** Present on some servers (e.g. OpenSubsonic) for album-level rating. */
userRating?: number;
} }
export interface SubsonicSong { export interface SubsonicSong {
@@ -141,6 +143,8 @@ export interface SubsonicArtist {
albumCount?: number; albumCount?: number;
coverArt?: string; coverArt?: string;
starred?: string; starred?: string;
/** Present on some servers (e.g. OpenSubsonic) for artist-level rating. */
userRating?: number;
} }
export interface SubsonicGenre { export interface SubsonicGenre {
@@ -374,6 +378,28 @@ export async function setRating(id: string, rating: number): Promise<void> {
await api('setRating.view', { id, rating }); 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> { export async function scrobbleSong(id: string, time: number): Promise<void> {
try { try {
await api('scrobble.view', { id, time, submission: true }); await api('scrobble.view', { id, time, submission: true });
+18
View File
@@ -6,6 +6,8 @@ import CachedImage from './CachedImage';
import CoverLightbox from './CoverLightbox'; import CoverLightbox from './CoverLightbox';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { useIsMobile } from '../hooks/useIsMobile'; import { useIsMobile } from '../hooks/useIsMobile';
import StarRating from './StarRating';
import type { EntityRatingSupportLevel } from '../api/subsonic';
function formatDuration(seconds: number): string { function formatDuration(seconds: number): string {
const h = Math.floor(seconds / 3600); const h = Math.floor(seconds / 3600);
@@ -85,6 +87,10 @@ interface AlbumHeaderProps {
onEnqueueAll: () => void; onEnqueueAll: () => void;
onBio: () => void; onBio: () => void;
onCloseBio: () => void; onCloseBio: () => void;
entityRatingValue: number;
onEntityRatingChange: (rating: number) => void;
/** `unknown` = probe pending or not run; from `entityRatingSupportByServer`. */
entityRatingSupport: EntityRatingSupportLevel | 'unknown';
} }
export default function AlbumHeader({ export default function AlbumHeader({
@@ -107,6 +113,9 @@ export default function AlbumHeader({
onEnqueueAll, onEnqueueAll,
onBio, onBio,
onCloseBio, onCloseBio,
entityRatingValue,
onEntityRatingChange,
entityRatingSupport,
}: AlbumHeaderProps) { }: AlbumHeaderProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const navigate = useNavigate(); const navigate = useNavigate();
@@ -186,6 +195,15 @@ export default function AlbumHeader({
</> </>
)} )}
</div> </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 ? ( {isMobile ? (
<div className="album-detail-actions-mobile"> <div className="album-detail-actions-mobile">
{/* Row 1 — Primary actions */} {/* Row 1 — Primary actions */}
+16 -30
View File
@@ -8,6 +8,7 @@ import { useNavigate } from 'react-router-dom';
import { useDragDrop } from '../contexts/DragDropContext'; import { useDragDrop } from '../contexts/DragDropContext';
import { AddToPlaylistSubmenu } from './ContextMenu'; import { AddToPlaylistSubmenu } from './ContextMenu';
import { useIsMobile } from '../hooks/useIsMobile'; import { useIsMobile } from '../hooks/useIsMobile';
import StarRating from './StarRating';
function formatDuration(seconds: number): string { function formatDuration(seconds: number): string {
const h = Math.floor(seconds / 3600); const h = Math.floor(seconds / 3600);
@@ -24,29 +25,6 @@ function codecLabel(song: { suffix?: string; bitRate?: number }): string {
return parts.join(' '); 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 ────────────────────────────────────────────────────── // ── Column configuration ──────────────────────────────────────────────────────
// 'num' → always 60 px fixed, no resize handle // 'num' → always 60 px fixed, no resize handle
// 'title' → minmax(150px, 1fr) via flex:true, absorbs window-resize changes // '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: 'artist', i18nKey: 'trackArtist', minWidth: 80, defaultWidth: 180, required: false },
{ key: 'favorite', i18nKey: 'trackFavorite', minWidth: 50, defaultWidth: 70, required: false }, { key: 'favorite', i18nKey: 'trackFavorite', minWidth: 50, defaultWidth: 70, required: false },
{ key: 'rating', i18nKey: 'trackRating', minWidth: 80, defaultWidth: 120, 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: 'format', i18nKey: 'trackFormat', minWidth: 60, defaultWidth: 90, required: false },
{ key: 'genre', i18nKey: 'trackGenre', 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'; 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']); const CENTERED_COLS = new Set<ColKey>(['favorite', 'rating', 'duration']);
// ── Props ───────────────────────────────────────────────────────────────────── // ── Props ─────────────────────────────────────────────────────────────────────
@@ -193,13 +171,21 @@ export default function AlbumTrackList({
); );
} }
// px-width columns: centred or left-aligned label + right-edge divider (except last col) // px-width columns: centred (compact controls) or left-aligned label + right-edge divider
// direction=1: drag right → this column grows, title (1fr) shrinks
const isResizable = !isLastCol; const isResizable = !isLastCol;
return ( return (
<div key={key} data-align={isCentered ? 'center' : 'start'} style={{ position: 'relative', padding: 0, margin: 0, minWidth: 0, overflow: 'hidden' }}> <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 }}> <div
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{label}</span> 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> </div>
{isResizable && ( {isResizable && (
<div className="col-resize-handle" onMouseDown={e => startResize(e, colIndex, 1)} /> <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', ratingLabel: 'Bewertung',
enlargeCover: 'Vergrößern', enlargeCover: 'Vergrößern',
}, },
entityRating: {
albumShort: 'Albumbewertung',
artistShort: 'Künstlerbewertung',
albumAriaLabel: 'Albumbewertung',
artistAriaLabel: 'Künstlerbewertung',
saveFailed: 'Bewertung konnte nicht gespeichert werden.',
},
artistDetail: { artistDetail: {
back: 'Zurück', back: 'Zurück',
albums: 'Alben', albums: 'Alben',
+7
View File
@@ -146,6 +146,13 @@ export const enTranslation = {
ratingLabel: 'Rating', ratingLabel: 'Rating',
enlargeCover: 'Enlarge', enlargeCover: 'Enlarge',
}, },
entityRating: {
albumShort: 'Album rating',
artistShort: 'Artist rating',
albumAriaLabel: 'Album rating',
artistAriaLabel: 'Artist rating',
saveFailed: 'Could not save rating.',
},
artistDetail: { artistDetail: {
back: 'Back', back: 'Back',
albums: 'Albums', albums: 'Albums',
+7
View File
@@ -145,6 +145,13 @@ export const frTranslation = {
ratingLabel: 'Note', ratingLabel: 'Note',
enlargeCover: 'Agrandir', 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: { artistDetail: {
back: 'Retour', back: 'Retour',
albums: 'Albums', albums: 'Albums',
+7
View File
@@ -145,6 +145,13 @@ export const nbTranslation = {
ratingLabel: 'Vurdering', ratingLabel: 'Vurdering',
enlargeCover: 'Forstørr', enlargeCover: 'Forstørr',
}, },
entityRating: {
albumShort: 'Albumvurdering',
artistShort: 'Artistvurdering',
albumAriaLabel: 'Albumvurdering',
artistAriaLabel: 'Artistvurdering',
saveFailed: 'Kunne ikke lagre vurderingen.',
},
artistDetail: { artistDetail: {
back: 'Tilbake', back: 'Tilbake',
albums: 'Album', albums: 'Album',
+7
View File
@@ -145,6 +145,13 @@ export const nlTranslation = {
ratingLabel: 'Beoordeling', ratingLabel: 'Beoordeling',
enlargeCover: 'Vergroten', enlargeCover: 'Vergroten',
}, },
entityRating: {
albumShort: 'Albumbeoordeling',
artistShort: 'Artiestbeoordeling',
albumAriaLabel: 'Albumbeoordeling',
artistAriaLabel: 'Artiestbeoordeling',
saveFailed: 'Beoordeling opslaan mislukt.',
},
artistDetail: { artistDetail: {
back: 'Terug', back: 'Terug',
albums: 'Albums', albums: 'Albums',
+7
View File
@@ -147,6 +147,13 @@ export const ruTranslation = {
ratingLabel: 'Оценка', ratingLabel: 'Оценка',
enlargeCover: 'Увеличить обложку', enlargeCover: 'Увеличить обложку',
}, },
entityRating: {
albumShort: 'Оценка альбома',
artistShort: 'Оценка исполнителя',
albumAriaLabel: 'Оценка альбома',
artistAriaLabel: 'Оценка исполнителя',
saveFailed: 'Не удалось сохранить оценку.',
},
artistDetail: { artistDetail: {
back: 'Назад', back: 'Назад',
albums: 'Альбомы', albums: 'Альбомы',
+7
View File
@@ -145,6 +145,13 @@ export const zhTranslation = {
ratingLabel: '评分', ratingLabel: '评分',
enlargeCover: '放大', enlargeCover: '放大',
}, },
entityRating: {
albumShort: '专辑评分',
artistShort: '艺人评分',
albumAriaLabel: '专辑评分',
artistAriaLabel: '艺人评分',
saveFailed: '无法保存评分。',
},
artistDetail: { artistDetail: {
back: '返回', back: '返回',
albums: '专辑', albums: '专辑',
+41
View File
@@ -13,6 +13,7 @@ import AlbumHeader from '../components/AlbumHeader';
import AlbumTrackList from '../components/AlbumTrackList'; import AlbumTrackList from '../components/AlbumTrackList';
import { useCachedUrl } from '../components/CachedImage'; import { useCachedUrl } from '../components/CachedImage';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { showToast } from '../utils/toast';
function sanitizeFilename(name: string): string { function sanitizeFilename(name: string): string {
return name return name
@@ -52,6 +53,11 @@ export default function AlbumDetail() {
const offlineAlbums = useOfflineStore(s => s.albums); const offlineAlbums = useOfflineStore(s => s.albums);
const offlineJobs = useOfflineStore(s => s.jobs); const offlineJobs = useOfflineStore(s => s.jobs);
const serverId = auth.activeServerId ?? ''; 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' = (() => { const offlineStatus: 'none' | 'downloading' | 'cached' = (() => {
if (!album) return 'none'; if (!album) return 'none';
@@ -90,6 +96,11 @@ export default function AlbumDetail() {
}).catch(() => setLoading(false)); }).catch(() => setLoading(false));
}, [id]); }, [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 = () => { const handlePlayAll = () => {
if (!album) return; if (!album) return;
const albumGenre = album.album.genre; const albumGenre = album.album.genre;
@@ -129,6 +140,33 @@ const handleEnqueueAll = () => {
await setRating(songId, rating); 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 () => { const handleBio = async () => {
if (!album) return; if (!album) return;
if (bio) { setBioOpen(true); return; } if (bio) { setBioOpen(true); return; }
@@ -270,6 +308,9 @@ const handleEnqueueAll = () => {
offlineProgress={offlineProgress} offlineProgress={offlineProgress}
onCacheOffline={handleCacheOffline} onCacheOffline={handleCacheOffline}
onRemoveOffline={handleRemoveOffline} onRemoveOffline={handleRemoveOffline}
entityRatingValue={albumEntityRating}
onEntityRatingChange={handleAlbumEntityRating}
entityRatingSupport={albumEntityRatingSupport}
/> />
{offlineStorageFull && ( {offlineStorageFull && (
<div className="offline-storage-full-banner" role="alert"> <div className="offline-storage-full-banner" role="alert">
+45 -1
View File
@@ -1,6 +1,6 @@
import { useEffect, useState, useRef } from 'react'; import { useEffect, useState, useRef } from 'react';
import { useParams, useNavigate } from 'react-router-dom'; 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 AlbumCard from '../components/AlbumCard';
import CachedImage from '../components/CachedImage'; import CachedImage from '../components/CachedImage';
import CoverLightbox from '../components/CoverLightbox'; import CoverLightbox from '../components/CoverLightbox';
@@ -14,6 +14,7 @@ import { lastfmGetSimilarArtists, lastfmIsConfigured } from '../api/lastfm';
import LastfmIcon from '../components/LastfmIcon'; import LastfmIcon from '../components/LastfmIcon';
import { invalidateCoverArt } from '../utils/imageCache'; import { invalidateCoverArt } from '../utils/imageCache';
import { showToast } from '../utils/toast'; import { showToast } from '../utils/toast';
import StarRating from '../components/StarRating';
function formatDuration(seconds: number): string { function formatDuration(seconds: number): string {
const m = Math.floor(seconds / 60); const m = Math.floor(seconds / 60);
@@ -71,6 +72,11 @@ export default function ArtistDetail() {
const { downloadArtist, bulkProgress } = useOfflineStore(); const { downloadArtist, bulkProgress } = useOfflineStore();
const activeServerId = useAuthStore(s => s.activeServerId) ?? ''; const activeServerId = useAuthStore(s => s.activeServerId) ?? '';
const musicLibraryFilterVersion = useAuthStore(s => s.musicLibraryFilterVersion); 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(() => { useEffect(() => {
if (!id) return; if (!id) return;
@@ -94,6 +100,34 @@ export default function ArtistDetail() {
}); });
}, [id]); }, [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 // "Also Featured On" — loaded in background after main content renders
useEffect(() => { useEffect(() => {
if (!id || !artist) return; if (!id || !artist) return;
@@ -351,6 +385,16 @@ export default function ArtistDetail() {
{t('artistDetail.albumCount_other', { count: artist.albumCount ?? 0 })} {t('artistDetail.albumCount_other', { count: artist.albumCount ?? 0 })}
</div> </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' }}> <div style={{ display: 'flex', gap: '8px', flexWrap: 'wrap' }}>
{(info?.lastFmUrl || artist.name) && ( {(info?.lastFmUrl || artist.name) && (
<div className="artist-detail-links"> <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: 'num', i18nKey: null, minWidth: 60, defaultWidth: 60, required: true },
{ key: 'title', i18nKey: 'trackTitle', minWidth: 150, defaultWidth: 0, required: true, flex: true }, { key: 'title', i18nKey: 'trackTitle', minWidth: 150, defaultWidth: 0, required: true, flex: true },
{ key: 'artist', i18nKey: 'trackArtist', minWidth: 80, defaultWidth: 180, required: false }, { 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 }, { key: 'remove', i18nKey: null, minWidth: 36, defaultWidth: 36, required: true },
]; ];
@@ -182,8 +182,17 @@ export default function Favorites() {
const isCentered = key === 'duration'; const isCentered = key === 'duration';
return ( return (
<div key={key} style={{ position: 'relative', padding: 0, margin: 0, minWidth: 0, overflow: 'hidden' }}> <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 }}> <div
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{label}</span> 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> </div>
{!isLastCol && <div className="col-resize-handle" onMouseDown={e => startResize(e, colIndex, 1)} />} {!isLastCol && <div className="col-resize-handle" onMouseDown={e => startResize(e, colIndex, 1)} />}
</div> </div>
+14 -21
View File
@@ -21,6 +21,7 @@ import CachedImage, { useCachedUrl } from '../components/CachedImage';
import { coverArtCacheKey, buildCoverArtUrl } from '../api/subsonic'; import { coverArtCacheKey, buildCoverArtUrl } from '../api/subsonic';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { showToast } from '../utils/toast'; import { showToast } from '../utils/toast';
import StarRating from '../components/StarRating';
function sanitizeFilename(name: string): string { function sanitizeFilename(name: string): string {
return name return name
@@ -50,23 +51,6 @@ function codecLabel(song: SubsonicSong): string {
return parts.join(' · '); 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 ────────────────────────────────────────────────────── // ── Column configuration ──────────────────────────────────────────────────────
const PL_COLUMNS: readonly ColDef[] = [ const PL_COLUMNS: readonly ColDef[] = [
{ key: 'num', i18nKey: null, minWidth: 60, defaultWidth: 60, required: true }, { 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: 'artist', i18nKey: 'trackArtist', minWidth: 80, defaultWidth: 180, required: false },
{ key: 'favorite', i18nKey: 'trackFavorite', minWidth: 50, defaultWidth: 70, required: false }, { key: 'favorite', i18nKey: 'trackFavorite', minWidth: 50, defaultWidth: 70, required: false },
{ key: 'rating', i18nKey: 'trackRating', minWidth: 80, defaultWidth: 120, 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: 'format', i18nKey: 'trackFormat', minWidth: 60, defaultWidth: 90, required: false },
{ key: 'delete', i18nKey: null, minWidth: 36, defaultWidth: 36, required: true }, { 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" />; if (key === 'delete') return <div key="delete" />;
return ( return (
<div key={key} style={{ position: 'relative', padding: 0, margin: 0, minWidth: 0, overflow: 'hidden' }}> <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 }}> <div
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{label}</span> 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> </div>
{!isLastCol && key !== 'delete' && ( {!isLastCol && key !== 'delete' && (
<div className="col-resize-handle" onMouseDown={e => startResize(e, colIndex, 1)} /> <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 === 'title') return <div key="title" style={{ paddingLeft: 12 }}>{label}</div>;
if (key === 'delete') return <div key="delete" />; if (key === 'delete') return <div key="delete" />;
if (key === 'favorite' || key === 'rating') return <div key={key} />; 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> </div>
+16
View File
@@ -1,6 +1,7 @@
import { create } from 'zustand'; import { create } from 'zustand';
import { persist, createJSONStorage } from 'zustand/middleware'; import { persist, createJSONStorage } from 'zustand/middleware';
import { invoke } from '@tauri-apps/api/core'; import { invoke } from '@tauri-apps/api/core';
import type { EntityRatingSupportLevel } from '../api/subsonic';
import { usePlayerStore } from './playerStore'; import { usePlayerStore } from './playerStore';
export interface ServerProfile { export interface ServerProfile {
@@ -69,6 +70,13 @@ interface AuthState {
/** Bumps when `setMusicLibraryFilter` runs so pages refetch catalog data. */ /** Bumps when `setMusicLibraryFilter` runs so pages refetch catalog data. */
musicLibraryFilterVersion: number; 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 // Status
isLoggedIn: boolean; isLoggedIn: boolean;
isConnecting: boolean; isConnecting: boolean;
@@ -172,6 +180,7 @@ export const useAuthStore = create<AuthState>()(
musicFolders: [], musicFolders: [],
musicLibraryFilterByServer: {}, musicLibraryFilterByServer: {},
musicLibraryFilterVersion: 0, musicLibraryFilterVersion: 0,
entityRatingSupportByServer: {},
isLoggedIn: false, isLoggedIn: false,
isConnecting: false, isConnecting: false,
connectionError: null, connectionError: null,
@@ -193,10 +202,12 @@ export const useAuthStore = create<AuthState>()(
set(s => { set(s => {
const newServers = s.servers.filter(srv => srv.id !== id); const newServers = s.servers.filter(srv => srv.id !== id);
const switchedAway = s.activeServerId === id; const switchedAway = s.activeServerId === id;
const { [id]: _r, ...entityRatingRest } = s.entityRatingSupportByServer;
return { return {
servers: newServers, servers: newServers,
activeServerId: switchedAway ? (newServers[0]?.id ?? null) : s.activeServerId, activeServerId: switchedAway ? (newServers[0]?.id ?? null) : s.activeServerId,
isLoggedIn: switchedAway ? false : s.isLoggedIn, 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: [] }), logout: () => set({ isLoggedIn: false, musicFolders: [] }),
getBaseUrl: () => { getBaseUrl: () => {
+44 -1
View File
@@ -997,7 +997,50 @@
gap: var(--space-2); gap: var(--space-2);
color: var(--text-muted); color: var(--text-muted);
font-size: 13px; 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 { .album-detail-actions {
+68
View File
@@ -3800,6 +3800,74 @@ select.input.input:focus {
cursor: pointer; 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 ─── */ /* ─── Animations ─── */
@keyframes fadeIn { @keyframes fadeIn {
from { from {
+6 -1
View File
@@ -24,8 +24,13 @@ function loadPrefs(
const parsed = JSON.parse(raw) as { widths?: Record<string, number>; visible?: string[] }; const parsed = JSON.parse(raw) as { widths?: Record<string, number>; visible?: string[] };
const visible = new Set<string>(parsed.visible ?? [...defaultVisible]); const visible = new Set<string>(parsed.visible ?? [...defaultVisible]);
columns.filter(c => c.required).forEach(c => visible.add(c.key)); 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 { return {
widths: { ...defaultWidths, ...(parsed.widths ?? {}) }, widths,
visible, visible,
}; };
} catch { } catch {