mirror of
https://github.com/kilyabin/psysonic.git
synced 2026-07-21 22:15:40 +00:00
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:
+13
-4
@@ -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(() => {
|
||||||
|
|||||||
@@ -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 });
|
||||||
|
|||||||
@@ -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 */}
|
||||||
|
|||||||
@@ -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)} />
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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',
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -145,6 +145,13 @@ export const frTranslation = {
|
|||||||
ratingLabel: 'Note',
|
ratingLabel: 'Note',
|
||||||
enlargeCover: 'Agrandir',
|
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: {
|
artistDetail: {
|
||||||
back: 'Retour',
|
back: 'Retour',
|
||||||
albums: 'Albums',
|
albums: 'Albums',
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -147,6 +147,13 @@ export const ruTranslation = {
|
|||||||
ratingLabel: 'Оценка',
|
ratingLabel: 'Оценка',
|
||||||
enlargeCover: 'Увеличить обложку',
|
enlargeCover: 'Увеличить обложку',
|
||||||
},
|
},
|
||||||
|
entityRating: {
|
||||||
|
albumShort: 'Оценка альбома',
|
||||||
|
artistShort: 'Оценка исполнителя',
|
||||||
|
albumAriaLabel: 'Оценка альбома',
|
||||||
|
artistAriaLabel: 'Оценка исполнителя',
|
||||||
|
saveFailed: 'Не удалось сохранить оценку.',
|
||||||
|
},
|
||||||
artistDetail: {
|
artistDetail: {
|
||||||
back: 'Назад',
|
back: 'Назад',
|
||||||
albums: 'Альбомы',
|
albums: 'Альбомы',
|
||||||
|
|||||||
@@ -145,6 +145,13 @@ export const zhTranslation = {
|
|||||||
ratingLabel: '评分',
|
ratingLabel: '评分',
|
||||||
enlargeCover: '放大',
|
enlargeCover: '放大',
|
||||||
},
|
},
|
||||||
|
entityRating: {
|
||||||
|
albumShort: '专辑评分',
|
||||||
|
artistShort: '艺人评分',
|
||||||
|
albumAriaLabel: '专辑评分',
|
||||||
|
artistAriaLabel: '艺人评分',
|
||||||
|
saveFailed: '无法保存评分。',
|
||||||
|
},
|
||||||
artistDetail: {
|
artistDetail: {
|
||||||
back: '返回',
|
back: '返回',
|
||||||
albums: '专辑',
|
albums: '专辑',
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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: () => {
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user