diff --git a/src/api/subsonic.ts b/src/api/subsonic.ts index 68f2761c..9df3a3e3 100644 --- a/src/api/subsonic.ts +++ b/src/api/subsonic.ts @@ -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; diff --git a/src/components/StarRating.tsx b/src/components/StarRating.tsx index eb27aee9..baea128c 100644 --- a/src/components/StarRating.tsx +++ b/src/components/StarRating.tsx @@ -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(null); const [clearShrinkStar, setClearShrinkStar] = React.useState(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({
- {[1, 2, 3, 4, 5].map(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 || locked} + aria-label={`${n}`} + role="radio" + aria-checked={filled(n)} + > + ★ + + ); + })}
); } diff --git a/src/locales/de.ts b/src/locales/de.ts index f84486fd..301d086f 100644 --- a/src/locales/de.ts +++ b/src/locales/de.ts @@ -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.', diff --git a/src/locales/en.ts b/src/locales/en.ts index a3de0655..2636b519 100644 --- a/src/locales/en.ts +++ b/src/locales/en.ts @@ -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.', diff --git a/src/locales/fr.ts b/src/locales/fr.ts index 3d62ff7e..b625fcc9 100644 --- a/src/locales/fr.ts +++ b/src/locales/fr.ts @@ -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é.', diff --git a/src/locales/nb.ts b/src/locales/nb.ts index 532921dd..64cf36f5 100644 --- a/src/locales/nb.ts +++ b/src/locales/nb.ts @@ -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.', diff --git a/src/locales/nl.ts b/src/locales/nl.ts index 3dde3436..254efcda 100644 --- a/src/locales/nl.ts +++ b/src/locales/nl.ts @@ -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.', diff --git a/src/locales/ru.ts b/src/locales/ru.ts index 679d48c9..7ba51955 100644 --- a/src/locales/ru.ts +++ b/src/locales/ru.ts @@ -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: diff --git a/src/locales/zh.ts b/src/locales/zh.ts index f1f97674..6b91a4bb 100644 --- a/src/locales/zh.ts +++ b/src/locales/zh.ts @@ -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 文件。密码以明文存储——请妥善保管该文件。', diff --git a/src/pages/RandomMix.tsx b/src/pages/RandomMix.tsx index 23e85088..56e6be7d 100644 --- a/src/pages/RandomMix.tsx +++ b/src/pages/RandomMix.tsx @@ -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(null); const psyDrag = useDragDrop(); const [starredSongs, setStarredSongs] = useState>(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(null); const [addedArtist, setAddedArtist] = useState(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(); - 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); diff --git a/src/pages/Settings.tsx b/src/pages/Settings.tsx index d6fc9bad..56267442 100644 --- a/src/pages/Settings.tsx +++ b/src/pages/Settings.tsx @@ -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() { + {/* Ratings (single block under Random Mix) */} +
+
+ +

{t('settings.ratingsSectionTitle')}

+
+
+
+
+
{t('settings.ratingsSkipStarTitle')}
+
{t('settings.ratingsSkipStarDesc')}
+
+
+ {auth.skipStarOnManualSkipsEnabled && ( + <> + + auth.setSkipStarManualSkipThreshold(Number(e.target.value))} + style={{ width: 72, padding: '6px 10px', fontSize: 13 }} + aria-label={t('settings.ratingsSkipStarThresholdLabel')} + /> + + )} + +
+
+ {auth.skipStarOnManualSkipsEnabled && ( +

+ {t('settings.ratingsSkipStarThresholdHint')} +

+ )} + +
+ +
+
+
{t('settings.ratingsMixFilterTitle')}
+
{t('settings.ratingsMixFilterDesc')}
+
+ +
+ {auth.mixMinRatingFilterEnabled && ( + <> +
+
+ {([ + { 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 => ( +
+ {row.label} + +
+ ))} +
+ + )} +
+
+ )} diff --git a/src/store/authStore.ts b/src/store/authStore.ts index 6141f8be..e9b29646 100644 --- a/src/store/authStore.ts +++ b/src/store/authStore.ts @@ -60,6 +60,19 @@ interface AuthState { /** Parent directory; actual cache is `/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()( persist( (set, get) => ({ @@ -177,6 +209,12 @@ export const useAuthStore = create()( 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()( 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()( 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), + }); + }, } ) ); diff --git a/src/store/playerStore.ts b/src/store/playerStore.ts index 6c82fb1c..ab13f65b 100644 --- a/src/store/playerStore.ts +++ b/src/store/playerStore.ts @@ -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(); + +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