import React, { useEffect, useState } from 'react'; import { getRandomSongs, getGenres, SubsonicSong, SubsonicGenre, star, unstar } from '../api/subsonic'; import { usePlayerStore } from '../store/playerStore'; import { useAuthStore } from '../store/authStore'; import { Play, Star, RefreshCw, ChevronDown, ChevronUp } from 'lucide-react'; import { useTranslation } from 'react-i18next'; const AUDIOBOOK_GENRES = [ 'hörbuch', 'hoerbuch', 'hörspiel', 'hoerspiel', 'audiobook', 'audio book', 'spoken word', 'spokenword', 'podcast', 'kapitel', 'thriller', 'krimi', 'speech', 'fantasy', 'comedy', 'literature', ]; interface SuperGenre { id: string; label: string; keywords: string[]; } const SUPER_GENRES: SuperGenre[] = [ { id: 'metal', label: 'Metal', keywords: ['metal', 'thrash', 'doom', 'sludge', 'hardcore', 'grindcore', 'deathcore', 'metalcore', 'stoner', 'crust', 'black', 'death'] }, { id: 'rock', label: 'Rock', keywords: ['rock', 'punk', 'grunge', 'alternative', 'indie', 'post-rock', 'prog', 'garage', 'psychedelic', 'shoegaze'] }, { id: 'pop', label: 'Pop', keywords: ['pop', 'synth-pop', 'dream pop', 'electropop', 'indie pop', 'dance pop'] }, { id: 'electronic', label: 'Electronic', keywords: ['electronic', 'techno', 'trance', 'ambient', 'edm', 'house', 'dubstep', 'drum and bass', 'dnb', 'electro', 'idm', 'synthwave', 'darkwave', 'industrial'] }, { id: 'jazz', label: 'Jazz', keywords: ['jazz', 'blues', 'soul', 'funk', 'swing', 'bebop', 'fusion'] }, { id: 'classical', label: 'Classical', keywords: ['classical', 'orchestra', 'symphony', 'baroque', 'opera', 'chamber', 'romantic'] }, { id: 'hiphop', label: 'Hip-Hop', keywords: ['hip-hop', 'hip hop', 'rap', 'r&b', 'rnb', 'trap', 'grime'] }, { id: 'country', label: 'Country', keywords: ['country', 'folk', 'bluegrass', 'americana', 'western'] }, { id: 'world', label: 'World', keywords: ['world', 'latin', 'reggae', 'ska', 'afro', 'celtic', 'flamenco', 'bossa nova'] }, ]; 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 [contextMenuSongId, setContextMenuSongId] = useState(null); const [starredSongs, setStarredSongs] = useState>(new Set()); const { excludeAudiobooks, setExcludeAudiobooks, customGenreBlacklist, setCustomGenreBlacklist } = useAuthStore(); const [addedGenre, setAddedGenre] = useState(null); // Blacklist panel state const [blacklistOpen, setBlacklistOpen] = useState(false); const [newGenre, setNewGenre] = useState(''); // Genre Mix state const [serverGenres, setServerGenres] = useState([]); const [selectedSuperGenre, setSelectedSuperGenre] = useState(null); const [genreMixSongs, setGenreMixSongs] = useState([]); const [genreMixLoading, setGenreMixLoading] = useState(false); const fetchSongs = () => { setLoading(true); getRandomSongs(50) .then(fetched => { setSongs(fetched); const st = new Set(); fetched.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(setServerGenres).catch(() => {}); }, []); 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; return true; }); const handlePlayAll = () => { if (filteredSongs.length > 0) playTrack(filteredSongs[0], filteredSongs); }; const toggleSongStar = async (song: SubsonicSong, e: React.MouseEvent) => { e.stopPropagation(); const currentlyStarred = starredSongs.has(song.id); const nextStarred = new Set(starredSongs); if (currentlyStarred) nextStarred.delete(song.id); else nextStarred.add(song.id); setStarredSongs(nextStarred); 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)); } }; // Compute which super-genres have matching server genres const availableSuperGenres = SUPER_GENRES.filter(sg => serverGenres.some(sg2 => sg.keywords.some(kw => sg2.value.toLowerCase().includes(kw)) ) ); const loadGenreMix = async (superGenreId: string) => { const sg = SUPER_GENRES.find(s => s.id === superGenreId); if (!sg) return; const matched = serverGenres .filter(sg2 => sg.keywords.some(kw => sg2.value.toLowerCase().includes(kw))) .map(sg2 => sg2.value); setGenreMixLoading(true); setGenreMixSongs([]); const perGenre = Math.max(1, Math.ceil(50 / matched.length)); const accumulated: SubsonicSong[] = []; let resolved = 0; await Promise.allSettled(matched.map(g => getRandomSongs(perGenre, g, 45000).then(songs => { accumulated.push(...songs); resolved++; // Show first batch immediately; update on every subsequent resolve setGenreMixSongs([...accumulated]); if (resolved === 1) setGenreMixLoading(false); }).catch(() => { resolved++; }) )); // Final shuffle once all requests are done setGenreMixSongs(prev => { const s = [...prev]; for (let i = s.length - 1; i > 0; i--) { const j = Math.floor(Math.random() * (i + 1)); [s[i], s[j]] = [s[j], s[i]]; } return s.slice(0, 50); }); setGenreMixLoading(false); }; return (

