refactor(randomMix): co-locate random/lucky-mix feature into features/randomMix

This commit is contained in:
Psychotoxical
2026-06-30 18:08:15 +02:00
parent f443a60c54
commit f05183d412
20 changed files with 44 additions and 31 deletions
+1 -1
View File
@@ -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';
+1 -1
View File
@@ -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 });
}
+13
View File
@@ -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';
+17
View File
@@ -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>
);
}
+405
View File
@@ -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 }),
}));
+407
View File
@@ -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';
+1 -1
View File
@@ -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';