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
+3
View File
@@ -81,6 +81,9 @@ export interface SubsonicSong {
coverArt?: string;
year?: number;
userRating?: number;
/** Some OpenSubsonic responses attach parent ratings on child songs. */
albumUserRating?: number;
artistUserRating?: number;
// Audio technical info
bitRate?: number;
suffix?: string;
+31 -10
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,16 +76,20 @@ 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 => (
{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' : ''}`}
onMouseEnter={() => !disabled && !suppressHoverPreview && setHover(n)}
className={`star ${filled(n) ? 'filled' : ''}${pulseStar === n ? ' star--pulse' : ''}${clearShrinkStar === n ? ' star--clear-shrink' : ''}${locked ? ' star--locked' : ''}`}
onMouseEnter={() =>
!disabled && !suppressHoverPreview && !locked && setHover(n)
}
onClick={() => handleStarClick(n)}
onAnimationEnd={e => {
if (e.currentTarget !== e.target) return;
@@ -81,14 +101,15 @@ export default function StarRating({
setClearShrinkStar(s => (s === n ? null : s));
}
}}
disabled={disabled}
disabled={disabled || locked}
aria-label={`${n}`}
role="radio"
aria-checked={filled(n)}
>
</button>
))}
);
})}
</div>
);
}
+14
View File
@@ -508,6 +508,20 @@ export const deTranslation = {
shortcutNativeFullscreen: 'Nativer Vollbildmodus',
tabSystem: 'System',
tabGeneral: 'Allgemein',
ratingsSectionTitle: 'Bewertungen',
ratingsSkipStarTitle: 'Überspringen für 1 Stern',
ratingsSkipStarDesc:
'Nach N manuellen Überspringen desselben Titels: Server-Bewertung 1 Stern, sofern noch unbewertet.',
ratingsSkipStarThresholdLabel: 'Überspringer bis 1★',
ratingsSkipStarThresholdHint:
'Nur wenn Sie den Titel mit Weiter / Medientaste verlassen — nicht wenn er von selbst endet. Keine Änderung, wenn der Titel schon mindestens 1 Stern hat.',
ratingsMixFilterTitle: 'Filter nach Bewertung',
ratingsMixFilterDesc:
'Inhalte mit niedriger Bewertung in Zufallsmixen und zufälligen Alben filtern. Gleichen Stern erneut klicken, um die Schwelle auszuschalten.',
ratingsMixMinSong: 'Titel (Titelbewertung)',
ratingsMixMinAlbum: 'Alben',
ratingsMixMinArtist: 'Interpreten',
ratingsMixMinThresholdAria: 'Mindest-Sterne ({{label}})',
backupTitle: 'Backup & Wiederherstellung',
backupExport: 'Einstellungen exportieren',
backupExportDesc: 'Speichert alle Einstellungen, Serverprofile, Last.fm-Konfiguration, Theme, EQ und Tastenkürzel in eine .psybkp-Datei. Passwörter werden im Klartext gespeichert — Datei sicher aufbewahren.',
+13
View File
@@ -493,6 +493,19 @@ export const enTranslation = {
tabServer: 'Server',
tabSystem: 'System',
tabGeneral: 'General',
ratingsSectionTitle: 'Ratings',
ratingsSkipStarTitle: 'Skip for 1 star',
ratingsSkipStarDesc:
'After N manual skips of the same track, set server rating to 1 star if the track is still unrated.',
ratingsSkipStarThresholdLabel: 'Skips before 1★',
ratingsSkipStarThresholdHint: 'Only counts when you leave the track with Next / media “next”, not when it ends on its own. Does nothing if the track is already rated 1 star or higher.',
ratingsMixFilterTitle: 'Filter by rating',
ratingsMixFilterDesc:
'Filter low-rated items in random mixes and random albums. Click the same star again to turn off that columns threshold.',
ratingsMixMinSong: 'Songs (track rating)',
ratingsMixMinAlbum: 'Albums',
ratingsMixMinArtist: 'Artists',
ratingsMixMinThresholdAria: 'Minimum stars ({{label}})',
backupTitle: 'Backup & Restore',
backupExport: 'Export settings',
backupExportDesc: 'Saves all settings, server profiles, Last.fm config, theme, EQ and keybindings to a .psybkp file. Passwords are stored in plaintext — keep the file secure.',
+14
View File
@@ -506,6 +506,20 @@ export const frTranslation = {
shortcutNativeFullscreen: 'Plein écran natif',
tabSystem: 'Système',
tabGeneral: 'Général',
ratingsSectionTitle: 'Notes',
ratingsSkipStarTitle: 'Passer pour 1 étoile',
ratingsSkipStarDesc:
'Après N sauts manuels du même morceau : note serveur 1 étoile si encore sans note.',
ratingsSkipStarThresholdLabel: 'Sauts avant 1★',
ratingsSkipStarThresholdHint:
'Uniquement si vous quittez le morceau avec Suivant / touche média « suivant », pas en fin de lecture. Aucun effet si le morceau a déjà au moins 1 étoile.',
ratingsMixFilterTitle: 'Filtrage par note',
ratingsMixFilterDesc:
'Filtrer le contenu peu noté dans les mix aléatoires et les albums aléatoires. Cliquer de nouveau sur la même étoile désactive le seuil.',
ratingsMixMinSong: 'Morceaux (note du titre)',
ratingsMixMinAlbum: 'Albums',
ratingsMixMinArtist: 'Artistes',
ratingsMixMinThresholdAria: 'Étoiles minimum ({{label}})',
backupTitle: 'Sauvegarde & Restauration',
backupExport: 'Exporter les paramètres',
backupExportDesc: 'Enregistre tous les paramètres, profils serveur, configuration Last.fm, thème, EQ et raccourcis dans un fichier .psybkp. Les mots de passe sont stockés en clair — conservez le fichier en sécurité.',
+14
View File
@@ -489,6 +489,20 @@ export const nbTranslation = {
tabServer: 'Tjener',
tabSystem: 'System',
tabGeneral: 'Generelt',
ratingsSectionTitle: 'Vurderinger',
ratingsSkipStarTitle: 'Hopp for 1 stjerne',
ratingsSkipStarDesc:
'Etter N manuelle hopp på samme spor: servervurdering 1 stjerne hvis fortsatt uvurdert.',
ratingsSkipStarThresholdLabel: 'Hopp før 1★',
ratingsSkipStarThresholdHint:
'Bare når du forlater sporet med Neste / medietast «neste», ikke når det slutter av seg selv. Ingenting skjer hvis sporet allerede har minst 1 stjerne.',
ratingsMixFilterTitle: 'Filtrering etter vurdering',
ratingsMixFilterDesc:
'Filtrer innhold med lav vurdering i tilfeldige mikser og tilfeldige album. Klikk samme stjerne igjen for å slå av terskelen.',
ratingsMixMinSong: 'Spor (sporvurdering)',
ratingsMixMinAlbum: 'Album',
ratingsMixMinArtist: 'Artister',
ratingsMixMinThresholdAria: 'Minimum stjerner ({{label}})',
backupTitle: 'Sikkerhetskopiering og gjenoppretting',
backupExport: 'Eksporter innstillinger',
backupExportDesc: 'Lagrer alle innstillinger, tjenerprofiler, Last.fm-konfigurasjon, tema, jevnstiller og tastebindinger til en .psybkp-fil. Passordet lagres i klartekst hold filen sikker.',
+14
View File
@@ -506,6 +506,20 @@ export const nlTranslation = {
shortcutNativeFullscreen: 'Systeemvolledig scherm',
tabSystem: 'Systeem',
tabGeneral: 'Algemeen',
ratingsSectionTitle: 'Beoordelingen',
ratingsSkipStarTitle: 'Overslaan voor 1 ster',
ratingsSkipStarDesc:
'Na N handmatige overslagen van hetzelfde nummer: serverbeoordeling 1 ster als nog niet beoordeeld.',
ratingsSkipStarThresholdLabel: 'Overslagen voor 1★',
ratingsSkipStarThresholdHint:
'Alleen als je het nummer verlaat met Volgende / mediatoets «volgende», niet als het vanzelf stopt. Geen wijziging als het nummer al minstens 1 ster heeft.',
ratingsMixFilterTitle: 'Filter op beoordeling',
ratingsMixFilterDesc:
'Content met lage beoordeling filteren in willekeurige mixen en willekeurige albums. Klik dezelfde ster opnieuw om de drempel uit te zetten.',
ratingsMixMinSong: 'Nummers (nummerbeoordeling)',
ratingsMixMinAlbum: 'Albums',
ratingsMixMinArtist: 'Artiesten',
ratingsMixMinThresholdAria: 'Minimum sterren ({{label}})',
backupTitle: 'Back-up & Herstel',
backupExport: 'Instellingen exporteren',
backupExportDesc: 'Slaat alle instellingen, serverprofielen, Last.fm-configuratie, thema, EQ en sneltoetsen op in een .psybkp-bestand. Wachtwoorden worden opgeslagen als leesbare tekst — bewaar het bestand veilig.',
+14
View File
@@ -511,6 +511,20 @@ export const ruTranslation = {
tabServer: 'Сервер',
tabSystem: 'Система',
tabGeneral: 'Общие',
ratingsSectionTitle: 'Рейтинги',
ratingsSkipStarTitle: 'Скипнуть для 1 звезды',
ratingsSkipStarDesc:
'N ручных скипов трека — 1 звезда рейтинга, если трек ещё без оценки.',
ratingsSkipStarThresholdLabel: 'Скипов',
ratingsSkipStarThresholdHint:
'Считаются только переходы «Далее» / медиаклавиша «следующий», не окончание трека само по себе. Не меняет оценку, если у трека уже не ниже 1★.',
ratingsMixFilterTitle: 'Фильтрация по рейтингу',
ratingsMixFilterDesc:
'Фильтровать с низким рейтингом в Случайных миксах и Случайных альбомах. Повторный клик по выбранной звезде отключает порог.',
ratingsMixMinSong: 'Песни',
ratingsMixMinAlbum: 'Альбомы',
ratingsMixMinArtist: 'Исполнители',
ratingsMixMinThresholdAria: 'Минимум звёзд: {{label}}',
backupTitle: 'Резервная копия',
backupExport: 'Экспорт настроек',
backupExportDesc:
+14
View File
@@ -486,6 +486,20 @@ export const zhTranslation = {
tabServer: '服务器',
tabSystem: '系统',
tabGeneral: '通用',
ratingsSectionTitle: '评分',
ratingsSkipStarTitle: '跳过以评 1 星',
ratingsSkipStarDesc:
'同一曲目手动跳过 N 次后,若尚未评分则在服务器上设为 1 星。',
ratingsSkipStarThresholdLabel: '跳过次数(至 1★)',
ratingsSkipStarThresholdHint:
'仅统计用“下一曲”或媒体键离开该曲的情况;自然播放结束不计入。曲目已有 1 星或以上时不改变。',
ratingsMixFilterTitle: '按评分筛选',
ratingsMixFilterDesc:
'在随机混合与随机专辑中筛选低评分内容。再次点击同一颗星可关闭该列阈值。',
ratingsMixMinSong: '歌曲(曲目评分)',
ratingsMixMinAlbum: '专辑',
ratingsMixMinArtist: '艺人',
ratingsMixMinThresholdAria: '最低星数({{label}}',
backupTitle: '备份与恢复',
backupExport: '导出设置',
backupExportDesc: '将所有设置、服务器配置、Last.fm 配置、主题、均衡器和快捷键保存到 .psybkp 文件。密码以明文存储——请妥善保管该文件。',
+41 -5
View File
@@ -1,10 +1,11 @@
import React, { useEffect, useState } from 'react';
import React, { useEffect, useMemo, useState } from 'react';
import { getRandomSongs, getGenres, SubsonicSong, SubsonicGenre, star, unstar } from '../api/subsonic';
import { usePlayerStore, songToTrack } from '../store/playerStore';
import { useAuthStore } from '../store/authStore';
import { Play, RefreshCw, ChevronDown, ChevronUp, Heart } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { useDragDrop } from '../contexts/DragDropContext';
import { passesMixMinRatings } from '../utils/mixRatingFilter';
const AUDIOBOOK_GENRES = [
'hörbuch', 'hoerbuch', 'hörspiel', 'hoerspiel',
@@ -35,7 +36,26 @@ export default function RandomMix() {
const [contextMenuSongId, setContextMenuSongId] = useState<string | null>(null);
const psyDrag = useDragDrop();
const [starredSongs, setStarredSongs] = useState<Set<string>>(new Set());
const { excludeAudiobooks, setExcludeAudiobooks, customGenreBlacklist, setCustomGenreBlacklist } = useAuthStore();
const {
excludeAudiobooks,
setExcludeAudiobooks,
customGenreBlacklist,
setCustomGenreBlacklist,
mixMinRatingFilterEnabled,
mixMinRatingSong,
mixMinRatingAlbum,
mixMinRatingArtist,
} = useAuthStore();
const mixRatingCfg = useMemo(
() => ({
enabled: mixMinRatingFilterEnabled,
minSong: mixMinRatingSong,
minAlbum: mixMinRatingAlbum,
minArtist: mixMinRatingArtist,
}),
[mixMinRatingFilterEnabled, mixMinRatingSong, mixMinRatingAlbum, mixMinRatingArtist]
);
const musicLibraryFilterVersion = useAuthStore(s => s.musicLibraryFilterVersion);
const [addedGenre, setAddedGenre] = useState<string | null>(null);
const [addedArtist, setAddedArtist] = useState<string | null>(null);
@@ -58,9 +78,17 @@ export default function RandomMix() {
setSongs([]);
getRandomSongs(50)
.then(fetched => {
setSongs(fetched);
const cfg = useAuthStore.getState();
const mixCfg = {
enabled: cfg.mixMinRatingFilterEnabled,
minSong: cfg.mixMinRatingSong,
minAlbum: cfg.mixMinRatingAlbum,
minArtist: cfg.mixMinRatingArtist,
};
const filtered = fetched.filter(s => passesMixMinRatings(s, mixCfg));
setSongs(filtered);
const st = new Set<string>();
fetched.forEach(s => { if (s.starred) st.add(s.id); });
filtered.forEach(s => { if (s.starred) st.add(s.id); });
setStarredSongs(st);
setLoading(false);
})
@@ -97,6 +125,7 @@ export default function RandomMix() {
if (song.title && checkText(song.title)) return false;
if (song.album && checkText(song.album)) return false;
if (song.artist && checkText(song.artist)) return false;
if (!passesMixMinRatings(song, mixRatingCfg)) return false;
return true;
});
@@ -133,7 +162,14 @@ export default function RandomMix() {
setGenreMixSongs([]);
try {
const fetched = await getRandomSongs(50, genre, 45000);
setGenreMixSongs(fetched);
const cfg = useAuthStore.getState();
const mixCfg = {
enabled: cfg.mixMinRatingFilterEnabled,
minSong: cfg.mixMinRatingSong,
minAlbum: cfg.mixMinRatingAlbum,
minArtist: cfg.mixMinRatingArtist,
};
setGenreMixSongs(fetched.filter(s => passesMixMinRatings(s, mixCfg)));
} catch {}
setGenreMixLoading(false);
setGenreMixComplete(true);
+108 -2
View File
@@ -5,7 +5,7 @@ import { useNavigate, useLocation } from 'react-router-dom';
import {
Wifi, WifiOff, Globe, Music2, Sliders, LogOut, CheckCircle2, FolderOpen,
Palette, Server, Plus, Trash2, Eye, EyeOff, Info, ExternalLink, Shuffle, X, Play, Type, Keyboard, ChevronDown,
GripVertical, PanelLeft, RotateCcw, LayoutGrid, AppWindow, HardDrive, Upload, Download, Waves
GripVertical, PanelLeft, RotateCcw, LayoutGrid, AppWindow, HardDrive, Upload, Download, Waves, Star
} from 'lucide-react';
import { exportBackup, importBackup } from '../utils/backup';
import { showToast } from '../utils/toast';
@@ -18,7 +18,7 @@ import { lastfmGetToken, lastfmAuthUrl, lastfmGetSession, lastfmGetUserInfo, Las
import LastfmIcon from '../components/LastfmIcon';
import CustomSelect from '../components/CustomSelect';
import ThemePicker from '../components/ThemePicker';
import { useAuthStore, ServerProfile } from '../store/authStore';
import { useAuthStore, ServerProfile, MIX_MIN_RATING_FILTER_MAX_STARS } from '../store/authStore';
import { IS_LINUX } from '../utils/platform';
import { useThemeStore } from '../store/themeStore';
import { useFontStore, FontId } from '../store/fontStore';
@@ -32,6 +32,7 @@ import { pingWithCredentials } from '../api/subsonic';
import { open as openDialog } from '@tauri-apps/plugin-dialog';
import { useTranslation } from 'react-i18next';
import Equalizer from '../components/Equalizer';
import StarRating from '../components/StarRating';
const AUDIOBOOK_GENRES_DISPLAY = ['Hörbuch', 'Hoerbuch', 'Hörspiel', 'Hoerspiel', 'Audiobook', 'Audio Book', 'Spoken Word', 'Spokenword', 'Podcast', 'Kapitel', 'Thriller', 'Krimi', 'Speech', 'Fantasy', 'Comedy', 'Literature'];
@@ -777,6 +778,111 @@ export default function Settings() {
</div>
</section>
{/* Ratings (single block under Random Mix) */}
<section className="settings-section">
<div className="settings-section-header">
<Star size={18} />
<h2>{t('settings.ratingsSectionTitle')}</h2>
</div>
<div className="settings-card">
<div className="settings-toggle-row">
<div>
<div style={{ fontWeight: 500 }}>{t('settings.ratingsSkipStarTitle')}</div>
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>{t('settings.ratingsSkipStarDesc')}</div>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 12, flexShrink: 0 }}>
{auth.skipStarOnManualSkipsEnabled && (
<>
<label htmlFor="settings-skip-star-threshold" style={{ fontSize: 13, color: 'var(--text-secondary)', whiteSpace: 'nowrap' }}>
{t('settings.ratingsSkipStarThresholdLabel')}
</label>
<input
id="settings-skip-star-threshold"
className="input"
type="number"
min={1}
max={99}
value={auth.skipStarManualSkipThreshold}
onChange={e => auth.setSkipStarManualSkipThreshold(Number(e.target.value))}
style={{ width: 72, padding: '6px 10px', fontSize: 13 }}
aria-label={t('settings.ratingsSkipStarThresholdLabel')}
/>
</>
)}
<label className="toggle-switch" aria-label={t('settings.ratingsSkipStarTitle')}>
<input
type="checkbox"
checked={auth.skipStarOnManualSkipsEnabled}
onChange={e => auth.setSkipStarOnManualSkipsEnabled(e.target.checked)}
/>
<span className="toggle-track" />
</label>
</div>
</div>
{auth.skipStarOnManualSkipsEnabled && (
<p style={{ fontSize: 12, color: 'var(--text-muted)', marginTop: 10, lineHeight: 1.45 }}>
{t('settings.ratingsSkipStarThresholdHint')}
</p>
)}
<div className="settings-section-divider" />
<div className="settings-toggle-row">
<div>
<div style={{ fontWeight: 500 }}>{t('settings.ratingsMixFilterTitle')}</div>
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>{t('settings.ratingsMixFilterDesc')}</div>
</div>
<label className="toggle-switch" aria-label={t('settings.ratingsMixFilterTitle')}>
<input
type="checkbox"
checked={auth.mixMinRatingFilterEnabled}
onChange={e => auth.setMixMinRatingFilterEnabled(e.target.checked)}
/>
<span className="toggle-track" />
</label>
</div>
{auth.mixMinRatingFilterEnabled && (
<>
<div className="settings-section-divider" />
<div
style={{
display: 'grid',
gridTemplateColumns: 'repeat(3, minmax(0, 1fr))',
gap: '1rem 0.75rem',
alignItems: 'start',
}}
>
{([
{ key: 'song', label: t('settings.ratingsMixMinSong'), value: auth.mixMinRatingSong, set: auth.setMixMinRatingSong },
{ key: 'album', label: t('settings.ratingsMixMinAlbum'), value: auth.mixMinRatingAlbum, set: auth.setMixMinRatingAlbum },
{ key: 'artist', label: t('settings.ratingsMixMinArtist'), value: auth.mixMinRatingArtist, set: auth.setMixMinRatingArtist },
] as const).map(row => (
<div
key={row.key}
style={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
gap: 8,
minWidth: 0,
textAlign: 'center',
}}
>
<span style={{ fontSize: 13, fontWeight: 500, color: 'var(--text-secondary)' }}>{row.label}</span>
<StarRating
maxSelectable={MIX_MIN_RATING_FILTER_MAX_STARS}
value={row.value}
onChange={row.set}
ariaLabel={t('settings.ratingsMixMinThresholdAria', { label: row.label })}
/>
</div>
))}
</div>
</>
)}
</div>
</section>
<HomeCustomizer />
</>
)}
+53
View File
@@ -60,6 +60,19 @@ interface AuthState {
/** Parent directory; actual cache is `<dir>/psysonic-hot-cache/`. Empty = app data. */
hotCacheDownloadDir: string;
/** After this many manual skips of the same track, set track rating to 1 if still unrated (below 1 star). */
skipStarOnManualSkipsEnabled: boolean;
/** Manual skips per track before applying rating 1 (when enabled). */
skipStarManualSkipThreshold: number;
/** Planned / active filter: random mixes (and later album flows) by min stars per axis. */
mixMinRatingFilterEnabled: boolean;
/** 0 = off; 13 = require at least that many stars on the song (UI capped at 3). */
mixMinRatingSong: number;
/** 03; uses `albumUserRating` on song payload when present (OpenSubsonic). */
mixMinRatingAlbum: number;
mixMinRatingArtist: number;
/** Subsonic music folders for the active server (not persisted; refetched on login / server change). */
musicFolders: Array<{ id: string; name: string }>;
/**
@@ -125,6 +138,12 @@ interface AuthState {
setHotCacheMaxMb: (v: number) => void;
setHotCacheDebounceSec: (v: number) => void;
setHotCacheDownloadDir: (v: string) => void;
setSkipStarOnManualSkipsEnabled: (v: boolean) => void;
setSkipStarManualSkipThreshold: (v: number) => void;
setMixMinRatingFilterEnabled: (v: boolean) => void;
setMixMinRatingSong: (v: number) => void;
setMixMinRatingAlbum: (v: number) => void;
setMixMinRatingArtist: (v: number) => void;
setMusicFolders: (folders: Array<{ id: string; name: string }>) => void;
setMusicLibraryFilter: (folderId: 'all' | string) => void;
logout: () => void;
@@ -138,6 +157,19 @@ function generateId(): string {
return Date.now().toString(36) + Math.random().toString(36).slice(2);
}
/** Upper bound for mix min-rating thresholds (UI shows five stars, only 1…this many are selectable). */
export const MIX_MIN_RATING_FILTER_MAX_STARS = 3;
function clampMixFilterMinStars(v: number): number {
if (!Number.isFinite(v)) return 0;
return Math.max(0, Math.min(MIX_MIN_RATING_FILTER_MAX_STARS, Math.round(v)));
}
function clampSkipStarThreshold(v: number): number {
if (!Number.isFinite(v)) return 3;
return Math.max(1, Math.min(99, Math.round(v)));
}
export const useAuthStore = create<AuthState>()(
persist(
(set, get) => ({
@@ -177,6 +209,12 @@ export const useAuthStore = create<AuthState>()(
hotCacheMaxMb: 256,
hotCacheDebounceSec: 30,
hotCacheDownloadDir: '',
skipStarOnManualSkipsEnabled: false,
skipStarManualSkipThreshold: 3,
mixMinRatingFilterEnabled: false,
mixMinRatingSong: 0,
mixMinRatingAlbum: 0,
mixMinRatingArtist: 0,
musicFolders: [],
musicLibraryFilterByServer: {},
musicLibraryFilterVersion: 0,
@@ -267,6 +305,13 @@ export const useAuthStore = create<AuthState>()(
setHotCacheDebounceSec: (v) => set({ hotCacheDebounceSec: v }),
setHotCacheDownloadDir: (v) => set({ hotCacheDownloadDir: v }),
setSkipStarOnManualSkipsEnabled: (v) => set({ skipStarOnManualSkipsEnabled: v }),
setSkipStarManualSkipThreshold: (v) => set({ skipStarManualSkipThreshold: clampSkipStarThreshold(v) }),
setMixMinRatingFilterEnabled: (v) => set({ mixMinRatingFilterEnabled: v }),
setMixMinRatingSong: (v) => set({ mixMinRatingSong: clampMixFilterMinStars(v) }),
setMixMinRatingAlbum: (v) => set({ mixMinRatingAlbum: clampMixFilterMinStars(v) }),
setMixMinRatingArtist: (v) => set({ mixMinRatingArtist: clampMixFilterMinStars(v) }),
setMusicFolders: (folders) => {
const sid = get().activeServerId;
set(s => {
@@ -316,6 +361,14 @@ export const useAuthStore = create<AuthState>()(
const { musicFolders: _mf, musicLibraryFilterVersion: _fv, ...rest } = state;
return rest;
},
onRehydrateStorage: () => (state, error) => {
if (error || !state) return;
useAuthStore.setState({
mixMinRatingSong: clampMixFilterMinStars(state.mixMinRatingSong as number),
mixMinRatingAlbum: clampMixFilterMinStars(state.mixMinRatingAlbum as number),
mixMinRatingArtist: clampMixFilterMinStars(state.mixMinRatingArtist as number),
});
},
}
)
);
+29 -1
View File
@@ -3,7 +3,7 @@ import { persist, createJSONStorage } from 'zustand/middleware';
import { invoke } from '@tauri-apps/api/core';
import { listen } from '@tauri-apps/api/event';
import { showToast } from '../utils/toast';
import { buildCoverArtUrl, getPlayQueue, savePlayQueue, reportNowPlaying, scrobbleSong, SubsonicSong, getSong, getRandomSongs, getSimilarSongs2, getTopSongs, InternetRadioStation } from '../api/subsonic';
import { buildCoverArtUrl, getPlayQueue, savePlayQueue, reportNowPlaying, scrobbleSong, SubsonicSong, getSong, getRandomSongs, getSimilarSongs2, getTopSongs, InternetRadioStation, setRating } from '../api/subsonic';
import { resolvePlaybackUrl } from '../utils/resolvePlaybackUrl';
import { setDeferHotCachePrefetch } from '../utils/hotCacheGate';
import { lastfmScrobble, lastfmUpdateNowPlaying, lastfmLoveTrack, lastfmUnloveTrack, lastfmGetTrackLoved, lastfmGetAllLovedTracks } from '../api/lastfm';
@@ -161,6 +161,32 @@ let seekTarget: number | null = null;
// to the Rust backend before it has finished the previous one.
let togglePlayLock = false;
/** Manual skip counts per track id toward optional “skip → rating 1” (in-memory). */
const manualSkipStarCounts = new Map<string, number>();
function applySkipStarOnManualNext(skippedTrack: Track | null, manual: boolean): void {
if (!manual || !skippedTrack) return;
const { skipStarOnManualSkipsEnabled, skipStarManualSkipThreshold } = useAuthStore.getState();
if (!skipStarOnManualSkipsEnabled || skipStarManualSkipThreshold < 1) return;
const id = skippedTrack.id;
const n = (manualSkipStarCounts.get(id) ?? 0) + 1;
if (n >= skipStarManualSkipThreshold) {
manualSkipStarCounts.delete(id);
const cur = skippedTrack.userRating ?? 0;
if (cur >= 1) return;
setRating(id, 1)
.then(() => {
usePlayerStore.setState(s => ({
queue: s.queue.map(t => (t.id === id ? { ...t, userRating: 1 } : t)),
currentTrack: s.currentTrack?.id === id ? { ...s.currentTrack, userRating: 1 } : s.currentTrack,
}));
})
.catch(() => {});
} else {
manualSkipStarCounts.set(id, n);
}
}
// ── HTML5 Radio Player ────────────────────────────────────────────────────────
// Internet radio streams are played via a native <audio> element instead of
// the Rust/Symphonia engine. This gives us browser-native reconnect logic,
@@ -335,6 +361,7 @@ function handleAudioEnded() {
}
const { repeatMode, currentTrack, queue } = usePlayerStore.getState();
if (currentTrack?.id) manualSkipStarCounts.delete(currentTrack.id);
isAudioPaused = false;
usePlayerStore.setState({ isPlaying: false, progress: 0, currentTime: 0, buffered: 0 });
setTimeout(() => {
@@ -887,6 +914,7 @@ export const usePlayerStore = create<PlayerState>()(
// ── next / previous ──────────────────────────────────────────────────────
next: (manual = true) => {
const { queue, queueIndex, repeatMode, currentTrack } = get();
applySkipStarOnManualNext(currentTrack, manual);
const nextIdx = queueIndex + 1;
if (nextIdx < queue.length) {
get().playTrack(queue[nextIdx], queue, manual);
+10
View File
@@ -3818,6 +3818,16 @@ select.input.input:focus {
transform-origin: center center;
}
.star-rating .star.star--locked {
opacity: 0.4;
cursor: default;
}
.star-rating .star.star--locked:hover {
color: var(--ctp-overlay1);
cursor: default;
}
@keyframes star-rating-star-pulse {
0% {
transform: scale(1);
+27
View File
@@ -0,0 +1,27 @@
import type { SubsonicSong } from '../api/subsonic';
export interface MixMinRatingsConfig {
enabled: boolean;
minSong: number;
minAlbum: number;
minArtist: number;
}
/**
* Random (and future album) flows: drop songs that are below per-axis thresholds when enabled.
* Song axis uses `userRating` (missing = 0). Album/artist use optional OpenSubsonic-style
* fields on the song payload; if a threshold is positive but the value is absent, the song is kept.
*/
export function passesMixMinRatings(song: SubsonicSong, c: MixMinRatingsConfig): boolean {
if (!c.enabled) return true;
if (c.minSong > 0 && (song.userRating ?? 0) < c.minSong) return false;
if (c.minAlbum > 0) {
const r = song.albumUserRating;
if (r !== undefined && r < c.minAlbum) return false;
}
if (c.minArtist > 0) {
const r = song.artistUserRating;
if (r !== undefined && r < c.minArtist) return false;
}
return true;
}