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 { useIsMobile } from '../hooks/useIsMobile'; import { fetchRandomMixSongsUntilFull, getMixMinRatingsConfigFromAuth, passesMixMinRatings, } from '../utils/mixRatingFilter'; const AUDIOBOOK_GENRES = [ 'hörbuch', 'hoerbuch', 'hörspiel', 'hoerspiel', 'audiobook', 'audio book', 'spoken word', 'spokenword', 'podcast', 'kapitel', 'thriller', 'krimi', 'speech', 'fantasy', 'comedy', 'literature', ]; function formatDuration(seconds: number): string { if (!seconds || isNaN(seconds)) return '0:00'; const m = Math.floor(seconds / 60); const s = seconds % 60; return `${m}:${s.toString().padStart(2, '0')}`; } export default function RandomMix() { const { t } = useTranslation(); const [songs, setSongs] = useState([]); const [loading, setLoading] = useState(true); const playTrack = usePlayerStore(s => s.playTrack); 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 starredOverrides = usePlayerStore(s => s.starredOverrides); const setStarredOverride = usePlayerStore(s => s.setStarredOverride); const [contextMenuSongId, setContextMenuSongId] = useState(null); const isMobile = useIsMobile(); const psyDrag = useDragDrop(); const [starredSongs, setStarredSongs] = useState>(new Set()); 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); // 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 = () => { setLoading(true); setSongs([]); fetchRandomMixSongsUntilFull(getMixMinRatingsConfigFromAuth()) .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 = songs.filter(song => { if (!excludeAudiobooks) return true; const checkText = (text: string) => { const t = text.toLowerCase(); if (AUDIOBOOK_GENRES.some(ag => t.includes(ag))) return true; if (customGenreBlacklist.some(bg => t.includes(bg.toLowerCase()))) return true; return false; }; if (song.genre && checkText(song.genre)) return false; 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; }); 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) => { setGenreMixLoading(true); setGenreMixComplete(false); setGenreMixSongs([]); try { const list = await fetchRandomMixSongsUntilFull(getMixMinRatingsConfigFromAuth(), { genre, timeout: 45000, }); 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 (

{t('randomMix.title')}

{(() => { const isGenreLoading = selectedGenre && !genreMixComplete; const isDisabled = loading || (selectedGenre ? !genreMixComplete || genreMixSongs.length === 0 : filteredSongs.length === 0); return ( ); })()}
{/* ── Filter + Genre Mix panel ─────────────────────────────── */}
{/* Left: Blacklist */}
{isMobile ? ( ) : (
{t('randomMix.filterPanelTitle')}
)} {(!isMobile || filtersExpanded) && (

{t('randomMix.filterPanelDesc')}

{blacklistOpen && (
{customGenreBlacklist.length === 0 ? ( {t('settings.randomMixBlacklistEmpty')} ) : ( customGenreBlacklist.map(genre => ( {genre} )) )}
setNewGenre(e.target.value)} onKeyDown={e => { if (e.key === 'Enter' && newGenre.trim()) { const trimmed = newGenre.trim(); if (!customGenreBlacklist.includes(trimmed)) setCustomGenreBlacklist([...customGenreBlacklist, trimmed]); setNewGenre(''); } }} placeholder={t('settings.randomMixBlacklistPlaceholder')} style={{ fontSize: 12 }} />
)}
)}
{/* Right: Genre Mix */}
{isMobile ? ( ) : (
{t('randomMix.genreMixTitle')}
)} {(!isMobile || genreMixExpanded) && (

{t('randomMix.genreMixDesc')}

{serverGenres.length === 0 ? (
) : displayedGenres.length === 0 ? ( {t('randomMix.genreMixNoGenres')} ) : ( <> {displayedGenres.map(genre => ( ))} {allAvailableGenres.length > 20 && ( )} )}
)}
{/* 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 isCurrentTrack = currentTrack?.id === song.id; const artist = song.artist; const isArtistBlocked = !!artist && customGenreBlacklist.some(bg => artist.toLowerCase().includes(bg.toLowerCase())); const isArtistJustAdded = addedArtist === artist; return (
{ if ((e.target as HTMLElement).closest('button, a, input')) return; playTrack(track, queueSongs); }} role="row" onContextMenu={e => { e.preventDefault(); setContextMenuSongId(song.id); openContextMenu(e.clientX, e.clientY, track, 'song'); }} onMouseDown={e => { if (e.button !== 0) return; e.preventDefault(); const sx = e.clientX, sy = e.clientY; const onMove = (me: MouseEvent) => { if (Math.abs(me.clientX - sx) > 5 || Math.abs(me.clientY - sy) > 5) { document.removeEventListener('mousemove', onMove); document.removeEventListener('mouseup', onUp); psyDrag.startDrag({ data: JSON.stringify({ type: 'song', track }), label: song.title }, me.clientX, me.clientY); } }; const onUp = () => { document.removeEventListener('mousemove', onMove); document.removeEventListener('mouseup', onUp); }; document.addEventListener('mousemove', onMove); document.addEventListener('mouseup', onUp); }} >
{ e.stopPropagation(); playTrack(track, queueSongs); }}> {isCurrentTrack && isPlaying &&
} {idx + 1}
{song.title}
{artist ? ( ) : }
{song.album ?? '—'}
{formatDuration(song.duration)}
); })}
)}
)} {!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 isCurrentTrack = currentTrack?.id === song.id; const artist = song.artist; const genre = song.genre; const isArtistBlocked = !!artist && customGenreBlacklist.some(bg => artist.toLowerCase().includes(bg.toLowerCase())); const isArtistJustAdded = addedArtist === artist; const isGenreBlocked = !!genre && ( AUDIOBOOK_GENRES.some(ag => genre.toLowerCase().includes(ag)) || customGenreBlacklist.some(bg => genre.toLowerCase().includes(bg.toLowerCase())) ); const isGenreJustAdded = addedGenre === genre; return (
{ if ((e.target as HTMLElement).closest('button, a, input')) return; playTrack(track, queueSongs); }} role="row" onContextMenu={e => { e.preventDefault(); setContextMenuSongId(song.id); openContextMenu(e.clientX, e.clientY, track, 'song'); }} onMouseDown={e => { if (e.button !== 0) return; e.preventDefault(); const sx = e.clientX, sy = e.clientY; const onMove = (me: MouseEvent) => { if (Math.abs(me.clientX - sx) > 5 || Math.abs(me.clientY - sy) > 5) { document.removeEventListener('mousemove', onMove); document.removeEventListener('mouseup', onUp); psyDrag.startDrag({ data: JSON.stringify({ type: 'song', track }), label: song.title }, me.clientX, me.clientY); } }; const onUp = () => { document.removeEventListener('mousemove', onMove); document.removeEventListener('mouseup', onUp); }; document.addEventListener('mousemove', onMove); document.addEventListener('mouseup', onUp); }} >
{ e.stopPropagation(); playTrack(track, queueSongs); }}> {isCurrentTrack && isPlaying &&
} {idx + 1}
{song.title}
{artist ? ( ) : }
{song.album ?? '—'}
{genre ? ( ) : }
{formatDuration(song.duration)}
); })}
))}
); }