feat(seekbar+ratings): 5 new seekbar styles and entity/mix rating system (PR #130)

Seekbar styles (5 new, by Psychotoxical):
- Neon Glow: transparent track, played section as multi-layer glowing neon tube
- Pulse Wave: flat line with animated gaussian sine pulse at playhead (rAF)
- Particle Trail: particles spawn at playhead and drift with glow (rAF)
- Liquid Fill: glass tube fills with liquid and animated wave surface (rAF)
- Retro Tape: two spinning reels connected by tape, shrink/grow with progress (rAF)

Ratings system (PR #130 by cucadmuh):
- Shared StarRating component with pulse/clear animations, disabled/locked states
- Entity ratings for albums and artists via OpenSubsonic probe (setEntityRatingSupport)
- Skip→1★: after N consecutive manual skips, set unrated track to 1★ (persisted per server)
- Mix rating filter: exclude low-rated songs/albums/artists from RandomMix, RandomAlbums, Hero, Home
- Batch refill in fetchRandomMixSongsUntilFull to fill list despite strict filters
- Prefetch artist/album ratings via getArtist/getAlbum for incomplete list payloads
- Settings: new Ratings section for skip threshold and per-axis mix filter stars
- i18n: all 7 languages (en, de, fr, nl, zh, nb, ru)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Psychotoxical
2026-04-08 10:29:21 +02:00
30 changed files with 1806 additions and 126 deletions
+44
View File
@@ -13,6 +13,7 @@ import AlbumHeader from '../components/AlbumHeader';
import AlbumTrackList from '../components/AlbumTrackList';
import { useCachedUrl } from '../components/CachedImage';
import { useTranslation } from 'react-i18next';
import { showToast } from '../utils/toast';
function sanitizeFilename(name: string): string {
return name
@@ -33,6 +34,7 @@ export default function AlbumDetail() {
const openContextMenu = usePlayerStore(s => s.openContextMenu);
const starredOverrides = usePlayerStore(s => s.starredOverrides);
const setStarredOverride = usePlayerStore(s => s.setStarredOverride);
const userRatingOverrides = usePlayerStore(s => s.userRatingOverrides);
const currentTrack = usePlayerStore(s => s.currentTrack);
const isPlaying = usePlayerStore(s => s.isPlaying);
@@ -52,6 +54,11 @@ export default function AlbumDetail() {
const offlineAlbums = useOfflineStore(s => s.albums);
const offlineJobs = useOfflineStore(s => s.jobs);
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' = (() => {
if (!album) return 'none';
@@ -90,6 +97,11 @@ export default function AlbumDetail() {
}).catch(() => setLoading(false));
}, [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 = () => {
if (!album) return;
const albumGenre = album.album.genre;
@@ -126,9 +138,37 @@ const handleEnqueueAll = () => {
const handleRate = async (songId: string, rating: number) => {
setRatings(r => ({ ...r, [songId]: rating }));
usePlayerStore.getState().setUserRatingOverride(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 () => {
if (!album) return;
if (bio) { setBioOpen(true); return; }
@@ -270,6 +310,9 @@ const handleEnqueueAll = () => {
offlineProgress={offlineProgress}
onCacheOffline={handleCacheOffline}
onRemoveOffline={handleRemoveOffline}
entityRatingValue={albumEntityRating}
onEntityRatingChange={handleAlbumEntityRating}
entityRatingSupport={albumEntityRatingSupport}
/>
{offlineStorageFull && (
<div className="offline-storage-full-banner" role="alert">
@@ -290,6 +333,7 @@ const handleEnqueueAll = () => {
currentTrack={currentTrack}
isPlaying={isPlaying}
ratings={ratings}
userRatingOverrides={userRatingOverrides}
starredSongs={new Set([
...[...starredSongs].filter(id => starredOverrides[id] !== false),
...Object.entries(starredOverrides).filter(([, v]) => v).map(([k]) => k),
+45 -1
View File
@@ -1,6 +1,6 @@
import { useEffect, useState, useRef } from 'react';
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 CachedImage from '../components/CachedImage';
import CoverLightbox from '../components/CoverLightbox';
@@ -14,6 +14,7 @@ import { lastfmGetSimilarArtists, lastfmIsConfigured } from '../api/lastfm';
import LastfmIcon from '../components/LastfmIcon';
import { invalidateCoverArt } from '../utils/imageCache';
import { showToast } from '../utils/toast';
import StarRating from '../components/StarRating';
function formatDuration(seconds: number): string {
const m = Math.floor(seconds / 60);
@@ -71,6 +72,11 @@ export default function ArtistDetail() {
const { downloadArtist, bulkProgress } = useOfflineStore();
const activeServerId = useAuthStore(s => s.activeServerId) ?? '';
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(() => {
if (!id) return;
@@ -94,6 +100,34 @@ export default function ArtistDetail() {
});
}, [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
useEffect(() => {
if (!id || !artist) return;
@@ -351,6 +385,16 @@ export default function ArtistDetail() {
{t('artistDetail.albumCount_other', { count: artist.albumCount ?? 0 })}
</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' }}>
{(info?.lastFmUrl || artist.name) && (
<div className="artist-detail-links">
+12 -3
View File
@@ -20,7 +20,7 @@ const FAV_COLUMNS: readonly ColDef[] = [
{ key: 'num', i18nKey: null, minWidth: 60, defaultWidth: 60, required: true },
{ key: 'title', i18nKey: 'trackTitle', minWidth: 150, defaultWidth: 0, required: true, flex: true },
{ 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 },
];
@@ -182,8 +182,17 @@ export default function Favorites() {
const isCentered = key === 'duration';
return (
<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 }}>
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{label}</span>
<div
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>
{!isLastCol && <div className="col-resize-handle" onMouseDown={e => startResize(e, colIndex, 1)} />}
</div>
+56 -24
View File
@@ -7,10 +7,19 @@ import { NavLink, useNavigate } from 'react-router-dom';
import { ChevronRight } from 'lucide-react';
import { useHomeStore } from '../store/homeStore';
import { useAuthStore } from '../store/authStore';
import { filterAlbumsByMixRatings, getMixMinRatingsConfigFromAuth } from '../utils/mixRatingFilter';
/** Match Random Albums overshoot when mix filter uses album/artist axes so hero + discover row can still fill. */
const HOME_RANDOM_FETCH = 100;
const HOME_HERO_COUNT = 8;
const HOME_DISCOVER_SLICE = 20;
export default function Home() {
const homeSections = useHomeStore(s => s.sections);
const musicLibraryFilterVersion = useAuthStore(s => s.musicLibraryFilterVersion);
const mixMinRatingFilterEnabled = useAuthStore(s => s.mixMinRatingFilterEnabled);
const mixMinRatingAlbum = useAuthStore(s => s.mixMinRatingAlbum);
const mixMinRatingArtist = useAuthStore(s => s.mixMinRatingArtist);
const isVisible = (id: string) => homeSections.find(s => s.id === id)?.visible ?? true;
const [starred, setStarred] = useState<SubsonicAlbum[]>([]);
@@ -23,30 +32,50 @@ export default function Home() {
const [loading, setLoading] = useState(true);
useEffect(() => {
Promise.all([
getAlbumList('starred', 12).catch(() => []),
getAlbumList('newest', 12).catch(() => []),
getAlbumList('random', 20).catch(() => []),
getAlbumList('frequent', 12).catch(() => []),
getAlbumList('recent', 12).catch(() => []),
isVisible('discoverArtists') ? getArtists().catch(() => []) : Promise.resolve<SubsonicArtist[]>([]),
]).then(([s, n, r, f, rp, artists]) => {
setStarred(s);
setRecent(n);
setHeroAlbums(r.slice(0, 8));
setRandom(r.slice(8));
setMostPlayed(f);
setRecentlyPlayed(rp);
// Pick 16 random artists via Fisher-Yates shuffle
const shuffled = [...artists];
for (let i = shuffled.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[shuffled[i], shuffled[j]] = [shuffled[j], shuffled[i]];
let cancelled = false;
setLoading(true);
(async () => {
try {
const mixCfg = getMixMinRatingsConfigFromAuth();
const albumMix =
mixCfg.enabled && (mixCfg.minAlbum > 0 || mixCfg.minArtist > 0);
const randomSize = albumMix ? HOME_RANDOM_FETCH : HOME_DISCOVER_SLICE;
const [s, n, rRaw, f, rp, artists] = await Promise.all([
getAlbumList('starred', 12).catch(() => []),
getAlbumList('newest', 12).catch(() => []),
getAlbumList('random', randomSize).catch(() => []),
getAlbumList('frequent', 12).catch(() => []),
getAlbumList('recent', 12).catch(() => []),
isVisible('discoverArtists') ? getArtists().catch(() => []) : Promise.resolve<SubsonicArtist[]>([]),
]);
if (cancelled) return;
const r = await filterAlbumsByMixRatings(rRaw, mixCfg);
setStarred(s);
setRecent(n);
setHeroAlbums(r.slice(0, HOME_HERO_COUNT));
setRandom(r.slice(HOME_HERO_COUNT, HOME_DISCOVER_SLICE));
setMostPlayed(f);
setRecentlyPlayed(rp);
const shuffled = [...artists];
for (let i = shuffled.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[shuffled[i], shuffled[j]] = [shuffled[j], shuffled[i]];
}
setRandomArtists(shuffled.slice(0, 16));
} catch {
/* ignore */
} finally {
if (!cancelled) setLoading(false);
}
setRandomArtists(shuffled.slice(0, 16));
setLoading(false);
}).catch(() => setLoading(false));
}, [musicLibraryFilterVersion, homeSections]);
})();
return () => { cancelled = true; };
}, [
musicLibraryFilterVersion,
homeSections,
mixMinRatingFilterEnabled,
mixMinRatingAlbum,
mixMinRatingArtist,
]);
const loadMore = async (
type: 'starred' | 'newest' | 'random' | 'frequent' | 'recent',
@@ -55,7 +84,10 @@ export default function Home() {
) => {
try {
const more = await getAlbumList(type, 12, currentList.length);
const newItems = more.filter(m => !currentList.find(c => c.id === m.id));
const mixCfg = getMixMinRatingsConfigFromAuth();
const batch =
type === 'random' ? await filterAlbumsByMixRatings(more, mixCfg) : more;
const newItems = batch.filter(m => !currentList.find(c => c.id === m.id));
if (newItems.length > 0) setter(prev => [...prev, ...newItems]);
} catch (e) {
console.error('Failed to load more', e);
+2 -1
View File
@@ -211,6 +211,7 @@ export default function NowPlaying() {
const navigate = useNavigate();
const currentTrack = usePlayerStore(s => s.currentTrack);
const userRatingOverrides = usePlayerStore(s => s.userRatingOverrides);
const isPlaying = usePlayerStore(s => s.isPlaying);
const showLyrics = useLyricsStore(s => s.showLyrics);
const activeTab = useLyricsStore(s => s.activeTab);
@@ -292,7 +293,7 @@ export default function NowPlaying() {
{currentTrack.suffix && <span className="np-badge">{currentTrack.suffix.toUpperCase()}</span>}
{currentTrack.bitRate && <span className="np-badge">{currentTrack.bitRate} kbps</span>}
{currentTrack.duration && <span className="np-badge">{formatTime(currentTrack.duration)}</span>}
{renderStars(currentTrack.userRating)}
{renderStars(userRatingOverrides[currentTrack.id] ?? currentTrack.userRating)}
<button onClick={toggleStar} className="np-star-btn"
data-tooltip={starred ? t('contextMenu.unfavorite') : t('contextMenu.favorite')}
>
+18 -23
View File
@@ -21,6 +21,7 @@ import CachedImage, { useCachedUrl } from '../components/CachedImage';
import { coverArtCacheKey, buildCoverArtUrl } from '../api/subsonic';
import { useTranslation } from 'react-i18next';
import { showToast } from '../utils/toast';
import StarRating from '../components/StarRating';
function sanitizeFilename(name: string): string {
return name
@@ -50,23 +51,6 @@ function codecLabel(song: SubsonicSong): string {
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 ──────────────────────────────────────────────────────
const PL_COLUMNS: readonly ColDef[] = [
{ 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: 'favorite', i18nKey: 'trackFavorite', minWidth: 50, defaultWidth: 70, 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: 'delete', i18nKey: null, minWidth: 36, defaultWidth: 36, required: true },
];
@@ -85,7 +69,7 @@ export default function PlaylistDetail() {
const { id } = useParams<{ id: string }>();
const { t } = useTranslation();
const navigate = useNavigate();
const { playTrack, enqueue, openContextMenu, currentTrack, isPlaying, starredOverrides, setStarredOverride } = usePlayerStore(
const { playTrack, enqueue, openContextMenu, currentTrack, isPlaying, starredOverrides, setStarredOverride, userRatingOverrides } = usePlayerStore(
useShallow(s => ({
playTrack: s.playTrack,
enqueue: s.enqueue,
@@ -94,6 +78,7 @@ export default function PlaylistDetail() {
isPlaying: s.isPlaying,
starredOverrides: s.starredOverrides,
setStarredOverride: s.setStarredOverride,
userRatingOverrides: s.userRatingOverrides,
}))
);
const touchPlaylist = usePlaylistStore((s) => s.touchPlaylist);
@@ -370,6 +355,7 @@ export default function PlaylistDetail() {
// ── Rating / Star ─────────────────────────────────────────────
const handleRate = (songId: string, rating: number) => {
setRatings(prev => ({ ...prev, [songId]: rating }));
usePlayerStore.getState().setUserRatingOverride(songId, rating);
setRating(songId, rating).catch(() => {});
};
@@ -750,8 +736,17 @@ export default function PlaylistDetail() {
if (key === 'delete') return <div key="delete" />;
return (
<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 }}>
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{label}</span>
<div
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>
{!isLastCol && key !== 'delete' && (
<div className="col-resize-handle" onMouseDown={e => startResize(e, colIndex, 1)} />
@@ -851,7 +846,7 @@ export default function PlaylistDetail() {
</button>
</div>
);
case 'rating': return <StarRating key="rating" value={ratings[song.id] ?? song.userRating ?? 0} onChange={r => handleRate(song.id, r)} />;
case 'rating': return <StarRating key="rating" value={ratings[song.id] ?? userRatingOverrides[song.id] ?? song.userRating ?? 0} onChange={r => handleRate(song.id, r)} />;
case 'duration': return <div key="duration" className="track-duration">{formatDuration(song.duration ?? 0)}</div>;
case 'format': return (
<div key="format" className="track-meta">
@@ -917,7 +912,7 @@ export default function PlaylistDetail() {
if (key === 'title') return <div key="title" style={{ paddingLeft: 12 }}>{label}</div>;
if (key === 'delete') return <div key="delete" />;
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>
+26 -6
View File
@@ -5,6 +5,7 @@ import AlbumCard from '../components/AlbumCard';
import GenreFilterBar from '../components/GenreFilterBar';
import { useTranslation } from 'react-i18next';
import { useAuthStore } from '../store/authStore';
import { filterAlbumsByMixRatings, getMixMinRatingsConfigFromAuth } from '../utils/mixRatingFilter';
import { useOfflineStore } from '../store/offlineStore';
import { useDownloadModalStore } from '../store/downloadModalStore';
import { writeFile } from '@tauri-apps/plugin-fs';
@@ -12,6 +13,10 @@ import { join } from '@tauri-apps/api/path';
import { showToast } from '../utils/toast';
const ALBUM_COUNT = 30;
/** Extra pool when mix rating filter is on so we can still fill the grid after filtering. */
const ALBUM_FETCH_OVERSHOOT = 100;
/** Cap genre-union size before rating prefetch (avoids hundreds of `getArtist` calls). */
const GENRE_UNION_PREFILTER_CAP = 250;
function sanitizeFilename(name: string): string {
return name.replace(/[<>:"/\\|?*\x00-\x1f]/g, '_').trim() || 'download';
@@ -25,17 +30,21 @@ async function fetchByGenres(genres: string[]): Promise<SubsonicAlbum[]> {
const j = Math.floor(Math.random() * (i + 1));
[union[i], union[j]] = [union[j], union[i]];
}
return union.slice(0, ALBUM_COUNT);
const pool = union.slice(0, GENRE_UNION_PREFILTER_CAP);
const filtered = await filterAlbumsByMixRatings(pool, getMixMinRatingsConfigFromAuth());
return filtered.slice(0, ALBUM_COUNT);
}
export default function RandomAlbums() {
const { t } = useTranslation();
const musicLibraryFilterVersion = useAuthStore(s => s.musicLibraryFilterVersion);
const auth = useAuthStore();
const serverId = useAuthStore(s => s.activeServerId ?? '');
const musicLibraryFilterVersion = auth.musicLibraryFilterVersion;
const mixMinRatingFilterEnabled = auth.mixMinRatingFilterEnabled;
const mixMinRatingAlbum = auth.mixMinRatingAlbum;
const mixMinRatingArtist = auth.mixMinRatingArtist;
const serverId = auth.activeServerId ?? '';
const { downloadAlbum } = useOfflineStore();
const requestDownloadFolder = useDownloadModalStore(s => s.requestFolder);
const [albums, setAlbums] = useState<SubsonicAlbum[]>([]);
const [loading, setLoading] = useState(true);
const [selectedGenres, setSelectedGenres] = useState<string[]>([]);
@@ -94,7 +103,13 @@ export default function RandomAlbums() {
loadingRef.current = true;
setLoading(true);
try {
const data = genres.length > 0 ? await fetchByGenres(genres) : await getAlbumList('random', ALBUM_COUNT);
const mixCfg = getMixMinRatingsConfigFromAuth();
const albumMixActive =
mixCfg.enabled && (mixCfg.minAlbum > 0 || mixCfg.minArtist > 0);
const randomSize = albumMixActive ? Math.max(ALBUM_COUNT * 3, ALBUM_FETCH_OVERSHOOT) : ALBUM_COUNT;
const data = genres.length > 0
? await fetchByGenres(genres)
: (await filterAlbumsByMixRatings(await getAlbumList('random', randomSize), mixCfg)).slice(0, ALBUM_COUNT);
setAlbums(data);
} catch (e) {
console.error(e);
@@ -102,7 +117,12 @@ export default function RandomAlbums() {
loadingRef.current = false;
setLoading(false);
}
}, [musicLibraryFilterVersion]);
}, [
musicLibraryFilterVersion,
mixMinRatingFilterEnabled,
mixMinRatingAlbum,
mixMinRatingArtist,
]);
useEffect(() => { load(selectedGenres); }, [selectedGenres, load]);
+37 -9
View File
@@ -1,10 +1,15 @@
import React, { useEffect, useState } from 'react';
import { getRandomSongs, getGenres, SubsonicSong, SubsonicGenre, star, unstar } from '../api/subsonic';
import React, { useEffect, useMemo, useState } from 'react';
import { 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 {
fetchRandomMixSongsUntilFull,
getMixMinRatingsConfigFromAuth,
passesMixMinRatings,
} from '../utils/mixRatingFilter';
const AUDIOBOOK_GENRES = [
'hörbuch', 'hoerbuch', 'hörspiel', 'hoerspiel',
@@ -35,7 +40,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);
@@ -56,11 +80,11 @@ export default function RandomMix() {
const fetchSongs = () => {
setLoading(true);
setSongs([]);
getRandomSongs(50)
.then(fetched => {
setSongs(fetched);
fetchRandomMixSongsUntilFull(getMixMinRatingsConfigFromAuth())
.then(list => {
setSongs(list);
const st = new Set<string>();
fetched.forEach(s => { if (s.starred) st.add(s.id); });
list.forEach(s => { if (s.starred) st.add(s.id); });
setStarredSongs(st);
setLoading(false);
})
@@ -97,6 +121,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;
});
@@ -132,8 +157,11 @@ export default function RandomMix() {
setGenreMixComplete(false);
setGenreMixSongs([]);
try {
const fetched = await getRandomSongs(50, genre, 45000);
setGenreMixSongs(fetched);
const list = await fetchRandomMixSongsUntilFull(getMixMinRatingsConfigFromAuth(), {
genre,
timeout: 45000,
});
setGenreMixSongs(list);
} catch {}
setGenreMixLoading(false);
setGenreMixComplete(true);
+109 -3
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, type SeekbarStyle } from '../store/authStore';
import { useAuthStore, ServerProfile, MIX_MIN_RATING_FILTER_MAX_STARS, type SeekbarStyle } from '../store/authStore';
import { SeekbarPreview } from '../components/WaveformSeek';
import { IS_LINUX } from '../utils/platform';
import { useThemeStore } from '../store/themeStore';
@@ -33,6 +33,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'];
@@ -778,6 +779,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>
<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', {
mix: t('sidebar.randomMix'),
albums: t('sidebar.randomAlbums'),
})}
</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 />
</>
)}
@@ -1158,7 +1264,7 @@ export default function Settings() {
{t('settings.seekbarStyleDesc')}
</div>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 10 }}>
{(['waveform', 'linedot', 'bar', 'thick', 'segmented'] as SeekbarStyle[]).map(style => (
{(['waveform', 'linedot', 'bar', 'thick', 'segmented', 'neon', 'pulsewave', 'particletrail', 'liquidfill', 'retrotape'] as SeekbarStyle[]).map(style => (
<SeekbarPreview
key={style}
style={style}