feat(ratings): skip-to-1★ threshold and mix min-rating filter

Add Ratings section under General: manual skip count before setting 1★,
and per-axis minimum stars for random mixes/albums (song, album, artist).
StarRating shows five stars with selection capped at three; shared max in authStore.
Queue filtering via passesMixMinRatings; OpenSubsonic-style entity rating fields on types.
This commit is contained in:
Maxim Isaev
2026-04-08 02:39:25 +03:00
parent 705c80ef07
commit 8a1d942128
15 changed files with 418 additions and 37 deletions
+50 -29
View File
@@ -5,34 +5,50 @@ export default function StarRating({
value,
onChange,
disabled = false,
maxStars = 5,
maxSelectable: maxSelectableProp,
labelKey = 'albumDetail.ratingLabel',
ariaLabel,
className = '',
}: {
value: number;
onChange: (rating: number) => void;
disabled?: boolean;
/** Number of star buttons (1…maxStars). Default 5. */
maxStars?: number;
/** Highest selectable star (inclusive); higher stars are shown but disabled. */
maxSelectable?: number;
labelKey?: string;
/** Overrides `t(labelKey)` for the radiogroup `aria-label` when set. */
ariaLabel?: string;
className?: string;
}) {
const { t } = useTranslation();
const stars = React.useMemo(
() => Array.from({ length: Math.max(1, Math.min(5, maxStars)) }, (_, i) => i + 1),
[maxStars]
);
const selectCap = Math.min(maxSelectableProp ?? stars.length, stars.length);
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);
const cappedValue = Math.min(Math.max(0, value), selectCap);
React.useEffect(() => {
if (value > 0) setSuppressHoverPreview(false);
}, [value]);
const effectiveHover = suppressHoverPreview ? 0 : hover;
const filled = (n: number) => (effectiveHover || value) >= n;
const effectiveHover = suppressHoverPreview ? 0 : Math.min(hover, selectCap);
const filled = (n: number) => (effectiveHover || cappedValue) >= n;
const handleStarClick = (n: number) => {
if (disabled) return;
if (disabled || n > selectCap) return;
setSuppressHoverPreview(false);
const next = value === n ? 0 : n;
const next = cappedValue === n ? 0 : n;
onChange(next);
setHover(0);
@@ -60,35 +76,40 @@ export default function StarRating({
<div
className={`star-rating${disabled ? ' star-rating--disabled' : ''}${suppressHoverPreview ? ' star-rating--suppress-hover' : ''} ${className}`.trim()}
role="radiogroup"
aria-label={t(labelKey)}
aria-label={ariaLabel ?? 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));
{stars.map(n => {
const locked = n > selectCap;
return (
<button
key={n}
type="button"
className={`star ${filled(n) ? 'filled' : ''}${pulseStar === n ? ' star--pulse' : ''}${clearShrinkStar === n ? ' star--clear-shrink' : ''}${locked ? ' star--locked' : ''}`}
onMouseEnter={() =>
!disabled && !suppressHoverPreview && !locked && setHover(n)
}
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>
))}
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 || locked}
aria-label={`${n}`}
role="radio"
aria-checked={filled(n)}
>
</button>
);
})}
</div>
);
}