refactor(random-mix): G.88 — extract panels + dedupe track row (cluster, multi-commit) (#655)

* 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)
This commit is contained in:
Frank Stellmacher
2026-05-13 18:21:50 +02:00
committed by GitHub
parent d4d3b0e53f
commit 40dd0bd100
6 changed files with 646 additions and 468 deletions
@@ -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<React.SetStateAction<boolean>>;
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<React.SetStateAction<boolean>>;
customGenreBlacklist: string[];
setCustomGenreBlacklist: (next: string[]) => void;
newGenre: string;
setNewGenre: React.Dispatch<React.SetStateAction<string>>;
}
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 (
<div style={{ background: 'var(--bg-card)', padding: '1rem 1.25rem' }}>
{isMobile ? (
<button
className="btn btn-ghost"
style={{ width: '100%', justifyContent: 'space-between', fontSize: 14, fontWeight: 700, textTransform: 'uppercase', letterSpacing: '0.05em', color: 'var(--text-secondary)', padding: '0' }}
onClick={() => setFiltersExpanded(v => !v)}
>
{t('randomMix.filterPanelTitle')}
{filtersExpanded ? <ChevronUp size={14} /> : <ChevronDown size={14} />}
</button>
) : (
<div style={{ fontSize: 14, fontWeight: 700, textTransform: 'uppercase', letterSpacing: '0.05em', color: 'var(--text-secondary)', marginBottom: '0.85rem' }}>
{t('randomMix.filterPanelTitle')}
</div>
)}
{(!isMobile || filtersExpanded) && (
<div style={{ marginTop: isMobile ? '0.75rem' : 0 }}>
<div style={{ fontSize: 10, fontWeight: 600, textTransform: 'uppercase', letterSpacing: '0.06em', color: 'var(--text-muted)', marginBottom: '0.4rem' }}>
{t('randomMix.mixSettingsHeader')}
</div>
<p style={{ fontSize: 11, color: 'var(--text-muted)', marginTop: 0, marginBottom: '0.6rem', lineHeight: 1.45, fontStyle: 'italic' }}>
{t('randomMix.filterPanelInexactSizeNote')}
</p>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', flexWrap: 'wrap', marginBottom: '0.25rem' }}>
<span style={{ fontSize: 12, color: 'var(--text-muted)' }}>{t('randomMix.mixSize')}</span>
{RANDOM_MIX_SIZE_OPTIONS.map(n => (
<button
key={n}
className={`btn ${randomMixSize === n ? 'btn-primary' : 'btn-surface'}`}
style={{ fontSize: 12, padding: '3px 10px' }}
onClick={() => {
if (n === randomMixSize) return;
setRandomMixSize(n);
if (selectedGenre) loadGenreMix(selectedGenre, n);
else fetchSongs(n);
}}
>{n}</button>
))}
</div>
<div style={{ borderTop: '1px solid var(--border)', margin: '0.85rem 0' }} />
<div style={{ fontSize: 10, fontWeight: 600, textTransform: 'uppercase', letterSpacing: '0.06em', color: 'var(--text-muted)', marginBottom: '0.5rem' }}>
{t('randomMix.exclusionsHeader')}
</div>
<p style={{ fontSize: 11, color: 'var(--text-muted)', marginBottom: '0.6rem', lineHeight: 1.45 }}>
{t('randomMix.filterPanelDesc')}
</p>
<label style={{ display: 'flex', alignItems: 'flex-start', gap: '0.5rem', cursor: 'pointer', fontSize: 13, marginBottom: '0.6rem' }}>
<input
type="checkbox"
checked={excludeAudiobooks}
onChange={e => setExcludeAudiobooks(e.target.checked)}
style={{ marginTop: 2 }}
/>
<div>
<div style={{ fontWeight: 500, color: 'var(--text-primary)' }}>{t('randomMix.excludeAudiobooks')}</div>
<div style={{ fontSize: 11, color: 'var(--text-muted)', marginTop: 2 }}>{t('randomMix.excludeAudiobooksDesc')}</div>
</div>
</label>
<button
className="btn btn-ghost"
style={{ fontSize: 12, padding: '3px 8px', marginBottom: blacklistOpen ? '0.5rem' : 0 }}
onClick={() => setBlacklistOpen(v => !v)}
>
{blacklistOpen ? <ChevronUp size={12} /> : <ChevronDown size={12} />}
{t('randomMix.blacklistToggle')} ({customGenreBlacklist.length})
</button>
{blacklistOpen && (
<div>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.35rem', marginBottom: '0.5rem', minHeight: 24 }}>
{customGenreBlacklist.length === 0 ? (
<span style={{ fontSize: 11, color: 'var(--text-muted)' }}>{t('settings.randomMixBlacklistEmpty')}</span>
) : (
customGenreBlacklist.map(genre => (
<span key={genre} style={{
display: 'inline-flex', alignItems: 'center', gap: 3,
background: 'color-mix(in srgb, var(--accent) 15%, transparent)',
color: 'var(--accent)', borderRadius: 'var(--radius-sm)',
padding: '1px 7px', fontSize: 11, fontWeight: 500,
}}>
{genre}
<button
style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'inherit', padding: 0, lineHeight: 1, fontSize: 13 }}
onClick={() => setCustomGenreBlacklist(customGenreBlacklist.filter(g => g !== genre))}
>×</button>
</span>
))
)}
</div>
<div style={{ display: 'flex', gap: '0.4rem' }}>
<input
className="input"
type="text"
value={newGenre}
onChange={e => setNewGenre(e.target.value)}
onKeyDown={e => {
if (e.key === 'Enter' && newGenre.trim()) addCustomGenre(newGenre.trim());
}}
placeholder={t('settings.randomMixBlacklistPlaceholder')}
style={{ fontSize: 12 }}
/>
<button
className="btn btn-ghost"
style={{ fontSize: 12, padding: '4px 10px', flexShrink: 0 }}
onClick={() => addCustomGenre(newGenre.trim())}
disabled={!newGenre.trim()}
>{t('settings.randomMixBlacklistAdd')}</button>
</div>
</div>
)}
</div>
)}
</div>
);
}
@@ -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<React.SetStateAction<boolean>>;
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 (
<div style={{ background: 'var(--bg-card)', padding: '1rem 1.25rem' }}>
{isMobile ? (
<button
className="btn btn-ghost"
style={{ width: '100%', justifyContent: 'space-between', fontSize: 14, fontWeight: 700, textTransform: 'uppercase', letterSpacing: '0.05em', color: 'var(--text-secondary)', padding: '0' }}
onClick={() => setGenreMixExpanded(v => !v)}
>
{t('randomMix.genreMixTitle')}
{genreMixExpanded ? <ChevronUp size={14} /> : <ChevronDown size={14} />}
</button>
) : (
<div style={{ fontSize: 14, fontWeight: 700, textTransform: 'uppercase', letterSpacing: '0.05em', color: 'var(--text-secondary)', marginBottom: '0.85rem' }}>
{t('randomMix.genreMixTitle')}
</div>
)}
{(!isMobile || genreMixExpanded) && (
<div style={{ marginTop: isMobile ? '0.75rem' : 0 }}>
<p style={{ fontSize: 12, color: 'var(--text-muted)', marginBottom: '0.75rem' }}>{t('randomMix.genreMixDesc')}</p>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.4rem', alignItems: 'center' }}>
{serverGenresLength === 0 ? (
<div className="spinner" style={{ width: 14, height: 14 }} />
) : displayedGenres.length === 0 ? (
<span style={{ fontSize: 12, color: 'var(--text-muted)' }}>{t('randomMix.genreMixNoGenres')}</span>
) : (
<>
<button
className={`btn ${selectedGenre === null ? 'btn-primary' : 'btn-surface'}`}
style={{ fontSize: 12, padding: '4px 12px' }}
onClick={onSelectAll}
disabled={genreMixLoading}
>
{t('randomMix.genreMixAll')}
</button>
{displayedGenres.map(genre => (
<button
key={genre}
className={`btn ${selectedGenre === genre ? 'btn-primary' : 'btn-surface'}`}
style={{ fontSize: 12, padding: '4px 12px' }}
onClick={() => onSelectGenre(genre)}
disabled={genreMixLoading}
>
{genre}
</button>
))}
{allAvailableGenresLength > 20 && (
<button
className="btn btn-ghost"
style={{ fontSize: 12, padding: '4px 10px', flexShrink: 0 }}
onClick={onShuffle}
disabled={genreMixLoading}
data-tooltip={t('randomMix.shuffleGenres')}
>
<RefreshCw size={12} />
</button>
)}
</>
)}
</div>
</div>
)}
</div>
);
}
@@ -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 (
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '2rem' }}>
<h1 className="page-title">{t('randomMix.title')}</h1>
<div style={{ display: 'flex', gap: '0.5rem' }}>
<button
className="btn btn-surface"
onClick={onRefresh}
disabled={selectedGenre ? genreMixLoading : loading}
data-tooltip={selectedGenre
? t('randomMix.remixTooltipGenre', { genre: selectedGenre })
: t('randomMix.remixTooltip')
}
>
<RefreshCw size={18} className={(selectedGenre ? genreMixLoading : loading) ? 'spin' : ''} />
{selectedGenre ? t('randomMix.remixGenre', { genre: selectedGenre }) : t('randomMix.remix')}
</button>
<button
className={`btn ${isGenreLoading ? 'btn-surface' : 'btn-primary'}`}
onClick={onPlayAll}
disabled={isPlayDisabled}
>
{isGenreLoading ? (
<><div className="spinner" style={{ width: 14, height: 14, borderWidth: 2 }} /> {Math.min(genreMixSongsLength, randomMixSize)} / {randomMixSize}</>
) : (
<><Play size={18} fill="currentColor" /> {t('randomMix.playAll')}</>
)}
</button>
</div>
</div>
);
}
@@ -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<string, boolean>;
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 (
<div
className={`track-row track-row-with-actions${isCurrentTrack ? ' active' : ''}${isContextActive ? ' context-active' : ''}`}
style={{ gridTemplateColumns }}
onClick={e => {
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);
}}
>
<div className={`track-num${isCurrentTrack ? ' track-num-active' : ''}`}>
{isCurrentTrack && isPlaying ? (
<span className="track-num-eq"><AudioLines className="eq-bars" size={14} /></span>
) : (
<span className="track-num-number">{idx + 1}</span>
)}
</div>
<div className="track-info track-info-suggestion">
<button
type="button"
className="playlist-suggestion-play-btn"
onClick={e => { e.stopPropagation(); if (orbitActive) { onQueueHint(); return; } onPlay(); }}
data-tooltip={t('common.play')}
aria-label={t('common.play')}
>
<Play size={10} fill="currentColor" strokeWidth={0} className="playlist-suggestion-play-icon" />
</button>
<button
type="button"
className={`playlist-suggestion-preview-btn${previewingId === song.id ? ' is-previewing' : ''}${previewingId === song.id && previewAudioStarted ? ' audio-started' : ''}`}
onClick={e => {
e.stopPropagation();
usePreviewStore.getState().startPreview(
{ id: song.id, title: song.title, artist: song.artist, coverArt: song.coverArt, duration: song.duration },
'randomMix',
);
}}
data-tooltip={previewingId === song.id ? t('playlists.previewStop') : t('playlists.preview')}
aria-label={previewingId === song.id ? t('playlists.previewStop') : t('playlists.preview')}
>
<svg className="playlist-suggestion-preview-ring" viewBox="0 0 24 24" aria-hidden="true">
<circle cx="12" cy="12" r="10.5" className="playlist-suggestion-preview-ring-track" />
<circle cx="12" cy="12" r="10.5" className="playlist-suggestion-preview-ring-progress" />
</svg>
{previewingId === song.id
? <Square size={9} fill="currentColor" strokeWidth={0} className="playlist-suggestion-preview-icon" />
: <ChevronRight size={14} className="playlist-suggestion-preview-icon playlist-suggestion-preview-icon-play" />}
</button>
<span className="track-title">{song.title}</span>
</div>
<div className="track-artist-cell">
{artist ? (
<button
className={`rm-artist-btn${isArtistBlocked ? ' is-blocked' : isArtistJustAdded ? ' just-added' : ''}`}
onClick={() => { if (!isArtistBlocked) onBlacklistArtist(artist); }}
data-tooltip={isArtistBlocked ? t('randomMix.artistBlocked') : isArtistJustAdded ? t('randomMix.artistAddedToBlacklist') : t('randomMix.artistClickHint')}
>{artist}</button>
) : <span className="track-artist"></span>}
</div>
<div className="track-info">
<span className="track-title" style={{ fontSize: '0.85rem', color: 'var(--text-secondary)' }}>{song.album ?? '—'}</span>
</div>
{showGenreCol && (
<div>
{genre ? (
<button
className={`rm-genre-chip${isGenreBlocked ? ' is-blocked' : isGenreJustAdded ? ' just-added' : ''}`}
onClick={() => { if (!isGenreBlocked) onBlacklistGenre(genre); }}
data-tooltip={isGenreBlocked ? t('randomMix.genreBlocked') : isGenreJustAdded ? t('randomMix.genreAddedToBlacklist') : t('randomMix.genreClickHint')}
>{genre}</button>
) : <span style={{ fontSize: 12, color: 'var(--text-muted)' }}></span>}
</div>
)}
<div className="track-star-cell">
<button
className="btn btn-ghost track-star-btn"
onClick={onToggleStar}
data-tooltip={isStarred ? t('randomMix.favoriteRemove') : t('randomMix.favoriteAdd')}
style={{ color: starColor }}
>
<Heart size={14} fill={isStarred ? 'currentColor' : 'none'} />
</button>
</div>
<div className="track-duration">{formatRandomMixDuration(song.duration)}</div>
</div>
);
}