From 40dd0bd100d615344754be11bf26f2c9d77f7187 Mon Sep 17 00:00:00 2001 From: Frank Stellmacher <171614930+Psychotoxical@users.noreply.github.com> Date: Wed, 13 May 2026 18:21:50 +0200 Subject: [PATCH] =?UTF-8?q?refactor(random-mix):=20G.88=20=E2=80=94=20extr?= =?UTF-8?q?act=20panels=20+=20dedupe=20track=20row=20(cluster,=20multi-com?= =?UTF-8?q?mit)=20(#655)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refactor(random-mix): G.88.1 — extract helpers + AUDIOBOOK_GENRES + filter logic * refactor(random-mix): G.88.2 — extract RandomMixHeader component * refactor(random-mix): G.88.3 — extract RandomMixFiltersPanel component * refactor(random-mix): G.88.4 — extract RandomMixGenrePanel component * refactor(random-mix): G.88.5 — extract RandomMixTrackRow (dedupe genre + main lists) --- .../randomMix/RandomMixFiltersPanel.tsx | 164 +++++ .../randomMix/RandomMixGenrePanel.tsx | 89 +++ src/components/randomMix/RandomMixHeader.tsx | 58 ++ .../randomMix/RandomMixTrackRow.tsx | 171 +++++ src/pages/RandomMix.tsx | 591 ++++-------------- src/utils/randomMixHelpers.ts | 41 ++ 6 files changed, 646 insertions(+), 468 deletions(-) create mode 100644 src/components/randomMix/RandomMixFiltersPanel.tsx create mode 100644 src/components/randomMix/RandomMixGenrePanel.tsx create mode 100644 src/components/randomMix/RandomMixHeader.tsx create mode 100644 src/components/randomMix/RandomMixTrackRow.tsx create mode 100644 src/utils/randomMixHelpers.ts diff --git a/src/components/randomMix/RandomMixFiltersPanel.tsx b/src/components/randomMix/RandomMixFiltersPanel.tsx new file mode 100644 index 00000000..e6345757 --- /dev/null +++ b/src/components/randomMix/RandomMixFiltersPanel.tsx @@ -0,0 +1,164 @@ +import React from 'react'; +import { useTranslation } from 'react-i18next'; +import { ChevronDown, ChevronUp } from 'lucide-react'; +import { RANDOM_MIX_SIZE_OPTIONS } from '../../store/authStoreDefaults'; + +interface Props { + isMobile: boolean; + filtersExpanded: boolean; + setFiltersExpanded: React.Dispatch>; + randomMixSize: number; + setRandomMixSize: (n: number) => void; + selectedGenre: string | null; + loadGenreMix: (genre: string, overrideSize?: number) => void; + fetchSongs: (overrideSize?: number) => void; + excludeAudiobooks: boolean; + setExcludeAudiobooks: (v: boolean) => void; + blacklistOpen: boolean; + setBlacklistOpen: React.Dispatch>; + customGenreBlacklist: string[]; + setCustomGenreBlacklist: (next: string[]) => void; + newGenre: string; + setNewGenre: React.Dispatch>; +} + +export default function RandomMixFiltersPanel({ + isMobile, filtersExpanded, setFiltersExpanded, + randomMixSize, setRandomMixSize, selectedGenre, loadGenreMix, fetchSongs, + excludeAudiobooks, setExcludeAudiobooks, + blacklistOpen, setBlacklistOpen, + customGenreBlacklist, setCustomGenreBlacklist, + newGenre, setNewGenre, +}: Props) { + const { t } = useTranslation(); + + const addCustomGenre = (trimmed: string) => { + if (trimmed && !customGenreBlacklist.includes(trimmed)) { + setCustomGenreBlacklist([...customGenreBlacklist, trimmed]); + } + setNewGenre(''); + }; + + return ( +
+ {isMobile ? ( + + ) : ( +
+ {t('randomMix.filterPanelTitle')} +
+ )} + {(!isMobile || filtersExpanded) && ( +
+
+ {t('randomMix.mixSettingsHeader')} +
+ +

+ {t('randomMix.filterPanelInexactSizeNote')} +

+ +
+ {t('randomMix.mixSize')} + {RANDOM_MIX_SIZE_OPTIONS.map(n => ( + + ))} +
+ +
+ +
+ {t('randomMix.exclusionsHeader')} +
+ +

+ {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()) addCustomGenre(newGenre.trim()); + }} + placeholder={t('settings.randomMixBlacklistPlaceholder')} + style={{ fontSize: 12 }} + /> + +
+
+ )} +
+ )} +
+ ); +} diff --git a/src/components/randomMix/RandomMixGenrePanel.tsx b/src/components/randomMix/RandomMixGenrePanel.tsx new file mode 100644 index 00000000..e619fdfd --- /dev/null +++ b/src/components/randomMix/RandomMixGenrePanel.tsx @@ -0,0 +1,89 @@ +import React from 'react'; +import { useTranslation } from 'react-i18next'; +import { ChevronDown, ChevronUp, RefreshCw } from 'lucide-react'; + +interface Props { + isMobile: boolean; + genreMixExpanded: boolean; + setGenreMixExpanded: React.Dispatch>; + serverGenresLength: number; + displayedGenres: string[]; + allAvailableGenresLength: number; + selectedGenre: string | null; + genreMixLoading: boolean; + onSelectAll: () => void; + onSelectGenre: (genre: string) => void; + onShuffle: () => void; +} + +export default function RandomMixGenrePanel({ + isMobile, genreMixExpanded, setGenreMixExpanded, + serverGenresLength, displayedGenres, allAvailableGenresLength, + selectedGenre, genreMixLoading, onSelectAll, onSelectGenre, onShuffle, +}: Props) { + const { t } = useTranslation(); + + return ( +
+ {isMobile ? ( + + ) : ( +
+ {t('randomMix.genreMixTitle')} +
+ )} + {(!isMobile || genreMixExpanded) && ( +
+

{t('randomMix.genreMixDesc')}

+
+ {serverGenresLength === 0 ? ( +
+ ) : displayedGenres.length === 0 ? ( + {t('randomMix.genreMixNoGenres')} + ) : ( + <> + + {displayedGenres.map(genre => ( + + ))} + {allAvailableGenresLength > 20 && ( + + )} + + )} +
+
+ )} +
+ ); +} diff --git a/src/components/randomMix/RandomMixHeader.tsx b/src/components/randomMix/RandomMixHeader.tsx new file mode 100644 index 00000000..1a04c1ce --- /dev/null +++ b/src/components/randomMix/RandomMixHeader.tsx @@ -0,0 +1,58 @@ +import React from 'react'; +import { useTranslation } from 'react-i18next'; +import { Play, RefreshCw } from 'lucide-react'; + +interface Props { + selectedGenre: string | null; + loading: boolean; + genreMixLoading: boolean; + genreMixComplete: boolean; + genreMixSongsLength: number; + filteredSongsLength: number; + randomMixSize: number; + onRefresh: () => void; + onPlayAll: () => void; +} + +export default function RandomMixHeader({ + selectedGenre, loading, genreMixLoading, genreMixComplete, + genreMixSongsLength, filteredSongsLength, randomMixSize, + onRefresh, onPlayAll, +}: Props) { + const { t } = useTranslation(); + const isGenreLoading = selectedGenre && !genreMixComplete; + const isPlayDisabled = loading + || (selectedGenre ? !genreMixComplete || genreMixSongsLength === 0 : filteredSongsLength === 0); + + return ( +
+

{t('randomMix.title')}

+ +
+ + +
+
+ ); +} diff --git a/src/components/randomMix/RandomMixTrackRow.tsx b/src/components/randomMix/RandomMixTrackRow.tsx new file mode 100644 index 00000000..25745a75 --- /dev/null +++ b/src/components/randomMix/RandomMixTrackRow.tsx @@ -0,0 +1,171 @@ +import React from 'react'; +import { useTranslation } from 'react-i18next'; +import { AudioLines, ChevronRight, Heart, Play, Square } from 'lucide-react'; +import type { SubsonicSong } from '../../api/subsonicTypes'; +import type { Track } from '../../store/playerStoreTypes'; +import { usePreviewStore } from '../../store/previewStore'; +import { useDragDrop } from '../../contexts/DragDropContext'; +import { formatRandomMixDuration } from '../../utils/randomMixHelpers'; + +interface Props { + song: SubsonicSong; + idx: number; + gridTemplateColumns: string; + track: Track; + queueSongs: Track[]; + isCurrentTrack: boolean; + isPlaying: boolean; + isContextActive: boolean; + orbitActive: boolean; + previewingId: string | null; + previewAudioStarted: boolean; + starredOverrides: Record; + isStarred: boolean; + customGenreBlacklist: string[]; + addedArtist: string | null; + addedGenre: string | null; + showGenreCol: boolean; + isGenreBlocked: boolean; + onPlay: () => void; + onQueueHint: () => void; + onAddTrackToOrbit: (id: string) => void; + onOpenContextMenu: (e: React.MouseEvent) => void; + onToggleStar: (e: React.MouseEvent) => void; + onBlacklistArtist: (artist: string) => void; + onBlacklistGenre: (genre: string) => void; +} + +export default function RandomMixTrackRow({ + song, idx, gridTemplateColumns, track, queueSongs, + isCurrentTrack, isPlaying, isContextActive, orbitActive, + previewingId, previewAudioStarted, starredOverrides, isStarred, + customGenreBlacklist, addedArtist, addedGenre, showGenreCol, isGenreBlocked, + onPlay, onQueueHint, onAddTrackToOrbit, onOpenContextMenu, onToggleStar, + onBlacklistArtist, onBlacklistGenre, +}: Props) { + const { t } = useTranslation(); + const psyDrag = useDragDrop(); + + const artist = song.artist; + const genre = song.genre; + const isArtistBlocked = !!artist && customGenreBlacklist.some(bg => artist.toLowerCase().includes(bg.toLowerCase())); + const isArtistJustAdded = addedArtist === artist; + const isGenreJustAdded = addedGenre === genre; + const starColor = isStarred + ? 'var(--color-star-active, var(--accent))' + : 'var(--color-star-inactive, var(--text-muted))'; + + return ( +
{ + if ((e.target as HTMLElement).closest('button, a, input')) return; + if (orbitActive) { onQueueHint(); return; } + onPlay(); + }} + onDoubleClick={orbitActive ? e => { + if ((e.target as HTMLElement).closest('button, a, input')) return; + onAddTrackToOrbit(song.id); + } : undefined} + role="row" + onContextMenu={onOpenContextMenu} + 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); + }} + > +
+ {isCurrentTrack && isPlaying ? ( + + ) : ( + {idx + 1} + )} +
+ +
+ + + {song.title} +
+ +
+ {artist ? ( + + ) : } +
+ +
+ {song.album ?? '—'} +
+ + {showGenreCol && ( +
+ {genre ? ( + + ) : } +
+ )} + +
+ +
+ +
{formatRandomMixDuration(song.duration)}
+
+ ); +} diff --git a/src/pages/RandomMix.tsx b/src/pages/RandomMix.tsx index 389cd1a8..26acdc7b 100644 --- a/src/pages/RandomMix.tsx +++ b/src/pages/RandomMix.tsx @@ -1,37 +1,23 @@ import { star, unstar } from '../api/subsonicStarRating'; import { getGenres } from '../api/subsonicGenres'; import type { SubsonicSong, SubsonicGenre } from '../api/subsonicTypes'; -import { RANDOM_MIX_SIZE_OPTIONS } from '../store/authStoreDefaults'; import { songToTrack } from '../utils/songToTrack'; import React, { useEffect, useMemo, useState } from 'react'; import { usePlayerStore } from '../store/playerStore'; import { usePreviewStore } from '../store/previewStore'; import { useAuthStore } from '../store/authStore'; -import { Play, RefreshCw, ChevronDown, ChevronRight, ChevronUp, Heart, Square, AudioLines } from 'lucide-react'; import { useTranslation } from 'react-i18next'; -import { useDragDrop } from '../contexts/DragDropContext'; import { useIsMobile } from '../hooks/useIsMobile'; import { useOrbitSongRowBehavior } from '../hooks/useOrbitSongRowBehavior'; 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')}`; -} +import { AUDIOBOOK_GENRES, filterRandomMixSongs, formatRandomMixDuration } from '../utils/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(); @@ -49,7 +35,6 @@ export default function RandomMix() { 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, @@ -126,21 +111,7 @@ export default function RandomMix() { }).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 filteredSongs = filterRandomMixSongs(songs, { excludeAudiobooks, customGenreBlacklist, mixRatingCfg }); const handlePlayAll = () => { if (selectedGenre && genreMixSongs.length > 0) { @@ -196,41 +167,17 @@ export default function RandomMix() { return (
-
-

{t('randomMix.title')}

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

- {t('randomMix.filterPanelInexactSizeNote')} -

- -
- {t('randomMix.mixSize')} - {RANDOM_MIX_SIZE_OPTIONS.map(n => ( - - ))} -
- - {/* ── divider ── */} -
- - {/* ── Exclusions ── */} -
- {t('randomMix.exclusionsHeader')} -
- -

- {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 && ( - - )} - - )} -
-
- )} -
+ { setSelectedGenre(null); setGenreMixSongs([]); setGenreMixComplete(false); fetchSongs(); }} + onSelectGenre={genre => { setSelectedGenre(genre); loadGenreMix(genre); }} + onShuffle={shuffleDisplayedGenres} + />
{/* Genre Mix tracklist (shown when a genre is selected) */} @@ -464,100 +248,46 @@ export default function RandomMix() { {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; + const isStarred = song.id in starredOverrides ? starredOverrides[song.id] : starredSongs.has(song.id); return ( -
{ if ((e.target as HTMLElement).closest('button, a, input')) return; if (orbitActive) { queueHint(); return; } playTrack(track, queueSongs); }} - onDoubleClick={orbitActive ? e => { if ((e.target as HTMLElement).closest('button, a, input')) return; addTrackToOrbit(song.id); } : undefined} - role="row" - onContextMenu={e => { e.preventDefault(); setContextMenuSongId(song.id); openContextMenu(e.clientX, e.clientY, track, 'song'); }} - onMouseDown={e => { - if (e.button !== 0) return; + song={song} + idx={idx} + gridTemplateColumns="60px minmax(150px, 1fr) minmax(80px, 1fr) minmax(80px, 1fr) 70px 65px" + track={track} + queueSongs={queueSongs} + isCurrentTrack={currentTrack?.id === song.id} + isPlaying={isPlaying} + isContextActive={contextMenuSongId === song.id} + orbitActive={orbitActive} + previewingId={previewingId} + previewAudioStarted={previewAudioStarted} + starredOverrides={starredOverrides} + isStarred={isStarred} + customGenreBlacklist={customGenreBlacklist} + addedArtist={addedArtist} + addedGenre={addedGenre} + showGenreCol={false} + isGenreBlocked={false} + onPlay={() => playTrack(track, queueSongs)} + onQueueHint={queueHint} + onAddTrackToOrbit={addTrackToOrbit} + onOpenContextMenu={e => { 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); + setContextMenuSongId(song.id); + openContextMenu(e.clientX, e.clientY, track, 'song'); }} - > -
- {isCurrentTrack && isPlaying ? ( - - ) : ( - {idx + 1} - )} -
-
- - - {song.title} -
-
- {artist ? ( - - ) : } -
-
- {song.album ?? '—'} -
-
- -
-
{formatDuration(song.duration)}
-
+ 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={() => {}} + /> ); })}
@@ -586,132 +316,57 @@ export default function RandomMix() { {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 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())) ); - const isGenreJustAdded = addedGenre === genre; return ( -
{ if ((e.target as HTMLElement).closest('button, a, input')) return; if (orbitActive) { queueHint(); return; } playTrack(track, queueSongs); }} - onDoubleClick={orbitActive ? e => { if ((e.target as HTMLElement).closest('button, a, input')) return; addTrackToOrbit(song.id); } : undefined} - role="row" - onContextMenu={e => { + song={song} + idx={idx} + gridTemplateColumns="60px minmax(150px, 1fr) minmax(80px, 1fr) minmax(80px, 1fr) 120px 70px 65px" + track={track} + queueSongs={queueSongs} + isCurrentTrack={currentTrack?.id === song.id} + isPlaying={isPlaying} + isContextActive={contextMenuSongId === song.id} + orbitActive={orbitActive} + previewingId={previewingId} + previewAudioStarted={previewAudioStarted} + starredOverrides={starredOverrides} + isStarred={isStarred} + customGenreBlacklist={customGenreBlacklist} + addedArtist={addedArtist} + addedGenre={addedGenre} + showGenreCol + isGenreBlocked={isGenreBlocked} + onPlay={() => playTrack(track, queueSongs)} + onQueueHint={queueHint} + onAddTrackToOrbit={addTrackToOrbit} + onOpenContextMenu={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); + 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); + } }} - > -
- {isCurrentTrack && isPlaying ? ( - - ) : ( - {idx + 1} - )} -
- -
- - - {song.title} -
- -
- {artist ? ( - - ) : } -
- -
- {song.album ?? '—'} -
- -
- {genre ? ( - - ) : } -
- -
- -
- -
{formatDuration(song.duration)}
-
+ onBlacklistGenre={g => { + if (!customGenreBlacklist.some(bg => g.toLowerCase().includes(bg.toLowerCase()))) { + setCustomGenreBlacklist([...customGenreBlacklist, g]); + setAddedGenre(g); + setTimeout(() => setAddedGenre(null), 1500); + } + }} + /> ); })}
diff --git a/src/utils/randomMixHelpers.ts b/src/utils/randomMixHelpers.ts new file mode 100644 index 00000000..93af1d7a --- /dev/null +++ b/src/utils/randomMixHelpers.ts @@ -0,0 +1,41 @@ +import type { SubsonicSong } from '../api/subsonicTypes'; +import { passesMixMinRatings, type MixMinRatingsConfig } from './mixRatingFilter'; + +export const AUDIOBOOK_GENRES = [ + 'hörbuch', 'hoerbuch', 'hörspiel', 'hoerspiel', + 'audiobook', 'audio book', 'spoken word', 'spokenword', + 'podcast', 'kapitel', 'thriller', 'krimi', 'speech', + 'fantasy', 'comedy', 'literature', +]; + +export function formatRandomMixDuration(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')}`; +} + +interface FilterArgs { + excludeAudiobooks: boolean; + customGenreBlacklist: string[]; + mixRatingCfg: MixMinRatingsConfig; +} + +export function filterRandomMixSongs(songs: SubsonicSong[], args: FilterArgs): SubsonicSong[] { + const { excludeAudiobooks, customGenreBlacklist, mixRatingCfg } = args; + return 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; + }); +}