import { star, unstar } from '../api/subsonicStarRating'; import { getGenres } from '../api/subsonicGenres'; import type { SubsonicSong, SubsonicGenre } from '../api/subsonicTypes'; import { songToTrack } from '../utils/playback/songToTrack'; import React, { useEffect, useMemo, useState } from 'react'; import { usePlayerStore } from '../store/playerStore'; import { usePreviewStore } from '../store/previewStore'; import { useAuthStore } from '../store/authStore'; import { useTranslation } from 'react-i18next'; import { useIsMobile } from '../hooks/useIsMobile'; import { useOrbitSongRowBehavior } from '../hooks/useOrbitSongRowBehavior'; import { fetchRandomMixSongsUntilFull, getMixMinRatingsConfigFromAuth, } from '../utils/mix/mixRatingFilter'; import { AUDIOBOOK_GENRES, filterRandomMixSongs, formatRandomMixDuration } from '../utils/componentHelpers/randomMixHelpers'; import RandomMixHeader from '../components/randomMix/RandomMixHeader'; import RandomMixFiltersPanel from '../components/randomMix/RandomMixFiltersPanel'; import RandomMixGenrePanel from '../components/randomMix/RandomMixGenrePanel'; import RandomMixTrackRow from '../components/randomMix/RandomMixTrackRow'; export default function RandomMix() { const { t } = useTranslation(); const [songs, setSongs] = useState([]); const [loading, setLoading] = useState(true); const playTrack = usePlayerStore(s => s.playTrack); const { orbitActive, queueHint, addTrackToOrbit } = useOrbitSongRowBehavior(); const openContextMenu = usePlayerStore(s => s.openContextMenu); const contextMenuOpen = usePlayerStore(s => s.contextMenu.isOpen); const currentTrack = usePlayerStore(s => s.currentTrack); const isPlaying = usePlayerStore(s => s.isPlaying); const previewingId = usePreviewStore(s => s.previewingId); const previewAudioStarted = usePreviewStore(s => s.audioStarted); const starredOverrides = usePlayerStore(s => s.starredOverrides); const setStarredOverride = usePlayerStore(s => s.setStarredOverride); const [contextMenuSongId, setContextMenuSongId] = useState(null); const isMobile = useIsMobile(); const [starredSongs, setStarredSongs] = useState>(new Set()); const { excludeAudiobooks, setExcludeAudiobooks, customGenreBlacklist, setCustomGenreBlacklist, mixMinRatingFilterEnabled, mixMinRatingSong, mixMinRatingAlbum, mixMinRatingArtist, randomMixSize, setRandomMixSize, } = 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); // Blacklist panel state const [blacklistOpen, setBlacklistOpen] = useState(false); const [newGenre, setNewGenre] = useState(''); // Mobile collapsible panels const [filtersExpanded, setFiltersExpanded] = useState(false); const [genreMixExpanded, setGenreMixExpanded] = useState(false); // Genre Mix state const [serverGenres, setServerGenres] = useState([]); const [allAvailableGenres, setAllAvailableGenres] = useState([]); const [displayedGenres, setDisplayedGenres] = useState([]); const [selectedGenre, setSelectedGenre] = useState(null); const [genreMixSongs, setGenreMixSongs] = useState([]); const [genreMixLoading, setGenreMixLoading] = useState(false); const [genreMixComplete, setGenreMixComplete] = useState(false); const fetchSongs = (overrideSize?: number) => { setLoading(true); setSongs([]); fetchRandomMixSongsUntilFull(getMixMinRatingsConfigFromAuth(), { targetSize: overrideSize ?? randomMixSize }) .then(list => { setSongs(list); const st = new Set(); list.forEach(s => { if (s.starred) st.add(s.id); }); setStarredSongs(st); setLoading(false); }) .catch(() => setLoading(false)); }; useEffect(() => { if (!contextMenuOpen) setContextMenuSongId(null); }, [contextMenuOpen]); useEffect(() => { fetchSongs(); getGenres().then(data => { setServerGenres(data); const audiobookLower = AUDIOBOOK_GENRES.map(g => g.toLowerCase()); const available = data .filter(g => g.songCount > 0 && !audiobookLower.some(ab => g.value.toLowerCase().includes(ab))) .sort((a, b) => b.songCount - a.songCount) .map(g => g.value); setAllAvailableGenres(available); setDisplayedGenres(available.slice(0, 20)); }).catch(() => {}); }, [musicLibraryFilterVersion]); const filteredSongs = filterRandomMixSongs(songs, { excludeAudiobooks, customGenreBlacklist, mixRatingCfg }); const handlePlayAll = () => { if (selectedGenre && genreMixSongs.length > 0) { playTrack(songToTrack(genreMixSongs[0]), genreMixSongs.map(songToTrack)); } else if (filteredSongs.length > 0) { playTrack(songToTrack(filteredSongs[0]), filteredSongs.map(songToTrack)); } }; const toggleSongStar = async (song: SubsonicSong, e: React.MouseEvent) => { e.stopPropagation(); const currentlyStarred = song.id in starredOverrides ? starredOverrides[song.id] : starredSongs.has(song.id); const nextStarred = new Set(starredSongs); if (currentlyStarred) nextStarred.delete(song.id); else nextStarred.add(song.id); setStarredSongs(nextStarred); setStarredOverride(song.id, !currentlyStarred); try { if (currentlyStarred) await unstar(song.id, 'song'); else await star(song.id, 'song'); } catch (err) { console.error('Failed to toggle song star', err); setStarredSongs(new Set(starredSongs)); setStarredOverride(song.id, currentlyStarred); } }; const loadGenreMix = async (genre: string, overrideSize?: number) => { setGenreMixLoading(true); setGenreMixComplete(false); setGenreMixSongs([]); try { const list = await fetchRandomMixSongsUntilFull(getMixMinRatingsConfigFromAuth(), { genre, timeout: 45000, targetSize: overrideSize ?? randomMixSize, }); setGenreMixSongs(list); } catch {} setGenreMixLoading(false); setGenreMixComplete(true); }; const shuffleDisplayedGenres = () => { const shuffled = [...allAvailableGenres].sort(() => Math.random() - 0.5); setDisplayedGenres(shuffled.slice(0, 20)); setSelectedGenre(null); setGenreMixSongs([]); setGenreMixComplete(false); }; return (
loadGenreMix(selectedGenre) : () => fetchSongs()} onPlayAll={handlePlayAll} /> {/* ── Filter + Genre Mix panel ─────────────────────────────── */}
{ setSelectedGenre(null); setGenreMixSongs([]); setGenreMixComplete(false); fetchSongs(); }} onSelectGenre={genre => { setSelectedGenre(genre); loadGenreMix(genre); }} onShuffle={shuffleDisplayedGenres} />
{/* Genre Mix tracklist (shown when a genre is selected) */} {(genreMixLoading || genreMixSongs.length > 0) && (
{selectedGenre} Mix {genreMixLoading &&
}
{genreMixLoading && genreMixSongs.length === 0 ? (
) : (
{t('randomMix.trackTitle')}
{t('randomMix.trackArtist')}
{t('randomMix.trackAlbum')}
{t('randomMix.trackFavorite')}
{t('randomMix.trackDuration')}
{genreMixSongs.map((song, idx) => { const track = songToTrack(song); const queueSongs = genreMixSongs.map(songToTrack); const isStarred = song.id in starredOverrides ? starredOverrides[song.id] : starredSongs.has(song.id); return ( playTrack(track, queueSongs)} onQueueHint={queueHint} onAddTrackToOrbit={addTrackToOrbit} onOpenContextMenu={e => { e.preventDefault(); setContextMenuSongId(song.id); openContextMenu(e.clientX, e.clientY, track, 'song'); }} onToggleStar={e => toggleSongStar(song, e)} onBlacklistArtist={artist => { if (!customGenreBlacklist.some(bg => artist.toLowerCase().includes(bg.toLowerCase()))) { setCustomGenreBlacklist([...customGenreBlacklist, artist]); setAddedArtist(artist); setTimeout(() => setAddedArtist(null), 1500); } }} onBlacklistGenre={() => {}} /> ); })}
)}
)} {!selectedGenre && (loading && songs.length === 0 ? (
) : (
{t('randomMix.trackTitle')}
{t('randomMix.trackArtist')}
{t('randomMix.trackAlbum')}
{t('randomMix.trackGenre')}
{t('randomMix.trackFavorite')}
{t('randomMix.trackDuration')}
{filteredSongs.map((song, idx) => { const track = songToTrack(song); const queueSongs = filteredSongs.map(songToTrack); const genre = song.genre; const isStarred = song.id in starredOverrides ? starredOverrides[song.id] : starredSongs.has(song.id); const isGenreBlocked = !!genre && ( AUDIOBOOK_GENRES.some(ag => genre.toLowerCase().includes(ag)) || customGenreBlacklist.some(bg => genre.toLowerCase().includes(bg.toLowerCase())) ); return ( playTrack(track, queueSongs)} onQueueHint={queueHint} onAddTrackToOrbit={addTrackToOrbit} onOpenContextMenu={e => { e.preventDefault(); setContextMenuSongId(song.id); openContextMenu(e.clientX, e.clientY, track, 'song'); }} onToggleStar={e => toggleSongStar(song, e)} onBlacklistArtist={artist => { if (!customGenreBlacklist.some(bg => artist.toLowerCase().includes(bg.toLowerCase()))) { setCustomGenreBlacklist([...customGenreBlacklist, artist]); setAddedArtist(artist); setTimeout(() => setAddedArtist(null), 1500); } }} onBlacklistGenre={g => { if (!customGenreBlacklist.some(bg => g.toLowerCase().includes(bg.toLowerCase()))) { setCustomGenreBlacklist([...customGenreBlacklist, g]); setAddedGenre(g); setTimeout(() => setAddedGenre(null), 1500); } }} /> ); })}
))}
); }