{t('randomMix.title')}

{/* ── Filter + Genre Mix panel ─────────────────────────────── */}
{/* Left: Blacklist */}
{t('randomMix.filterPanelTitle')}
{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 */}
{t('randomMix.genreMixTitle')}

{t('randomMix.genreMixDesc')}

{serverGenres.length === 0 ? (
) : availableSuperGenres.length === 0 ? ( {t('randomMix.genreMixNoGenres')} ) : ( availableSuperGenres.map(sg => ( )) )}
{/* Genre Mix tracklist (shown when a super-genre is selected) */} {(genreMixLoading || genreMixSongs.length > 0) && (
{SUPER_GENRES.find(s => s.id === selectedSuperGenre)?.label} Mix {genreMixLoading &&
}
{genreMixLoading && genreMixSongs.length === 0 ? (
) : (
{t('randomMix.trackTitle')} {t('randomMix.trackArtist')} {t('randomMix.trackAlbum')} {t('randomMix.trackGenre')} {t('randomMix.trackDuration')}
{genreMixSongs.map(song => (
playTrack(song, genreMixSongs)} role="row" draggable onContextMenu={e => { e.preventDefault(); setContextMenuSongId(song.id); openContextMenu(e.clientX, e.clientY, { id: song.id, title: song.title, artist: song.artist, album: song.album, albumId: song.albumId, artistId: song.artistId, duration: song.duration, coverArt: song.coverArt, track: song.track, year: song.year, bitRate: song.bitRate, suffix: song.suffix, userRating: song.userRating }, 'song'); }} onDragStart={e => { e.dataTransfer.effectAllowed = 'copy'; e.dataTransfer.setData('text/plain', JSON.stringify({ type: 'song', track: { id: song.id, title: song.title, artist: song.artist, album: song.album, albumId: song.albumId, artistId: song.artistId, duration: song.duration, coverArt: song.coverArt, track: song.track, year: song.year, bitRate: song.bitRate, suffix: song.suffix, userRating: song.userRating } })); }} >
{song.title}
{song.artist}
{song.album}
{song.genre ?? '—'}
{formatDuration(song.duration)}
))}
)}
)} {!selectedSuperGenre && (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) => (
playTrack(song, filteredSongs)} role="row" draggable onContextMenu={e => { e.preventDefault(); const track = { id: song.id, title: song.title, artist: song.artist, album: song.album, albumId: song.albumId, artistId: song.artistId, duration: song.duration, coverArt: song.coverArt, track: song.track, year: song.year, bitRate: song.bitRate, suffix: song.suffix, userRating: song.userRating }; setContextMenuSongId(song.id); openContextMenu(e.clientX, e.clientY, track, 'song'); }} onDragStart={e => { e.dataTransfer.effectAllowed = 'copy'; const track = { id: song.id, title: song.title, artist: song.artist, album: song.album, albumId: song.albumId, artistId: song.artistId, duration: song.duration, coverArt: song.coverArt, track: song.track, year: song.year, bitRate: song.bitRate, suffix: song.suffix, userRating: song.userRating, }; e.dataTransfer.setData('text/plain', JSON.stringify({ type: 'song', track })); }} >
{song.title}
{song.artist}
{song.album}
{(() => { const genre = song.genre; if (!genre) return
; const isBlocked = AUDIOBOOK_GENRES.some(ag => genre.toLowerCase().includes(ag)) || customGenreBlacklist.some(bg => genre.toLowerCase().includes(bg.toLowerCase())); const justAdded = addedGenre === genre; return ( ); })()}
{formatDuration(song.duration)}
))}
))}
); }