mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-21 23:05:46 +00:00
refactor(randomMix): co-locate random/lucky-mix feature into features/randomMix
This commit is contained in:
@@ -4,7 +4,7 @@ import { Play } from 'lucide-react';
|
||||
import type { TFunction } from 'i18next';
|
||||
import OverlayScrollArea from '@/ui/OverlayScrollArea';
|
||||
import { usePlayerStore } from '@/features/playback/store/playerStore';
|
||||
import { useLuckyMixStore } from '@/store/luckyMixStore';
|
||||
import { useLuckyMixStore } from '@/features/randomMix';
|
||||
import type { QueueItemRef } from '@/lib/media/trackTypes';
|
||||
import type { PlayerState } from '@/features/playback/store/playerStoreTypes';
|
||||
import type { QueueDisplayMode } from '@/store/authStoreTypes';
|
||||
|
||||
@@ -20,7 +20,7 @@ import { useThemeStore } from '@/store/themeStore';
|
||||
import { useLyricsStore } from '@/store/lyricsStore';
|
||||
import LyricsPane from '@/components/LyricsPane';
|
||||
import { NowPlayingInfo } from '@/features/nowPlaying';
|
||||
import { useLuckyMixStore } from '@/store/luckyMixStore';
|
||||
import { useLuckyMixStore } from '@/features/randomMix';
|
||||
import { useQueueToolbarStore } from '@/store/queueToolbarStore';
|
||||
import { SavePlaylistModal } from '@/features/queue/components/SavePlaylistModal';
|
||||
import { LoadPlaylistModal } from '@/features/queue/components/LoadPlaylistModal';
|
||||
|
||||
@@ -0,0 +1,170 @@
|
||||
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>
|
||||
|
||||
<div style={{ display: 'flex', alignItems: 'flex-start', gap: '0.5rem', fontSize: 13, marginBottom: '0.6rem' }}>
|
||||
<input
|
||||
id="random-mix-exclude-audiobooks"
|
||||
type="checkbox"
|
||||
checked={excludeAudiobooks}
|
||||
onChange={e => setExcludeAudiobooks(e.target.checked)}
|
||||
style={{ marginTop: 2, flexShrink: 0, cursor: 'pointer' }}
|
||||
/>
|
||||
<div>
|
||||
<label
|
||||
htmlFor="random-mix-exclude-audiobooks"
|
||||
style={{ fontWeight: 500, color: 'var(--text-primary)', cursor: 'pointer', display: 'inline-block' }}
|
||||
>
|
||||
{t('randomMix.excludeAudiobooks')}
|
||||
</label>
|
||||
<div style={{ fontSize: 11, color: 'var(--text-muted)', marginTop: 2 }}>{t('randomMix.excludeAudiobooksDesc')}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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,90 @@
|
||||
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>>;
|
||||
genresLoading: 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,
|
||||
genresLoading, 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' }}>
|
||||
{genresLoading ? (
|
||||
<div className="spinner" style={{ width: 14, height: 14 }} />
|
||||
) : serverGenresLength === 0 || 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,61 @@
|
||||
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 className="compact-action-bar" style={{ display: 'flex', gap: '0.5rem' }}>
|
||||
<button
|
||||
className="btn btn-surface"
|
||||
onClick={onRefresh}
|
||||
disabled={selectedGenre ? genreMixLoading : loading}
|
||||
aria-label={selectedGenre ? t('randomMix.remixGenre', { genre: selectedGenre }) : t('randomMix.remix')}
|
||||
data-tooltip={selectedGenre
|
||||
? t('randomMix.remixTooltipGenre', { genre: selectedGenre })
|
||||
: t('randomMix.remixTooltip')
|
||||
}
|
||||
>
|
||||
<RefreshCw size={18} className={(selectedGenre ? genreMixLoading : loading) ? 'spin' : ''} />
|
||||
<span className="compact-btn-label">{selectedGenre ? t('randomMix.remixGenre', { genre: selectedGenre }) : t('randomMix.remix')}</span>
|
||||
</button>
|
||||
<button
|
||||
className={`btn ${isGenreLoading ? 'btn-surface' : 'btn-primary'}`}
|
||||
onClick={onPlayAll}
|
||||
disabled={isPlayDisabled}
|
||||
aria-label={t('randomMix.playAll')}
|
||||
data-tooltip={t('randomMix.playAll')}
|
||||
>
|
||||
{isGenreLoading ? (
|
||||
<><div className="spinner" style={{ width: 14, height: 14, borderWidth: 2 }} /> <span className="compact-btn-label">{Math.min(genreMixSongsLength, randomMixSize)} / {randomMixSize}</span></>
|
||||
) : (
|
||||
<><Play size={18} fill="currentColor" /> <span className="compact-btn-label">{t('randomMix.playAll')}</span></>
|
||||
)}
|
||||
</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 '@/lib/api/subsonicTypes';
|
||||
import type { Track } from '@/lib/media/trackTypes';
|
||||
import { previewInputFromSong, usePreviewStore } from '@/features/playback/store/previewStore';
|
||||
import { useDragDrop } from '@/lib/dnd/DragDropContext';
|
||||
import { formatRandomMixDuration } from '@/features/randomMix/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,
|
||||
isCurrentTrack, isPlaying, isContextActive, orbitActive,
|
||||
previewingId, previewAudioStarted, 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(
|
||||
previewInputFromSong(song),
|
||||
'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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import { useAuthStore } from '@/store/authStore';
|
||||
|
||||
/**
|
||||
* Whether "Lucky Mix" should be exposed as a navigable menu/card entry.
|
||||
*
|
||||
* Single source of truth for the gate — previously this logic was inlined in
|
||||
* Sidebar, MobileMoreOverlay, RandomLanding, Settings/SidebarCustomizer, and
|
||||
* the sidebarNavReorder filter. All call sites share the same three-way
|
||||
* predicate:
|
||||
* 1. User hasn't hidden it via the Settings toggle.
|
||||
* 2. AudioMuse is enabled for the active server (feature depends on
|
||||
* audiomuse-backed similar-track quality).
|
||||
* 3. An active server exists at all.
|
||||
*
|
||||
* Callers that additionally care about the "split vs hub" navigation mode
|
||||
* should combine this with `randomNavMode === 'separate'` explicitly — that's
|
||||
* an orthogonal UI placement concern, not an availability concern.
|
||||
*/
|
||||
export function isLuckyMixAvailable(args: {
|
||||
activeServerId: string | null | undefined;
|
||||
audiomuseByServer: Record<string, boolean>;
|
||||
showLuckyMixMenu: boolean;
|
||||
}): boolean {
|
||||
const { activeServerId, audiomuseByServer, showLuckyMixMenu } = args;
|
||||
if (!showLuckyMixMenu) return false;
|
||||
if (!activeServerId) return false;
|
||||
return Boolean(audiomuseByServer[activeServerId]);
|
||||
}
|
||||
|
||||
/**
|
||||
* React hook form — subscribes to the three authStore slices the predicate
|
||||
* depends on, so any user-facing change (toggle flip, server switch, AudioMuse
|
||||
* toggle on/off) re-renders the caller automatically.
|
||||
*/
|
||||
export function useLuckyMixAvailable(): boolean {
|
||||
const activeServerId = useAuthStore(s => s.activeServerId);
|
||||
const audiomuseByServer = useAuthStore(s => s.audiomuseNavidromeByServer);
|
||||
const showLuckyMixMenu = useAuthStore(s => s.showLuckyMixMenu);
|
||||
return isLuckyMixAvailable({ activeServerId, audiomuseByServer, showLuckyMixMenu });
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
/**
|
||||
* Random / Lucky Mix feature — the random landing, random-mix browser, and
|
||||
* lucky-mix trigger pages, the random-mix panels (filters/genre/header/row),
|
||||
* the lucky-mix availability hook + session store, and the lucky-mix /
|
||||
* random-mix queue-build helpers. The pages are lazy-loaded by the router via
|
||||
* their deep paths, so they are not re-exported here.
|
||||
*
|
||||
* Stays OUT (shared infra, consumed by the playback core too, not owned):
|
||||
* `utils/mix/mixRatingFilter` (a generic rating-window filter used by the
|
||||
* infinite-queue builder + home/album/CLI → a `lib/` candidate).
|
||||
*/
|
||||
export { useLuckyMixAvailable, isLuckyMixAvailable } from './hooks/useLuckyMixAvailable';
|
||||
export { useLuckyMixStore } from './store/luckyMixStore';
|
||||
@@ -0,0 +1,17 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { buildAndPlayLuckyMix } from '@/features/randomMix/utils/luckyMix';
|
||||
|
||||
export default function LuckyMixPage() {
|
||||
const navigate = useNavigate();
|
||||
const startedRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (startedRef.current) return;
|
||||
startedRef.current = true;
|
||||
void buildAndPlayLuckyMix();
|
||||
navigate('/now-playing', { replace: true });
|
||||
}, [navigate]);
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import React from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Shuffle, Dices, Sparkles } from 'lucide-react';
|
||||
import { useLuckyMixAvailable } from '@/features/randomMix/hooks/useLuckyMixAvailable';
|
||||
|
||||
interface MixCard {
|
||||
icon: React.ElementType;
|
||||
labelKey: string;
|
||||
descKey: string;
|
||||
to: string;
|
||||
}
|
||||
|
||||
const CARDS: MixCard[] = [
|
||||
{
|
||||
icon: Shuffle,
|
||||
labelKey: 'randomLanding.mixByTracks',
|
||||
descKey: 'randomLanding.mixByTracksDesc',
|
||||
to: '/random/mix',
|
||||
},
|
||||
{
|
||||
icon: Dices,
|
||||
labelKey: 'randomLanding.mixByAlbums',
|
||||
descKey: 'randomLanding.mixByAlbumsDesc',
|
||||
to: '/random/albums',
|
||||
},
|
||||
];
|
||||
|
||||
export default function RandomLanding() {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
// RandomLanding is only reachable in "hub" nav mode, so we don't need to
|
||||
// gate on randomNavMode here — availability alone is enough.
|
||||
const luckyMixAvailable = useLuckyMixAvailable();
|
||||
const cards = luckyMixAvailable
|
||||
? [
|
||||
...CARDS,
|
||||
{
|
||||
icon: Sparkles,
|
||||
labelKey: 'randomLanding.mixByLucky',
|
||||
descKey: 'randomLanding.mixByLuckyDesc',
|
||||
to: '/lucky-mix',
|
||||
},
|
||||
]
|
||||
: CARDS;
|
||||
|
||||
return (
|
||||
<div className="random-landing">
|
||||
<div className="random-landing-grid">
|
||||
{cards.map(({ icon: Icon, labelKey, descKey, to }) => (
|
||||
<button
|
||||
key={to}
|
||||
className="mix-pick-card"
|
||||
onClick={() => navigate(to)}
|
||||
>
|
||||
<Icon className="mix-pick-card-bg-icon" strokeWidth={1} aria-hidden />
|
||||
<div className="mix-pick-card-content">
|
||||
<Icon size={28} strokeWidth={1.5} className="mix-pick-card-icon" aria-hidden />
|
||||
<span className="mix-pick-card-label">{t(labelKey)}</span>
|
||||
<span className="mix-pick-card-desc">{t(descKey)}</span>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,405 @@
|
||||
import { queueSongStar } from '@/features/playback/store/pendingStarSync';
|
||||
import type { SubsonicSong, SubsonicGenre } from '@/lib/api/subsonicTypes';
|
||||
import { songToTrack } from '@/lib/media/songToTrack';
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import { usePlayerStore } from '@/features/playback/store/playerStore';
|
||||
import { usePreviewStore } from '@/features/playback/store/previewStore';
|
||||
import { useAuthStore } from '@/store/authStore';
|
||||
import { useLibraryIndexStore } from '@/store/libraryIndexStore';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useIsMobile } from '@/lib/hooks/useIsMobile';
|
||||
import { useOrbitSongRowBehavior } from '@/features/orbit';
|
||||
import {
|
||||
fetchRandomMixSongsUntilFull,
|
||||
getMixMinRatingsConfigFromAuth,
|
||||
} from '@/utils/mix/mixRatingFilter';
|
||||
import { fetchGenreCatalog } from '@/features/playback/utils/playback/genreBrowsePlayback';
|
||||
import { AUDIOBOOK_GENRES, filterRandomMixSongs } from '@/features/randomMix/utils/randomMixHelpers';
|
||||
import RandomMixHeader from '@/features/randomMix/components/RandomMixHeader';
|
||||
import RandomMixFiltersPanel from '@/features/randomMix/components/RandomMixFiltersPanel';
|
||||
import RandomMixGenrePanel from '@/features/randomMix/components/RandomMixGenrePanel';
|
||||
import RandomMixTrackRow from '@/features/randomMix/components/RandomMixTrackRow';
|
||||
|
||||
export default function RandomMix() {
|
||||
const { t } = useTranslation();
|
||||
const [songs, setSongs] = useState<SubsonicSong[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const playTrack = usePlayerStore(s => s.playTrack);
|
||||
const { orbitActive, queueHint, addTrackToOrbit } = useOrbitSongRowBehavior();
|
||||
const openContextMenu = usePlayerStore(s => s.openContextMenu);
|
||||
const contextMenuOpen = usePlayerStore(s => s.contextMenu.isOpen);
|
||||
const currentTrack = usePlayerStore(s => s.currentTrack);
|
||||
const isPlaying = usePlayerStore(s => s.isPlaying);
|
||||
const previewingId = usePreviewStore(s => s.previewingId);
|
||||
const previewAudioStarted = usePreviewStore(s => s.audioStarted);
|
||||
const starredOverrides = usePlayerStore(s => s.starredOverrides);
|
||||
const [contextMenuSongId, setContextMenuSongId] = useState<string | null>(null);
|
||||
const isMobile = useIsMobile();
|
||||
const [starredSongs, setStarredSongs] = useState<Set<string>>(new Set());
|
||||
const {
|
||||
excludeAudiobooks,
|
||||
setExcludeAudiobooks,
|
||||
customGenreBlacklist,
|
||||
setCustomGenreBlacklist,
|
||||
mixMinRatingFilterEnabled,
|
||||
mixMinRatingSong,
|
||||
mixMinRatingAlbum,
|
||||
mixMinRatingArtist,
|
||||
randomMixSize,
|
||||
setRandomMixSize,
|
||||
} = useAuthStore();
|
||||
|
||||
const mixRatingCfg = useMemo(
|
||||
() => ({
|
||||
enabled: mixMinRatingFilterEnabled,
|
||||
minSong: mixMinRatingSong,
|
||||
minAlbum: mixMinRatingAlbum,
|
||||
minArtist: mixMinRatingArtist,
|
||||
}),
|
||||
[mixMinRatingFilterEnabled, mixMinRatingSong, mixMinRatingAlbum, mixMinRatingArtist]
|
||||
);
|
||||
const musicLibraryFilterVersion = useAuthStore(s => s.musicLibraryFilterVersion);
|
||||
const activeServerId = useAuthStore(s => s.activeServerId ?? '');
|
||||
const indexEnabled = useLibraryIndexStore(s => s.isIndexEnabled(activeServerId));
|
||||
const [addedGenre, setAddedGenre] = useState<string | null>(null);
|
||||
const [addedArtist, setAddedArtist] = useState<string | null>(null);
|
||||
|
||||
// Blacklist panel state
|
||||
const [blacklistOpen, setBlacklistOpen] = useState(false);
|
||||
const [newGenre, setNewGenre] = useState('');
|
||||
|
||||
// Mobile collapsible panels
|
||||
const [filtersExpanded, setFiltersExpanded] = useState(false);
|
||||
const [genreMixExpanded, setGenreMixExpanded] = useState(false);
|
||||
|
||||
// Genre Mix state
|
||||
const [serverGenres, setServerGenres] = useState<SubsonicGenre[]>([]);
|
||||
const [allAvailableGenres, setAllAvailableGenres] = useState<string[]>([]);
|
||||
const [displayedGenres, setDisplayedGenres] = useState<string[]>([]);
|
||||
const [selectedGenre, setSelectedGenre] = useState<string | null>(null);
|
||||
const [genreMixSongs, setGenreMixSongs] = useState<SubsonicSong[]>([]);
|
||||
const [genreMixLoading, setGenreMixLoading] = useState(false);
|
||||
const [genreMixComplete, setGenreMixComplete] = useState(false);
|
||||
const [genresLoading, setGenresLoading] = useState(true);
|
||||
|
||||
const fetchSongs = (overrideSize?: number) => {
|
||||
setLoading(true);
|
||||
setSongs([]);
|
||||
fetchRandomMixSongsUntilFull(getMixMinRatingsConfigFromAuth(), { targetSize: overrideSize ?? randomMixSize })
|
||||
.then(list => {
|
||||
setSongs(list);
|
||||
const st = new Set<string>();
|
||||
list.forEach(s => { if (s.starred) st.add(s.id); });
|
||||
setStarredSongs(st);
|
||||
setLoading(false);
|
||||
})
|
||||
.catch(() => setLoading(false));
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
// React Compiler set-state-in-effect rule: state set from an async result resolved in this effect.
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
if (!contextMenuOpen) setContextMenuSongId(null);
|
||||
}, [contextMenuOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
// React Compiler set-state-in-effect rule: state set from an async result resolved in this effect.
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
fetchSongs();
|
||||
setGenresLoading(true);
|
||||
void fetchGenreCatalog(activeServerId, indexEnabled)
|
||||
.then(data => {
|
||||
setServerGenres(data);
|
||||
const audiobookLower = AUDIOBOOK_GENRES.map(g => g.toLowerCase());
|
||||
const available = data
|
||||
.filter(g => g.songCount > 0 && !audiobookLower.some(ab => g.value.toLowerCase().includes(ab)))
|
||||
.sort((a, b) => b.songCount - a.songCount)
|
||||
.map(g => g.value);
|
||||
setAllAvailableGenres(available);
|
||||
setDisplayedGenres(available.slice(0, 20));
|
||||
})
|
||||
.catch(() => {
|
||||
setServerGenres([]);
|
||||
setAllAvailableGenres([]);
|
||||
setDisplayedGenres([]);
|
||||
})
|
||||
.finally(() => setGenresLoading(false));
|
||||
// fetchSongs is a local helper recreated each render; the mix reload is keyed
|
||||
// on the library filter / server / index, not on the function identity.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [musicLibraryFilterVersion, activeServerId, indexEnabled]);
|
||||
|
||||
const filteredSongs = filterRandomMixSongs(songs, { excludeAudiobooks, customGenreBlacklist, mixRatingCfg });
|
||||
const filteredGenreMixSongs = filterRandomMixSongs(genreMixSongs, {
|
||||
excludeAudiobooks,
|
||||
customGenreBlacklist,
|
||||
mixRatingCfg,
|
||||
});
|
||||
|
||||
const handlePlayAll = () => {
|
||||
if (selectedGenre && filteredGenreMixSongs.length > 0) {
|
||||
playTrack(songToTrack(filteredGenreMixSongs[0]), filteredGenreMixSongs.map(songToTrack));
|
||||
} else if (filteredSongs.length > 0) {
|
||||
playTrack(songToTrack(filteredSongs[0]), filteredSongs.map(songToTrack));
|
||||
}
|
||||
};
|
||||
|
||||
const toggleSongStar = (song: SubsonicSong, e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
const currentlyStarred = song.id in starredOverrides ? starredOverrides[song.id] : starredSongs.has(song.id);
|
||||
const nextStarred = new Set(starredSongs);
|
||||
if (currentlyStarred) nextStarred.delete(song.id);
|
||||
else nextStarred.add(song.id);
|
||||
setStarredSongs(nextStarred);
|
||||
// F4: optimistic override + retried server sync via the central helper (no rollback).
|
||||
queueSongStar(song.id, !currentlyStarred, song.serverId);
|
||||
};
|
||||
|
||||
const loadGenreMix = async (genre: string, overrideSize?: number) => {
|
||||
setGenreMixLoading(true);
|
||||
setGenreMixComplete(false);
|
||||
setGenreMixSongs([]);
|
||||
try {
|
||||
const list = await fetchRandomMixSongsUntilFull(getMixMinRatingsConfigFromAuth(), {
|
||||
genre,
|
||||
timeout: 45000,
|
||||
targetSize: overrideSize ?? randomMixSize,
|
||||
});
|
||||
setGenreMixSongs(list);
|
||||
} catch { /* ignore: best-effort */ }
|
||||
setGenreMixLoading(false);
|
||||
setGenreMixComplete(true);
|
||||
};
|
||||
|
||||
const shuffleDisplayedGenres = () => {
|
||||
const shuffled = [...allAvailableGenres].sort(() => Math.random() - 0.5);
|
||||
setDisplayedGenres(shuffled.slice(0, 20));
|
||||
setSelectedGenre(null);
|
||||
setGenreMixSongs([]);
|
||||
setGenreMixComplete(false);
|
||||
};
|
||||
|
||||
|
||||
return (
|
||||
<div className="content-body animate-fade-in">
|
||||
<RandomMixHeader
|
||||
selectedGenre={selectedGenre}
|
||||
loading={loading}
|
||||
genreMixLoading={genreMixLoading}
|
||||
genreMixComplete={genreMixComplete}
|
||||
genreMixSongsLength={filteredGenreMixSongs.length}
|
||||
filteredSongsLength={filteredSongs.length}
|
||||
randomMixSize={randomMixSize}
|
||||
onRefresh={selectedGenre ? () => loadGenreMix(selectedGenre) : () => fetchSongs()}
|
||||
onPlayAll={handlePlayAll}
|
||||
/>
|
||||
|
||||
{/* ── Filter + Genre Mix panel ─────────────────────────────── */}
|
||||
<div style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: isMobile ? '1fr' : '1fr 1fr',
|
||||
gap: '1px',
|
||||
background: 'var(--border)',
|
||||
border: '1px solid var(--border)',
|
||||
borderRadius: 'var(--radius)',
|
||||
marginBottom: '2rem',
|
||||
overflow: 'hidden',
|
||||
}}>
|
||||
<RandomMixFiltersPanel
|
||||
isMobile={isMobile}
|
||||
filtersExpanded={filtersExpanded}
|
||||
setFiltersExpanded={setFiltersExpanded}
|
||||
randomMixSize={randomMixSize}
|
||||
setRandomMixSize={setRandomMixSize}
|
||||
selectedGenre={selectedGenre}
|
||||
loadGenreMix={loadGenreMix}
|
||||
fetchSongs={fetchSongs}
|
||||
excludeAudiobooks={excludeAudiobooks}
|
||||
setExcludeAudiobooks={setExcludeAudiobooks}
|
||||
blacklistOpen={blacklistOpen}
|
||||
setBlacklistOpen={setBlacklistOpen}
|
||||
customGenreBlacklist={customGenreBlacklist}
|
||||
setCustomGenreBlacklist={setCustomGenreBlacklist}
|
||||
newGenre={newGenre}
|
||||
setNewGenre={setNewGenre}
|
||||
/>
|
||||
|
||||
<RandomMixGenrePanel
|
||||
isMobile={isMobile}
|
||||
genreMixExpanded={genreMixExpanded}
|
||||
setGenreMixExpanded={setGenreMixExpanded}
|
||||
genresLoading={genresLoading}
|
||||
serverGenresLength={serverGenres.length}
|
||||
displayedGenres={displayedGenres}
|
||||
allAvailableGenresLength={allAvailableGenres.length}
|
||||
selectedGenre={selectedGenre}
|
||||
genreMixLoading={genreMixLoading}
|
||||
onSelectAll={() => { setSelectedGenre(null); setGenreMixSongs([]); setGenreMixComplete(false); fetchSongs(); }}
|
||||
onSelectGenre={genre => { setSelectedGenre(genre); loadGenreMix(genre); }}
|
||||
onShuffle={shuffleDisplayedGenres}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Genre Mix tracklist (shown when a genre is selected) */}
|
||||
{selectedGenre && (genreMixLoading || genreMixComplete || genreMixSongs.length > 0) && (
|
||||
<div style={{ marginBottom: '2rem' }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '1rem' }}>
|
||||
<span style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', fontSize: 13, fontWeight: 600, color: 'var(--text-primary)' }}>
|
||||
{selectedGenre} Mix
|
||||
{genreMixLoading && <div className="spinner" style={{ width: 12, height: 12, borderWidth: 2 }} />}
|
||||
</span>
|
||||
</div>
|
||||
{genreMixLoading && genreMixSongs.length === 0 ? (
|
||||
<div style={{ display: 'flex', justifyContent: 'center', padding: '2rem' }}><div className="spinner" /></div>
|
||||
) : genreMixSongs.length === 0 ? (
|
||||
<div className="empty-state" style={{ padding: '2rem 1rem', textAlign: 'center' }}>
|
||||
{t('randomMix.noSongsMatchFilters')}
|
||||
</div>
|
||||
) : filteredGenreMixSongs.length === 0 ? (
|
||||
<div className="empty-state" style={{ padding: '2rem 1rem', textAlign: 'center' }}>
|
||||
{t('randomMix.noSongsMatchFilters')}
|
||||
</div>
|
||||
) : (
|
||||
<div className="tracklist" data-preview-loc="randomMix">
|
||||
<div className="tracklist-header" style={{ gridTemplateColumns: '60px minmax(150px, 1fr) minmax(80px, 1fr) minmax(80px, 1fr) 70px 65px' }}>
|
||||
<div></div>
|
||||
<div>{t('randomMix.trackTitle')}</div>
|
||||
<div>{t('randomMix.trackArtist')}</div>
|
||||
<div>{t('randomMix.trackAlbum')}</div>
|
||||
<div className="col-center">{t('randomMix.trackFavorite')}</div>
|
||||
<div className="col-center">{t('randomMix.trackDuration')}</div>
|
||||
</div>
|
||||
{filteredGenreMixSongs.map((song, idx) => {
|
||||
const track = songToTrack(song);
|
||||
const queueSongs = filteredGenreMixSongs.map(songToTrack);
|
||||
const isStarred = song.id in starredOverrides ? starredOverrides[song.id] : starredSongs.has(song.id);
|
||||
return (
|
||||
<RandomMixTrackRow
|
||||
key={song.id}
|
||||
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();
|
||||
setContextMenuSongId(song.id);
|
||||
openContextMenu(e.clientX, e.clientY, track, 'song');
|
||||
}}
|
||||
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={() => {}}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!selectedGenre && (loading && songs.length === 0 ? (
|
||||
<div style={{ display: 'flex', justifyContent: 'center', padding: '4rem' }}>
|
||||
<div className="spinner" />
|
||||
</div>
|
||||
) : filteredSongs.length === 0 ? (
|
||||
<div className="empty-state" style={{ padding: '4rem 1rem', textAlign: 'center' }}>
|
||||
{t('randomMix.noSongsMatchFilters')}
|
||||
</div>
|
||||
) : (
|
||||
<div className="tracklist" data-preview-loc="randomMix">
|
||||
<div className="tracklist-header" style={{ gridTemplateColumns: '60px minmax(150px, 1fr) minmax(80px, 1fr) minmax(80px, 1fr) 120px 70px 65px' }}>
|
||||
<div></div>
|
||||
<div>{t('randomMix.trackTitle')}</div>
|
||||
<div>{t('randomMix.trackArtist')}</div>
|
||||
<div>{t('randomMix.trackAlbum')}</div>
|
||||
<div data-tooltip={t('randomMix.genreClickHint')} data-tooltip-wrap style={{ cursor: 'help' }}>
|
||||
{t('randomMix.trackGenre')} <span style={{ color: 'var(--accent)', fontWeight: 700, fontSize: 13 }}>ⓘ</span>
|
||||
</div>
|
||||
<div className="col-center">{t('randomMix.trackFavorite')}</div>
|
||||
<div className="col-center">{t('randomMix.trackDuration')}</div>
|
||||
</div>
|
||||
|
||||
{filteredSongs.map((song, idx) => {
|
||||
const track = songToTrack(song);
|
||||
const queueSongs = filteredSongs.map(songToTrack);
|
||||
const genre = song.genre;
|
||||
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()))
|
||||
);
|
||||
return (
|
||||
<RandomMixTrackRow
|
||||
key={song.id}
|
||||
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');
|
||||
}}
|
||||
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={g => {
|
||||
if (!customGenreBlacklist.some(bg => g.toLowerCase().includes(bg.toLowerCase()))) {
|
||||
setCustomGenreBlacklist([...customGenreBlacklist, g]);
|
||||
setAddedGenre(g);
|
||||
setTimeout(() => setAddedGenre(null), 1500);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { create } from 'zustand';
|
||||
|
||||
interface LuckyMixState {
|
||||
/** True while `buildAndPlayLuckyMix` is actively assembling a mix. */
|
||||
isRolling: boolean;
|
||||
/**
|
||||
* Set by `cancel()` — the build loop polls this between awaits and bails
|
||||
* out silently when true. Reset to false on `start()` so a new build can
|
||||
* run after a cancelled one.
|
||||
*/
|
||||
cancelRequested: boolean;
|
||||
start: () => void;
|
||||
stop: () => void;
|
||||
cancel: () => void;
|
||||
}
|
||||
|
||||
export const useLuckyMixStore = create<LuckyMixState>((set) => ({
|
||||
isRolling: false,
|
||||
cancelRequested: false,
|
||||
start: () => set({ isRolling: true, cancelRequested: false }),
|
||||
stop: () => set({ isRolling: false, cancelRequested: false }),
|
||||
cancel: () => set({ cancelRequested: true }),
|
||||
}));
|
||||
@@ -0,0 +1,407 @@
|
||||
import { fetchSimilarTracksRouted } from '@/lib/api/subsonicArtists';
|
||||
import { filterSongsToActiveLibrary, getRandomSongs } from '@/lib/api/subsonicLibrary';
|
||||
import type { SubsonicAlbum, SubsonicSong } from '@/lib/api/subsonicTypes';
|
||||
import type { QueueItemRef } from '@/lib/media/trackTypes';
|
||||
import { songToTrack } from '@/lib/media/songToTrack';
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import i18n from '@/lib/i18n';
|
||||
import { useAuthStore } from '@/store/authStore';
|
||||
import { pushQueueUndoFromGetter } from '@/features/playback/store/queueUndo';
|
||||
import { usePlayerStore } from '@/features/playback/store/playerStore';
|
||||
import { useLuckyMixStore } from '@/features/randomMix/store/luckyMixStore';
|
||||
import { isLuckyMixAvailable } from '@/features/randomMix/hooks/useLuckyMixAvailable';
|
||||
import { showToast } from '@/utils/ui/toast';
|
||||
import {
|
||||
bindQueueServerForPlayback,
|
||||
playbackServerDiffersFromActive,
|
||||
prepareActiveServerForNewMix,
|
||||
shouldHandoffQueueToActiveServer,
|
||||
} from '@/features/playback/utils/playback/playbackServer';
|
||||
import {
|
||||
filterSongsForLuckyMixRatings,
|
||||
filterTopArtistsForMixRatings,
|
||||
getMixMinRatingsConfigFromAuth,
|
||||
} from '@/utils/mix/mixRatingFilter';
|
||||
import {
|
||||
MIX_TARGET_SIZE,
|
||||
SEED_TARGET_SIZE,
|
||||
sampleRandom,
|
||||
uniqueBySongId,
|
||||
uniqueAppend,
|
||||
deriveTopArtistsFromFrequentAlbums,
|
||||
fetchFrequentAlbumsPool,
|
||||
pickSongsForArtist,
|
||||
pickSongsForAlbum,
|
||||
pickGoodRatedSongs,
|
||||
} from '@/features/randomMix/utils/luckyMixHelpers';
|
||||
|
||||
/**
|
||||
* Sentinel thrown inside the build loop when `useLuckyMixStore.cancelRequested`
|
||||
* flips to true. The `catch` handler swallows it silently (no toast, no
|
||||
* queue restore, no error state) — the user already moved on.
|
||||
*/
|
||||
class LuckyMixCancelled extends Error {
|
||||
constructor() {
|
||||
super('lucky-mix-cancelled');
|
||||
this.name = 'LuckyMixCancelled';
|
||||
}
|
||||
}
|
||||
|
||||
export async function buildAndPlayLuckyMix(): Promise<void> {
|
||||
const lucky = useLuckyMixStore.getState();
|
||||
if (lucky.isRolling) return;
|
||||
const auth = useAuthStore.getState();
|
||||
const debugEnabled = auth.loggingMode === 'debug';
|
||||
const debugSteps: Array<{ step: string; details?: unknown }> = [];
|
||||
const logStep = (step: string, details?: unknown) => {
|
||||
if (!debugEnabled) return;
|
||||
const payload = { step, details };
|
||||
debugSteps.push(payload);
|
||||
console.debug('[psysonic][lucky-mix]', payload);
|
||||
void invoke('frontend_debug_log', {
|
||||
scope: 'lucky-mix',
|
||||
message: JSON.stringify(payload),
|
||||
}).catch(() => {});
|
||||
};
|
||||
const songDebug = (songs: SubsonicSong[]) =>
|
||||
songs.map(s => ({ id: s.id, title: s.title, artist: s.artist, rating: s.userRating ?? 0 }));
|
||||
const albumDebug = (albums: SubsonicAlbum[]) =>
|
||||
albums.map(a => ({ id: a.id, name: a.name, artist: a.artist, playCount: a.playCount ?? 0 }));
|
||||
const activeServerId = auth.activeServerId;
|
||||
const available = isLuckyMixAvailable({
|
||||
activeServerId,
|
||||
audiomuseByServer: auth.audiomuseNavidromeByServer,
|
||||
showLuckyMixMenu: auth.showLuckyMixMenu,
|
||||
});
|
||||
const mixRatingCfg = getMixMinRatingsConfigFromAuth();
|
||||
logStep('init', {
|
||||
activeServerId,
|
||||
available,
|
||||
showLuckyMixMenu: auth.showLuckyMixMenu,
|
||||
libraryFilter: activeServerId ? (auth.musicLibraryFilterByServer[activeServerId] ?? 'all') : 'all',
|
||||
mixRatingFilter: mixRatingCfg,
|
||||
crossServerPlayback: playbackServerDiffersFromActive(),
|
||||
handoffQueueToActive: shouldHandoffQueueToActiveServer(),
|
||||
});
|
||||
if (!available) {
|
||||
logStep('abort_unavailable');
|
||||
showToast(i18n.t('luckyMix.unavailable'), 4000, 'warning');
|
||||
return;
|
||||
}
|
||||
|
||||
lucky.start();
|
||||
|
||||
// Snapshot the current queue *before* we prune — so if the build fails
|
||||
// before we ever play a track, we can put it back the way it was instead
|
||||
// of leaving the user with an empty player. Thin-state: snapshot the refs and
|
||||
// the resolved tracks (to re-seed the resolver on restore).
|
||||
const playerStateBefore = usePlayerStore.getState();
|
||||
const queueSnapshot: {
|
||||
queueItems: QueueItemRef[];
|
||||
queueIndex: number;
|
||||
queueServerId: string | null;
|
||||
} = {
|
||||
queueItems: [...playerStateBefore.queueItems],
|
||||
queueIndex: playerStateBefore.queueIndex,
|
||||
queueServerId: playerStateBefore.queueServerId,
|
||||
};
|
||||
|
||||
// One undo step for the whole Lucky Mix run — internal prune/play/enqueue
|
||||
// batches must not each push (QUEUE_UNDO_MAX would drop this snapshot).
|
||||
pushQueueUndoFromGetter(() => usePlayerStore.getState());
|
||||
|
||||
let unsubPlayer: (() => void) | null = null;
|
||||
try {
|
||||
// Browsed server ≠ queue server: stop A's stream so Now Playing does not call
|
||||
// ensurePlaybackServerActive() and revert the UI mid-build.
|
||||
if (shouldHandoffQueueToActiveServer()) {
|
||||
prepareActiveServerForNewMix();
|
||||
logStep('cross_server_handoff', { activeServerId });
|
||||
} else {
|
||||
// Drop the old "upcoming" tail so the queue UI does not show stale next
|
||||
// tracks while the mix is still building (first playTrack may be delayed).
|
||||
usePlayerStore.getState().pruneUpcomingToCurrent(true);
|
||||
}
|
||||
let startedPlayback = false;
|
||||
try {
|
||||
let allSeedSongs: SubsonicSong[] = [];
|
||||
|
||||
const mixQueueSize = () => usePlayerStore.getState().queueItems.length;
|
||||
const mixQueueTrackIds = () => new Set(usePlayerStore.getState().queueItems.map(r => r.trackId));
|
||||
|
||||
const bailIfCancelled = () => {
|
||||
if (useLuckyMixStore.getState().cancelRequested) throw new LuckyMixCancelled();
|
||||
};
|
||||
const reachedTarget = () => mixQueueSize() >= MIX_TARGET_SIZE;
|
||||
|
||||
const startImmediatePlayback = async (song: SubsonicSong, source: string) => {
|
||||
if (startedPlayback || !song?.id) return;
|
||||
const allowed = await filterSongsForLuckyMixRatings([song], mixRatingCfg);
|
||||
if (!allowed.length) return;
|
||||
const play = allowed[0];
|
||||
startedPlayback = true;
|
||||
const track = songToTrack(play);
|
||||
usePlayerStore.getState().playTrack(track, [track], false);
|
||||
logStep('start_immediate_playback', {
|
||||
source,
|
||||
song: songDebug([play])[0],
|
||||
queuedCount: mixQueueSize(),
|
||||
});
|
||||
|
||||
// Auto-cancel: once we're playing, watch the player store. If the
|
||||
// current track switches to something the user picked themselves (not
|
||||
// in the mix queue), treat that as "user moved on" and cancel the build.
|
||||
if (!unsubPlayer) {
|
||||
unsubPlayer = usePlayerStore.subscribe((state, prev) => {
|
||||
const prevId = prev.currentTrack?.id ?? null;
|
||||
const nextId = state.currentTrack?.id ?? null;
|
||||
if (nextId === prevId) return;
|
||||
if (!nextId) return;
|
||||
if (state.queueItems.some(r => r.trackId === nextId)) return;
|
||||
useLuckyMixStore.getState().cancel();
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const appendSongsToQueue = async (songs: SubsonicSong[], reason: string): Promise<number> => {
|
||||
if (useLuckyMixStore.getState().cancelRequested) return 0;
|
||||
if (reachedTarget()) return 0;
|
||||
if (!songs.length) return 0;
|
||||
const knownIds = mixQueueTrackIds();
|
||||
const unique = uniqueBySongId(songs).filter(s => !knownIds.has(s.id));
|
||||
const deduped = await filterSongsForLuckyMixRatings(unique, mixRatingCfg);
|
||||
if (!deduped.length) return 0;
|
||||
|
||||
const candidates = [...deduped];
|
||||
if (!startedPlayback && candidates.length > 0) {
|
||||
const first = candidates.shift();
|
||||
if (first) await startImmediatePlayback(first, reason);
|
||||
}
|
||||
|
||||
if (!candidates.length) return 0;
|
||||
const remaining = Math.max(0, MIX_TARGET_SIZE - mixQueueSize());
|
||||
if (remaining <= 0) return 0;
|
||||
const toAdd = sampleRandom(candidates, Math.min(remaining, candidates.length));
|
||||
if (!toAdd.length) return 0;
|
||||
const before = mixQueueSize();
|
||||
bindQueueServerForPlayback();
|
||||
usePlayerStore.getState().enqueue(toAdd.map(songToTrack), true, true);
|
||||
const added = mixQueueSize() - before;
|
||||
logStep('append_queue_batch', {
|
||||
reason,
|
||||
added,
|
||||
queuedCount: mixQueueSize(),
|
||||
songs: songDebug(toAdd),
|
||||
});
|
||||
return added;
|
||||
};
|
||||
|
||||
const frequentAlbums = await fetchFrequentAlbumsPool();
|
||||
bailIfCancelled();
|
||||
const albumsWithPlays = frequentAlbums.filter(a => (a.playCount ?? 0) > 0);
|
||||
logStep('fetch_frequent_albums', {
|
||||
fetched: frequentAlbums.length,
|
||||
withPlays: albumsWithPlays.length,
|
||||
});
|
||||
const topArtists = await filterTopArtistsForMixRatings(
|
||||
deriveTopArtistsFromFrequentAlbums(albumsWithPlays),
|
||||
mixRatingCfg,
|
||||
);
|
||||
const pickedArtists = sampleRandom(topArtists, 2);
|
||||
logStep('pick_top_artists', {
|
||||
topArtistsCount: topArtists.length,
|
||||
pickedArtists,
|
||||
});
|
||||
|
||||
for (const artist of pickedArtists) {
|
||||
bailIfCancelled();
|
||||
const songs = await pickSongsForArtist(artist, 3, mixRatingCfg);
|
||||
allSeedSongs = uniqueAppend(allSeedSongs, songs);
|
||||
const firstPlayable = songs[0];
|
||||
if (firstPlayable) await startImmediatePlayback(firstPlayable, `artist:${artist.name}`);
|
||||
logStep('pick_artist_songs', {
|
||||
artist,
|
||||
pickedCount: songs.length,
|
||||
songs: songDebug(songs),
|
||||
});
|
||||
}
|
||||
|
||||
const pickedAlbums = sampleRandom(albumsWithPlays, 2);
|
||||
logStep('pick_top_albums', {
|
||||
poolCount: albumsWithPlays.length,
|
||||
pickedAlbums: albumDebug(pickedAlbums),
|
||||
});
|
||||
for (const album of pickedAlbums) {
|
||||
bailIfCancelled();
|
||||
const songs = await pickSongsForAlbum(album.id, 3, mixRatingCfg);
|
||||
allSeedSongs = uniqueAppend(allSeedSongs, songs);
|
||||
const firstPlayable = songs[0];
|
||||
if (firstPlayable) await startImmediatePlayback(firstPlayable, `album:${album.id}`);
|
||||
logStep('pick_album_songs', {
|
||||
albumId: album.id,
|
||||
pickedCount: songs.length,
|
||||
songs: songDebug(songs),
|
||||
});
|
||||
}
|
||||
|
||||
bailIfCancelled();
|
||||
const rated = await pickGoodRatedSongs(new Set(allSeedSongs.map(s => s.id)), 3, mixRatingCfg);
|
||||
logStep('pick_rated_songs_4plus_only', {
|
||||
ratedPickedCount: rated.length,
|
||||
ratedSongs: songDebug(rated),
|
||||
});
|
||||
allSeedSongs = uniqueAppend(allSeedSongs, rated);
|
||||
let seeds = await filterSongsForLuckyMixRatings(allSeedSongs, mixRatingCfg);
|
||||
logStep('seed_after_dedup', {
|
||||
seedCount: seeds.length,
|
||||
seeds: songDebug(seeds),
|
||||
});
|
||||
|
||||
if (seeds.length < SEED_TARGET_SIZE) {
|
||||
logStep('seed_fill_start', { target: SEED_TARGET_SIZE, before: seeds.length });
|
||||
for (let i = 0; i < 10 && seeds.length < SEED_TARGET_SIZE; i++) {
|
||||
bailIfCancelled();
|
||||
const rnd = await filterSongsToActiveLibrary(await getRandomSongs(80));
|
||||
const allowedRnd = await filterSongsForLuckyMixRatings(rnd, mixRatingCfg);
|
||||
seeds = uniqueAppend(seeds, allowedRnd);
|
||||
const firstPlayable = allowedRnd[0];
|
||||
if (firstPlayable) await startImmediatePlayback(firstPlayable, `seed-fill-batch:${i + 1}`);
|
||||
logStep('seed_fill_batch', {
|
||||
batch: i + 1,
|
||||
fetched: rnd.length,
|
||||
seedCount: seeds.length,
|
||||
});
|
||||
}
|
||||
seeds = seeds.slice(0, SEED_TARGET_SIZE);
|
||||
logStep('seed_fill_end', {
|
||||
finalSeedCount: seeds.length,
|
||||
seeds: songDebug(seeds),
|
||||
});
|
||||
}
|
||||
|
||||
if (seeds.length === 0) {
|
||||
throw new Error('no-seeds');
|
||||
}
|
||||
if (!startedPlayback) {
|
||||
const firstPlayableSeed = seeds[0];
|
||||
if (firstPlayableSeed) await startImmediatePlayback(firstPlayableSeed, 'seed-fallback-first');
|
||||
}
|
||||
|
||||
let similarRaw: SubsonicSong[] = [];
|
||||
let similar: SubsonicSong[] = [];
|
||||
for (let i = 0; i < seeds.length; i++) {
|
||||
bailIfCancelled();
|
||||
const seed = seeds[i];
|
||||
const oneRaw = await fetchSimilarTracksRouted(seed.id, 60).catch(() => [] as SubsonicSong[]);
|
||||
const oneScoped = await filterSongsToActiveLibrary(oneRaw);
|
||||
similarRaw = uniqueAppend(similarRaw, oneRaw);
|
||||
similar = uniqueAppend(similar, oneScoped);
|
||||
await appendSongsToQueue(oneScoped, `similar-seed-${i + 1}/${seeds.length}`);
|
||||
if (reachedTarget()) break;
|
||||
}
|
||||
const seedForPool = seeds.filter(() => Math.random() < 0.5);
|
||||
let pool = uniqueBySongId([...seedForPool, ...similar]);
|
||||
await appendSongsToQueue(seedForPool, 'seed-50pct');
|
||||
logStep('instant_mix', {
|
||||
seedUsedForInstantMixCount: seeds.length,
|
||||
seedIncludedInPoolCount: seedForPool.length,
|
||||
seedIncludedInPool: songDebug(seedForPool),
|
||||
similarRawCount: similarRaw.length,
|
||||
similarScopedCount: similar.length,
|
||||
initialPoolCount: pool.length,
|
||||
});
|
||||
|
||||
const poolFillMaxBatches = mixRatingCfg.enabled ? 25 : 10;
|
||||
for (let i = 0; i < poolFillMaxBatches && !reachedTarget(); i++) {
|
||||
bailIfCancelled();
|
||||
const rnd = await filterSongsToActiveLibrary(await getRandomSongs(120));
|
||||
pool = uniqueAppend(pool, rnd);
|
||||
await appendSongsToQueue(rnd, `pool-fill-${i + 1}`);
|
||||
logStep('pool_fill_batch', {
|
||||
batch: i + 1,
|
||||
fetched: rnd.length,
|
||||
poolCount: pool.length,
|
||||
queueCount: mixQueueSize(),
|
||||
});
|
||||
}
|
||||
|
||||
bailIfCancelled();
|
||||
if (!reachedTarget()) {
|
||||
const poolFiltered = await filterSongsForLuckyMixRatings(pool, mixRatingCfg);
|
||||
const need = MIX_TARGET_SIZE - mixQueueSize();
|
||||
const finalSongs = sampleRandom(
|
||||
poolFiltered.filter(s => !mixQueueTrackIds().has(s.id)),
|
||||
need,
|
||||
);
|
||||
await appendSongsToQueue(finalSongs, 'finalize-randomized');
|
||||
}
|
||||
|
||||
for (let i = 0; i < 20 && !reachedTarget(); i++) {
|
||||
bailIfCancelled();
|
||||
const rnd = await filterSongsToActiveLibrary(await getRandomSongs(120));
|
||||
const added = await appendSongsToQueue(rnd, `topup-${i + 1}`);
|
||||
if (added === 0 && i >= 8) break;
|
||||
}
|
||||
|
||||
const finalQueueCount = mixQueueSize();
|
||||
logStep('final_queue_state', {
|
||||
poolCount: pool.length,
|
||||
queuedCount: finalQueueCount,
|
||||
queuedTarget: MIX_TARGET_SIZE,
|
||||
});
|
||||
if (finalQueueCount === 0) {
|
||||
throw new Error('empty-mix');
|
||||
}
|
||||
showToast(i18n.t('luckyMix.done', { count: finalQueueCount }), 3500, 'success');
|
||||
logStep('done', { queueCount: finalQueueCount });
|
||||
if (debugEnabled) {
|
||||
console.debug('[psysonic][lucky-mix] full-steps', debugSteps);
|
||||
void invoke('frontend_debug_log', {
|
||||
scope: 'lucky-mix',
|
||||
message: JSON.stringify({ step: 'full-steps', details: debugSteps }),
|
||||
}).catch(() => {});
|
||||
}
|
||||
} catch (err) {
|
||||
// Cancellation is a user-initiated path, not an error. Silent teardown.
|
||||
if (err instanceof LuckyMixCancelled) {
|
||||
logStep('cancelled');
|
||||
if (debugEnabled) {
|
||||
console.debug('[psysonic][lucky-mix] full-steps', debugSteps);
|
||||
void invoke('frontend_debug_log', {
|
||||
scope: 'lucky-mix',
|
||||
message: JSON.stringify({ step: 'full-steps', details: debugSteps }),
|
||||
}).catch(() => {});
|
||||
}
|
||||
return;
|
||||
}
|
||||
console.error('[psysonic] lucky mix failed:', err);
|
||||
logStep('failed', { error: String(err) });
|
||||
if (debugEnabled) {
|
||||
console.debug('[psysonic][lucky-mix] full-steps', debugSteps);
|
||||
void invoke('frontend_debug_log', {
|
||||
scope: 'lucky-mix',
|
||||
message: JSON.stringify({ step: 'full-steps', details: debugSteps }),
|
||||
}).catch(() => {});
|
||||
}
|
||||
// If we failed before ever calling playTrack, the queue-prune we did up
|
||||
// front left the user with nothing. Restore the snapshot so they land
|
||||
// back where they were pre-click instead of in an empty player.
|
||||
// If playback did start, leave it alone — their current track plus
|
||||
// whatever we managed to enqueue is more useful than the old queue.
|
||||
if (!startedPlayback) {
|
||||
usePlayerStore.setState({
|
||||
queueItems: queueSnapshot.queueItems,
|
||||
queueIndex: queueSnapshot.queueIndex,
|
||||
queueServerId: queueSnapshot.queueServerId,
|
||||
});
|
||||
logStep('queue_restored_after_failure', {
|
||||
restoredCount: queueSnapshot.queueItems.length,
|
||||
});
|
||||
}
|
||||
showToast(i18n.t('luckyMix.failed'), 5000, 'error');
|
||||
}
|
||||
} finally {
|
||||
if (unsubPlayer) { try { unsubPlayer(); } catch { /* noop */ } }
|
||||
useLuckyMixStore.getState().stop();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
import { getTopSongs } from '@/lib/api/subsonicArtists';
|
||||
import { filterSongsToActiveLibrary, getAlbumList, getRandomSongs } from '@/lib/api/subsonicLibrary';
|
||||
import { resolveAlbumForActiveServer } from '@/store/mediaResolver';
|
||||
import type { SubsonicAlbum, SubsonicSong } from '@/lib/api/subsonicTypes';
|
||||
import {
|
||||
filterSongsForLuckyMixRatings,
|
||||
type MixMinRatingsConfig,
|
||||
} from '@/utils/mix/mixRatingFilter';
|
||||
|
||||
export interface TopArtist {
|
||||
id: string;
|
||||
name: string;
|
||||
totalPlays: number;
|
||||
}
|
||||
|
||||
export const MOST_PLAYED_PAGE_SIZE = 100;
|
||||
export const MOST_PLAYED_MAX_ALBUMS = 500;
|
||||
export const MIX_TARGET_SIZE = 50;
|
||||
export const SEED_TARGET_SIZE = 15;
|
||||
|
||||
export function sampleRandom<T>(items: T[], count: number): T[] {
|
||||
if (count <= 0 || items.length === 0) return [];
|
||||
const arr = [...items];
|
||||
for (let i = arr.length - 1; i > 0; i--) {
|
||||
const j = Math.floor(Math.random() * (i + 1));
|
||||
[arr[i], arr[j]] = [arr[j], arr[i]];
|
||||
}
|
||||
return arr.slice(0, Math.min(count, arr.length));
|
||||
}
|
||||
|
||||
export function uniqueBySongId(items: SubsonicSong[]): SubsonicSong[] {
|
||||
const out: SubsonicSong[] = [];
|
||||
const seen = new Set<string>();
|
||||
for (const s of items) {
|
||||
if (!s?.id || seen.has(s.id)) continue;
|
||||
seen.add(s.id);
|
||||
out.push(s);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function uniqueAppend(base: SubsonicSong[], incoming: SubsonicSong[]): SubsonicSong[] {
|
||||
return uniqueBySongId([...base, ...incoming]);
|
||||
}
|
||||
|
||||
export function deriveTopArtistsFromFrequentAlbums(albums: SubsonicAlbum[]): TopArtist[] {
|
||||
const map = new Map<string, TopArtist>();
|
||||
for (const a of albums) {
|
||||
const plays = a.playCount ?? 0;
|
||||
if (!a.artistId || !a.artist || plays <= 0) continue;
|
||||
const prev = map.get(a.artistId);
|
||||
if (prev) {
|
||||
prev.totalPlays += plays;
|
||||
continue;
|
||||
}
|
||||
map.set(a.artistId, { id: a.artistId, name: a.artist, totalPlays: plays });
|
||||
}
|
||||
return [...map.values()].sort((a, b) => b.totalPlays - a.totalPlays);
|
||||
}
|
||||
|
||||
export async function fetchFrequentAlbumsPool(): Promise<SubsonicAlbum[]> {
|
||||
const out: SubsonicAlbum[] = [];
|
||||
let offset = 0;
|
||||
while (out.length < MOST_PLAYED_MAX_ALBUMS) {
|
||||
const page = await getAlbumList('frequent', MOST_PLAYED_PAGE_SIZE, offset);
|
||||
if (!page.length) break;
|
||||
out.push(...page);
|
||||
if (page.length < MOST_PLAYED_PAGE_SIZE) break;
|
||||
offset += MOST_PLAYED_PAGE_SIZE;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export async function pickSongsForArtist(
|
||||
artist: TopArtist,
|
||||
need: number,
|
||||
mixRatings: MixMinRatingsConfig,
|
||||
): Promise<SubsonicSong[]> {
|
||||
const primary = uniqueBySongId(await filterSongsToActiveLibrary(await getTopSongs(artist.name)));
|
||||
let pool = primary;
|
||||
if (primary.length < need) {
|
||||
const extra: SubsonicSong[] = [];
|
||||
for (let i = 0; i < 8 && primary.length + extra.length < need * 4; i++) {
|
||||
const rnd = await filterSongsToActiveLibrary(await getRandomSongs(120));
|
||||
for (const s of rnd) {
|
||||
if (s.artistId === artist.id || s.artist === artist.name) {
|
||||
extra.push(s);
|
||||
}
|
||||
}
|
||||
}
|
||||
pool = uniqueBySongId([...primary, ...extra]);
|
||||
}
|
||||
const filtered = await filterSongsForLuckyMixRatings(pool, mixRatings);
|
||||
return sampleRandom(filtered, Math.min(need, filtered.length));
|
||||
}
|
||||
|
||||
export async function pickSongsForAlbum(
|
||||
albumId: string,
|
||||
need: number,
|
||||
mixRatings: MixMinRatingsConfig,
|
||||
): Promise<SubsonicSong[]> {
|
||||
const full = await resolveAlbumForActiveServer(albumId).catch(() => null);
|
||||
if (!full?.songs?.length) return [];
|
||||
const scopedSongs = await filterSongsToActiveLibrary(full.songs);
|
||||
const unique = uniqueBySongId(scopedSongs);
|
||||
const filtered = await filterSongsForLuckyMixRatings(unique, mixRatings);
|
||||
return sampleRandom(filtered, Math.min(need, filtered.length));
|
||||
}
|
||||
|
||||
export async function pickGoodRatedSongs(
|
||||
existingIds: Set<string>,
|
||||
need: number,
|
||||
mixRatings: MixMinRatingsConfig,
|
||||
): Promise<SubsonicSong[]> {
|
||||
const out: SubsonicSong[] = [];
|
||||
const push = (s: SubsonicSong) => {
|
||||
const r = s.userRating ?? 0;
|
||||
if (r < 4) return;
|
||||
if (existingIds.has(s.id)) return;
|
||||
if (out.some(x => x.id === s.id)) return;
|
||||
out.push(s);
|
||||
};
|
||||
|
||||
for (let i = 0; i < 14 && out.length < need * 8; i++) {
|
||||
const rnd = await filterSongsToActiveLibrary(await getRandomSongs(120));
|
||||
rnd.forEach(push);
|
||||
}
|
||||
|
||||
const filtered = await filterSongsForLuckyMixRatings(out, mixRatings);
|
||||
return sampleRandom(filtered, Math.min(need, filtered.length));
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import type { SubsonicSong } from '@/lib/api/subsonicTypes';
|
||||
import { filterRandomMixSongs } from '@/features/randomMix/utils/randomMixHelpers';
|
||||
|
||||
function song(id: string): SubsonicSong {
|
||||
return {
|
||||
id,
|
||||
title: 't',
|
||||
artist: 'A',
|
||||
album: 'Al',
|
||||
albumId: 'alb',
|
||||
duration: 1,
|
||||
artistUserRating: 1,
|
||||
};
|
||||
}
|
||||
|
||||
describe('filterRandomMixSongs', () => {
|
||||
it('applies mix rating filter even when audiobook exclusion is off', () => {
|
||||
const cfg = { enabled: true, minSong: 0, minAlbum: 0, minArtist: 2 };
|
||||
const out = filterRandomMixSongs([song('1'), song('2')], {
|
||||
excludeAudiobooks: false,
|
||||
customGenreBlacklist: [],
|
||||
mixRatingCfg: { ...cfg, minArtist: 2 },
|
||||
});
|
||||
expect(out).toHaveLength(0);
|
||||
|
||||
const kept = filterRandomMixSongs([song('1'), { ...song('2'), artistUserRating: 4 }], {
|
||||
excludeAudiobooks: false,
|
||||
customGenreBlacklist: [],
|
||||
mixRatingCfg: cfg,
|
||||
});
|
||||
expect(kept).toHaveLength(1);
|
||||
expect(kept[0].id).toBe('2');
|
||||
});
|
||||
|
||||
it('applies keyword blacklist even when audiobook exclusion is off', () => {
|
||||
const blocked = { ...song('1'), artist: '[Unknown Artist]' };
|
||||
const out = filterRandomMixSongs([blocked, song('2')], {
|
||||
excludeAudiobooks: false,
|
||||
customGenreBlacklist: ['[Unknown Artist]'],
|
||||
mixRatingCfg: { enabled: false, minSong: 0, minAlbum: 0, minArtist: 0 },
|
||||
});
|
||||
expect(out).toEqual([song('2')]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,40 @@
|
||||
import type { SubsonicSong } from '@/lib/api/subsonicTypes';
|
||||
import { passesMixMinRatings, type MixMinRatingsConfig } from '@/utils/mix/mixRatingFilter';
|
||||
|
||||
export const AUDIOBOOK_GENRES = [
|
||||
'hörbuch', 'hoerbuch', 'hörspiel', 'hoerspiel',
|
||||
'audiobook', 'audio book', 'spoken word', 'spokenword',
|
||||
'podcast', 'kapitel', 'krimi', 'speech',
|
||||
'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 (!passesMixMinRatings(song, mixRatingCfg)) return false;
|
||||
const matchesExcludedText = (text: string) => {
|
||||
const t = text.toLowerCase();
|
||||
if (excludeAudiobooks && AUDIOBOOK_GENRES.some(ag => t.includes(ag))) return true;
|
||||
if (customGenreBlacklist.some(bg => t.includes(bg.toLowerCase()))) return true;
|
||||
return false;
|
||||
};
|
||||
if (song.genre && matchesExcludedText(song.genre)) return false;
|
||||
if (song.title && matchesExcludedText(song.title)) return false;
|
||||
if (song.album && matchesExcludedText(song.album)) return false;
|
||||
if (song.artist && matchesExcludedText(song.artist)) return false;
|
||||
return true;
|
||||
});
|
||||
}
|
||||
@@ -2,7 +2,7 @@ import { useCallback, useRef } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useAuthStore } from '@/store/authStore';
|
||||
import { useSidebarStore, SidebarItemConfig, CONSERVED_SIDEBAR_NAV_IDS } from '@/features/sidebar';
|
||||
import { useLuckyMixAvailable } from '@/hooks/useLuckyMixAvailable';
|
||||
import { useLuckyMixAvailable } from '@/features/randomMix';
|
||||
import { ALL_NAV_ITEMS } from '@/config/navItems';
|
||||
import { applySidebarReorderById } from '@/features/sidebar';
|
||||
import { useListReorderDnd } from '@/hooks/useListReorderDnd';
|
||||
|
||||
@@ -17,7 +17,7 @@ import {
|
||||
getLibraryItemsForReorder,
|
||||
getSystemItemsForReorder,
|
||||
} from '@/features/sidebar/utils/sidebarNavReorder';
|
||||
import { useLuckyMixAvailable } from '@/hooks/useLuckyMixAvailable';
|
||||
import { useLuckyMixAvailable } from '@/features/randomMix';
|
||||
import { usePerfProbeFlags } from '@/utils/perf/perfFlags';
|
||||
import { useSidebarNewReleasesUnread } from '@/features/sidebar/hooks/useSidebarNewReleasesUnread';
|
||||
import { useSidebarNavDnd } from '@/features/sidebar/hooks/useSidebarNavDnd';
|
||||
|
||||
Reference in New Issue
Block a user