mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-21 23:05:46 +00:00
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:
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -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 column’s 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.',
|
||||
|
||||
@@ -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é.',
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
@@ -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
@@ -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 />
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -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; 1–3 = require at least that many stars on the song (UI capped at 3). */
|
||||
mixMinRatingSong: number;
|
||||
/** 0–3; 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),
|
||||
});
|
||||
},
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user