mirror of
https://github.com/kilyabin/psysonic.git
synced 2026-07-22 06:25:41 +00:00
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:
committed by
GitHub
parent
d4d3b0e53f
commit
40dd0bd100
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
+123
-468
@@ -1,37 +1,23 @@
|
|||||||
import { star, unstar } from '../api/subsonicStarRating';
|
import { star, unstar } from '../api/subsonicStarRating';
|
||||||
import { getGenres } from '../api/subsonicGenres';
|
import { getGenres } from '../api/subsonicGenres';
|
||||||
import type { SubsonicSong, SubsonicGenre } from '../api/subsonicTypes';
|
import type { SubsonicSong, SubsonicGenre } from '../api/subsonicTypes';
|
||||||
import { RANDOM_MIX_SIZE_OPTIONS } from '../store/authStoreDefaults';
|
|
||||||
import { songToTrack } from '../utils/songToTrack';
|
import { songToTrack } from '../utils/songToTrack';
|
||||||
import React, { useEffect, useMemo, useState } from 'react';
|
import React, { useEffect, useMemo, useState } from 'react';
|
||||||
import { usePlayerStore } from '../store/playerStore';
|
import { usePlayerStore } from '../store/playerStore';
|
||||||
import { usePreviewStore } from '../store/previewStore';
|
import { usePreviewStore } from '../store/previewStore';
|
||||||
import { useAuthStore } from '../store/authStore';
|
import { useAuthStore } from '../store/authStore';
|
||||||
import { Play, RefreshCw, ChevronDown, ChevronRight, ChevronUp, Heart, Square, AudioLines } from 'lucide-react';
|
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { useDragDrop } from '../contexts/DragDropContext';
|
|
||||||
import { useIsMobile } from '../hooks/useIsMobile';
|
import { useIsMobile } from '../hooks/useIsMobile';
|
||||||
import { useOrbitSongRowBehavior } from '../hooks/useOrbitSongRowBehavior';
|
import { useOrbitSongRowBehavior } from '../hooks/useOrbitSongRowBehavior';
|
||||||
import {
|
import {
|
||||||
fetchRandomMixSongsUntilFull,
|
fetchRandomMixSongsUntilFull,
|
||||||
getMixMinRatingsConfigFromAuth,
|
getMixMinRatingsConfigFromAuth,
|
||||||
passesMixMinRatings,
|
|
||||||
} from '../utils/mixRatingFilter';
|
} from '../utils/mixRatingFilter';
|
||||||
|
import { AUDIOBOOK_GENRES, filterRandomMixSongs, formatRandomMixDuration } from '../utils/randomMixHelpers';
|
||||||
const AUDIOBOOK_GENRES = [
|
import RandomMixHeader from '../components/randomMix/RandomMixHeader';
|
||||||
'hörbuch', 'hoerbuch', 'hörspiel', 'hoerspiel',
|
import RandomMixFiltersPanel from '../components/randomMix/RandomMixFiltersPanel';
|
||||||
'audiobook', 'audio book', 'spoken word', 'spokenword',
|
import RandomMixGenrePanel from '../components/randomMix/RandomMixGenrePanel';
|
||||||
'podcast', 'kapitel', 'thriller', 'krimi', 'speech',
|
import RandomMixTrackRow from '../components/randomMix/RandomMixTrackRow';
|
||||||
'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() {
|
export default function RandomMix() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
@@ -49,7 +35,6 @@ export default function RandomMix() {
|
|||||||
const setStarredOverride = usePlayerStore(s => s.setStarredOverride);
|
const setStarredOverride = usePlayerStore(s => s.setStarredOverride);
|
||||||
const [contextMenuSongId, setContextMenuSongId] = useState<string | null>(null);
|
const [contextMenuSongId, setContextMenuSongId] = useState<string | null>(null);
|
||||||
const isMobile = useIsMobile();
|
const isMobile = useIsMobile();
|
||||||
const psyDrag = useDragDrop();
|
|
||||||
const [starredSongs, setStarredSongs] = useState<Set<string>>(new Set());
|
const [starredSongs, setStarredSongs] = useState<Set<string>>(new Set());
|
||||||
const {
|
const {
|
||||||
excludeAudiobooks,
|
excludeAudiobooks,
|
||||||
@@ -126,21 +111,7 @@ export default function RandomMix() {
|
|||||||
}).catch(() => {});
|
}).catch(() => {});
|
||||||
}, [musicLibraryFilterVersion]);
|
}, [musicLibraryFilterVersion]);
|
||||||
|
|
||||||
const filteredSongs = songs.filter(song => {
|
const filteredSongs = filterRandomMixSongs(songs, { excludeAudiobooks, customGenreBlacklist, mixRatingCfg });
|
||||||
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 = () => {
|
const handlePlayAll = () => {
|
||||||
if (selectedGenre && genreMixSongs.length > 0) {
|
if (selectedGenre && genreMixSongs.length > 0) {
|
||||||
@@ -196,41 +167,17 @@ export default function RandomMix() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="content-body animate-fade-in">
|
<div className="content-body animate-fade-in">
|
||||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '2rem' }}>
|
<RandomMixHeader
|
||||||
<h1 className="page-title">{t('randomMix.title')}</h1>
|
selectedGenre={selectedGenre}
|
||||||
|
loading={loading}
|
||||||
<div style={{ display: 'flex', gap: '0.5rem' }}>
|
genreMixLoading={genreMixLoading}
|
||||||
<button
|
genreMixComplete={genreMixComplete}
|
||||||
className="btn btn-surface"
|
genreMixSongsLength={genreMixSongs.length}
|
||||||
onClick={selectedGenre ? () => loadGenreMix(selectedGenre) : () => fetchSongs()}
|
filteredSongsLength={filteredSongs.length}
|
||||||
disabled={selectedGenre ? genreMixLoading : loading}
|
randomMixSize={randomMixSize}
|
||||||
data-tooltip={selectedGenre
|
onRefresh={selectedGenre ? () => loadGenreMix(selectedGenre) : () => fetchSongs()}
|
||||||
? t('randomMix.remixTooltipGenre', { genre: selectedGenre })
|
onPlayAll={handlePlayAll}
|
||||||
: t('randomMix.remixTooltip')
|
/>
|
||||||
}
|
|
||||||
>
|
|
||||||
<RefreshCw size={18} className={(selectedGenre ? genreMixLoading : loading) ? 'spin' : ''} />
|
|
||||||
{selectedGenre ? t('randomMix.remixGenre', { genre: selectedGenre }) : t('randomMix.remix')}
|
|
||||||
</button>
|
|
||||||
{(() => {
|
|
||||||
const isGenreLoading = selectedGenre && !genreMixComplete;
|
|
||||||
const isDisabled = loading || (selectedGenre ? !genreMixComplete || genreMixSongs.length === 0 : filteredSongs.length === 0);
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
className={`btn ${isGenreLoading ? 'btn-surface' : 'btn-primary'}`}
|
|
||||||
onClick={handlePlayAll}
|
|
||||||
disabled={isDisabled}
|
|
||||||
>
|
|
||||||
{isGenreLoading ? (
|
|
||||||
<><div className="spinner" style={{ width: 14, height: 14, borderWidth: 2 }} /> {Math.min(genreMixSongs.length, randomMixSize)} / {randomMixSize}</>
|
|
||||||
) : (
|
|
||||||
<><Play size={18} fill="currentColor" /> {t('randomMix.playAll')}</>
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
})()}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* ── Filter + Genre Mix panel ─────────────────────────────── */}
|
{/* ── Filter + Genre Mix panel ─────────────────────────────── */}
|
||||||
<div style={{
|
<div style={{
|
||||||
@@ -243,201 +190,38 @@ export default function RandomMix() {
|
|||||||
marginBottom: '2rem',
|
marginBottom: '2rem',
|
||||||
overflow: 'hidden',
|
overflow: 'hidden',
|
||||||
}}>
|
}}>
|
||||||
{/* Left: Blacklist */}
|
<RandomMixFiltersPanel
|
||||||
<div style={{ background: 'var(--bg-card)', padding: '1rem 1.25rem' }}>
|
isMobile={isMobile}
|
||||||
{isMobile ? (
|
filtersExpanded={filtersExpanded}
|
||||||
<button
|
setFiltersExpanded={setFiltersExpanded}
|
||||||
className="btn btn-ghost"
|
randomMixSize={randomMixSize}
|
||||||
style={{ width: '100%', justifyContent: 'space-between', fontSize: 14, fontWeight: 700, textTransform: 'uppercase', letterSpacing: '0.05em', color: 'var(--text-secondary)', padding: '0' }}
|
setRandomMixSize={setRandomMixSize}
|
||||||
onClick={() => setFiltersExpanded(v => !v)}
|
selectedGenre={selectedGenre}
|
||||||
>
|
loadGenreMix={loadGenreMix}
|
||||||
{t('randomMix.filterPanelTitle')}
|
fetchSongs={fetchSongs}
|
||||||
{filtersExpanded ? <ChevronUp size={14} /> : <ChevronDown size={14} />}
|
excludeAudiobooks={excludeAudiobooks}
|
||||||
</button>
|
setExcludeAudiobooks={setExcludeAudiobooks}
|
||||||
) : (
|
blacklistOpen={blacklistOpen}
|
||||||
<div style={{ fontSize: 14, fontWeight: 700, textTransform: 'uppercase', letterSpacing: '0.05em', color: 'var(--text-secondary)', marginBottom: '0.85rem' }}>
|
setBlacklistOpen={setBlacklistOpen}
|
||||||
{t('randomMix.filterPanelTitle')}
|
customGenreBlacklist={customGenreBlacklist}
|
||||||
</div>
|
setCustomGenreBlacklist={setCustomGenreBlacklist}
|
||||||
)}
|
newGenre={newGenre}
|
||||||
{(!isMobile || filtersExpanded) && (
|
setNewGenre={setNewGenre}
|
||||||
<div style={{ marginTop: isMobile ? '0.75rem' : 0 }}>
|
/>
|
||||||
{/* ── Mix Settings ── */}
|
|
||||||
<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' }}>
|
<RandomMixGenrePanel
|
||||||
{t('randomMix.filterPanelInexactSizeNote')}
|
isMobile={isMobile}
|
||||||
</p>
|
genreMixExpanded={genreMixExpanded}
|
||||||
|
setGenreMixExpanded={setGenreMixExpanded}
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', flexWrap: 'wrap', marginBottom: '0.25rem' }}>
|
serverGenresLength={serverGenres.length}
|
||||||
<span style={{ fontSize: 12, color: 'var(--text-muted)' }}>{t('randomMix.mixSize')}</span>
|
displayedGenres={displayedGenres}
|
||||||
{RANDOM_MIX_SIZE_OPTIONS.map(n => (
|
allAvailableGenresLength={allAvailableGenres.length}
|
||||||
<button
|
selectedGenre={selectedGenre}
|
||||||
key={n}
|
genreMixLoading={genreMixLoading}
|
||||||
className={`btn ${randomMixSize === n ? 'btn-primary' : 'btn-surface'}`}
|
onSelectAll={() => { setSelectedGenre(null); setGenreMixSongs([]); setGenreMixComplete(false); fetchSongs(); }}
|
||||||
style={{ fontSize: 12, padding: '3px 10px' }}
|
onSelectGenre={genre => { setSelectedGenre(genre); loadGenreMix(genre); }}
|
||||||
onClick={() => {
|
onShuffle={shuffleDisplayedGenres}
|
||||||
if (n === randomMixSize) return;
|
/>
|
||||||
setRandomMixSize(n);
|
|
||||||
if (selectedGenre) loadGenreMix(selectedGenre, n);
|
|
||||||
else fetchSongs(n);
|
|
||||||
}}
|
|
||||||
>{n}</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* ── divider ── */}
|
|
||||||
<div style={{ borderTop: '1px solid var(--border)', margin: '0.85rem 0' }} />
|
|
||||||
|
|
||||||
{/* ── Exclusions ── */}
|
|
||||||
<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()) {
|
|
||||||
const trimmed = newGenre.trim();
|
|
||||||
if (!customGenreBlacklist.includes(trimmed)) setCustomGenreBlacklist([...customGenreBlacklist, trimmed]);
|
|
||||||
setNewGenre('');
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
placeholder={t('settings.randomMixBlacklistPlaceholder')}
|
|
||||||
style={{ fontSize: 12 }}
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
className="btn btn-ghost"
|
|
||||||
style={{ fontSize: 12, padding: '4px 10px', flexShrink: 0 }}
|
|
||||||
onClick={() => {
|
|
||||||
const trimmed = newGenre.trim();
|
|
||||||
if (trimmed && !customGenreBlacklist.includes(trimmed)) setCustomGenreBlacklist([...customGenreBlacklist, trimmed]);
|
|
||||||
setNewGenre('');
|
|
||||||
}}
|
|
||||||
disabled={!newGenre.trim()}
|
|
||||||
>{t('settings.randomMixBlacklistAdd')}</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Right: Genre Mix */}
|
|
||||||
<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' }}>
|
|
||||||
{serverGenres.length === 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={() => { setSelectedGenre(null); setGenreMixSongs([]); setGenreMixComplete(false); fetchSongs(); }}
|
|
||||||
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={() => { setSelectedGenre(genre); loadGenreMix(genre); }}
|
|
||||||
disabled={genreMixLoading}
|
|
||||||
>
|
|
||||||
{genre}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
{allAvailableGenres.length > 20 && (
|
|
||||||
<button
|
|
||||||
className="btn btn-ghost"
|
|
||||||
style={{ fontSize: 12, padding: '4px 10px', flexShrink: 0 }}
|
|
||||||
onClick={shuffleDisplayedGenres}
|
|
||||||
disabled={genreMixLoading}
|
|
||||||
data-tooltip={t('randomMix.shuffleGenres')}
|
|
||||||
>
|
|
||||||
<RefreshCw size={12} />
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Genre Mix tracklist (shown when a genre is selected) */}
|
{/* Genre Mix tracklist (shown when a genre is selected) */}
|
||||||
@@ -464,100 +248,46 @@ export default function RandomMix() {
|
|||||||
{genreMixSongs.map((song, idx) => {
|
{genreMixSongs.map((song, idx) => {
|
||||||
const track = songToTrack(song);
|
const track = songToTrack(song);
|
||||||
const queueSongs = genreMixSongs.map(songToTrack);
|
const queueSongs = genreMixSongs.map(songToTrack);
|
||||||
const isCurrentTrack = currentTrack?.id === song.id;
|
const isStarred = song.id in starredOverrides ? starredOverrides[song.id] : starredSongs.has(song.id);
|
||||||
const artist = song.artist;
|
|
||||||
const isArtistBlocked = !!artist && customGenreBlacklist.some(bg => artist.toLowerCase().includes(bg.toLowerCase()));
|
|
||||||
const isArtistJustAdded = addedArtist === artist;
|
|
||||||
return (
|
return (
|
||||||
<div
|
<RandomMixTrackRow
|
||||||
key={song.id}
|
key={song.id}
|
||||||
className={`track-row track-row-with-actions${isCurrentTrack ? ' active' : ''}${contextMenuSongId === song.id ? ' context-active' : ''}`}
|
song={song}
|
||||||
style={{ gridTemplateColumns: '60px minmax(150px, 1fr) minmax(80px, 1fr) minmax(80px, 1fr) 70px 65px' }}
|
idx={idx}
|
||||||
onClick={e => { if ((e.target as HTMLElement).closest('button, a, input')) return; if (orbitActive) { queueHint(); return; } playTrack(track, queueSongs); }}
|
gridTemplateColumns="60px minmax(150px, 1fr) minmax(80px, 1fr) minmax(80px, 1fr) 70px 65px"
|
||||||
onDoubleClick={orbitActive ? e => { if ((e.target as HTMLElement).closest('button, a, input')) return; addTrackToOrbit(song.id); } : undefined}
|
track={track}
|
||||||
role="row"
|
queueSongs={queueSongs}
|
||||||
onContextMenu={e => { e.preventDefault(); setContextMenuSongId(song.id); openContextMenu(e.clientX, e.clientY, track, 'song'); }}
|
isCurrentTrack={currentTrack?.id === song.id}
|
||||||
onMouseDown={e => {
|
isPlaying={isPlaying}
|
||||||
if (e.button !== 0) return;
|
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();
|
e.preventDefault();
|
||||||
const sx = e.clientX, sy = e.clientY;
|
setContextMenuSongId(song.id);
|
||||||
const onMove = (me: MouseEvent) => {
|
openContextMenu(e.clientX, e.clientY, track, 'song');
|
||||||
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)}
|
||||||
<div className={`track-num${isCurrentTrack ? ' track-num-active' : ''}`}>
|
onBlacklistArtist={artist => {
|
||||||
{isCurrentTrack && isPlaying ? (
|
if (!customGenreBlacklist.some(bg => artist.toLowerCase().includes(bg.toLowerCase()))) {
|
||||||
<span className="track-num-eq"><AudioLines className="eq-bars" size={14} /></span>
|
setCustomGenreBlacklist([...customGenreBlacklist, artist]);
|
||||||
) : (
|
setAddedArtist(artist);
|
||||||
<span className="track-num-number">{idx + 1}</span>
|
setTimeout(() => setAddedArtist(null), 1500);
|
||||||
)}
|
}
|
||||||
</div>
|
}}
|
||||||
<div className="track-info track-info-suggestion">
|
onBlacklistGenre={() => {}}
|
||||||
<button
|
/>
|
||||||
type="button"
|
|
||||||
className="playlist-suggestion-play-btn"
|
|
||||||
onClick={e => { e.stopPropagation(); if (orbitActive) { queueHint(); return; } playTrack(track, queueSongs); }}
|
|
||||||
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) return;
|
|
||||||
if (!customGenreBlacklist.some(bg => artist.toLowerCase().includes(bg.toLowerCase()))) {
|
|
||||||
setCustomGenreBlacklist([...customGenreBlacklist, artist]);
|
|
||||||
setAddedArtist(artist);
|
|
||||||
setTimeout(() => setAddedArtist(null), 1500);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
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>
|
|
||||||
<div className="track-star-cell">
|
|
||||||
<button
|
|
||||||
className="btn btn-ghost track-star-btn"
|
|
||||||
onClick={e => toggleSongStar(song, e)}
|
|
||||||
data-tooltip={(song.id in starredOverrides ? starredOverrides[song.id] : starredSongs.has(song.id)) ? t('randomMix.favoriteRemove') : t('randomMix.favoriteAdd')}
|
|
||||||
style={{ color: (song.id in starredOverrides ? starredOverrides[song.id] : starredSongs.has(song.id)) ? 'var(--color-star-active, var(--accent))' : 'var(--color-star-inactive, var(--text-muted))' }}
|
|
||||||
>
|
|
||||||
<Heart size={14} fill={(song.id in starredOverrides ? starredOverrides[song.id] : starredSongs.has(song.id)) ? 'currentColor' : 'none'} />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div className="track-duration">{formatDuration(song.duration)}</div>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
@@ -586,132 +316,57 @@ export default function RandomMix() {
|
|||||||
{filteredSongs.map((song, idx) => {
|
{filteredSongs.map((song, idx) => {
|
||||||
const track = songToTrack(song);
|
const track = songToTrack(song);
|
||||||
const queueSongs = filteredSongs.map(songToTrack);
|
const queueSongs = filteredSongs.map(songToTrack);
|
||||||
const isCurrentTrack = currentTrack?.id === song.id;
|
|
||||||
const artist = song.artist;
|
|
||||||
const genre = song.genre;
|
const genre = song.genre;
|
||||||
const isArtistBlocked = !!artist && customGenreBlacklist.some(bg => artist.toLowerCase().includes(bg.toLowerCase()));
|
const isStarred = song.id in starredOverrides ? starredOverrides[song.id] : starredSongs.has(song.id);
|
||||||
const isArtistJustAdded = addedArtist === artist;
|
|
||||||
const isGenreBlocked = !!genre && (
|
const isGenreBlocked = !!genre && (
|
||||||
AUDIOBOOK_GENRES.some(ag => genre.toLowerCase().includes(ag)) ||
|
AUDIOBOOK_GENRES.some(ag => genre.toLowerCase().includes(ag)) ||
|
||||||
customGenreBlacklist.some(bg => genre.toLowerCase().includes(bg.toLowerCase()))
|
customGenreBlacklist.some(bg => genre.toLowerCase().includes(bg.toLowerCase()))
|
||||||
);
|
);
|
||||||
const isGenreJustAdded = addedGenre === genre;
|
|
||||||
return (
|
return (
|
||||||
<div
|
<RandomMixTrackRow
|
||||||
key={song.id}
|
key={song.id}
|
||||||
className={`track-row track-row-with-actions${isCurrentTrack ? ' active' : ''}${contextMenuSongId === song.id ? ' context-active' : ''}`}
|
song={song}
|
||||||
style={{ gridTemplateColumns: '60px minmax(150px, 1fr) minmax(80px, 1fr) minmax(80px, 1fr) 120px 70px 65px' }}
|
idx={idx}
|
||||||
onClick={e => { if ((e.target as HTMLElement).closest('button, a, input')) return; if (orbitActive) { queueHint(); return; } playTrack(track, queueSongs); }}
|
gridTemplateColumns="60px minmax(150px, 1fr) minmax(80px, 1fr) minmax(80px, 1fr) 120px 70px 65px"
|
||||||
onDoubleClick={orbitActive ? e => { if ((e.target as HTMLElement).closest('button, a, input')) return; addTrackToOrbit(song.id); } : undefined}
|
track={track}
|
||||||
role="row"
|
queueSongs={queueSongs}
|
||||||
onContextMenu={e => {
|
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();
|
e.preventDefault();
|
||||||
setContextMenuSongId(song.id);
|
setContextMenuSongId(song.id);
|
||||||
openContextMenu(e.clientX, e.clientY, track, 'song');
|
openContextMenu(e.clientX, e.clientY, track, 'song');
|
||||||
}}
|
}}
|
||||||
onMouseDown={e => {
|
onToggleStar={e => toggleSongStar(song, e)}
|
||||||
if (e.button !== 0) return;
|
onBlacklistArtist={artist => {
|
||||||
e.preventDefault();
|
if (!customGenreBlacklist.some(bg => artist.toLowerCase().includes(bg.toLowerCase()))) {
|
||||||
const sx = e.clientX, sy = e.clientY;
|
setCustomGenreBlacklist([...customGenreBlacklist, artist]);
|
||||||
const onMove = (me: MouseEvent) => {
|
setAddedArtist(artist);
|
||||||
if (Math.abs(me.clientX - sx) > 5 || Math.abs(me.clientY - sy) > 5) {
|
setTimeout(() => setAddedArtist(null), 1500);
|
||||||
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);
|
|
||||||
}}
|
}}
|
||||||
>
|
onBlacklistGenre={g => {
|
||||||
<div className={`track-num${isCurrentTrack ? ' track-num-active' : ''}`}>
|
if (!customGenreBlacklist.some(bg => g.toLowerCase().includes(bg.toLowerCase()))) {
|
||||||
{isCurrentTrack && isPlaying ? (
|
setCustomGenreBlacklist([...customGenreBlacklist, g]);
|
||||||
<span className="track-num-eq"><AudioLines className="eq-bars" size={14} /></span>
|
setAddedGenre(g);
|
||||||
) : (
|
setTimeout(() => setAddedGenre(null), 1500);
|
||||||
<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) { queueHint(); return; } playTrack(track, queueSongs); }}
|
|
||||||
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) return;
|
|
||||||
if (!customGenreBlacklist.some(bg => artist.toLowerCase().includes(bg.toLowerCase()))) {
|
|
||||||
setCustomGenreBlacklist([...customGenreBlacklist, artist]);
|
|
||||||
setAddedArtist(artist);
|
|
||||||
setTimeout(() => setAddedArtist(null), 1500);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
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>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
{genre ? (
|
|
||||||
<button
|
|
||||||
className={`rm-genre-chip${isGenreBlocked ? ' is-blocked' : isGenreJustAdded ? ' just-added' : ''}`}
|
|
||||||
onClick={() => {
|
|
||||||
if (isGenreBlocked) return;
|
|
||||||
if (!customGenreBlacklist.some(bg => genre.toLowerCase().includes(bg.toLowerCase()))) {
|
|
||||||
setCustomGenreBlacklist([...customGenreBlacklist, genre]);
|
|
||||||
setAddedGenre(genre);
|
|
||||||
setTimeout(() => setAddedGenre(null), 1500);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
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={e => toggleSongStar(song, e)}
|
|
||||||
data-tooltip={(song.id in starredOverrides ? starredOverrides[song.id] : starredSongs.has(song.id)) ? t('randomMix.favoriteRemove') : t('randomMix.favoriteAdd')}
|
|
||||||
style={{ color: (song.id in starredOverrides ? starredOverrides[song.id] : starredSongs.has(song.id)) ? 'var(--color-star-active, var(--accent))' : 'var(--color-star-inactive, var(--text-muted))' }}
|
|
||||||
>
|
|
||||||
<Heart size={14} fill={(song.id in starredOverrides ? starredOverrides[song.id] : starredSongs.has(song.id)) ? 'currentColor' : 'none'} />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="track-duration">{formatDuration(song.duration)}</div>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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;
|
||||||
|
});
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user