mirror of
https://github.com/kilyabin/psysonic.git
synced 2026-07-22 06:25:41 +00:00
feat(random-mix): playlist size selector + filter panel layout cleanup (#445)
* feat(random-mix): playlist size selector + filter panel layout cleanup Adds a 5-button playlist-size picker (50/75/100/125/150) at the top of the Random Mix filter panel, persisted via authStore. Clicking a size immediately reruns the current mix (genre-scoped or All Songs) at the new size — no second click on Remix needed. Filter panel layout cleaned up: - Two sub-sections "MIX SETTINGS" and "EXCLUSIONS" with a divider between them so the panel reads cleanly with the new size row. - Larger panel-level headers (FILTERS / GENRE MIX) so the hierarchy panel-title > sub-section is visually unambiguous. - Italic muted note under MIX SETTINGS calling out that large mix sizes may return fewer unique tracks if the server's random pool runs short — sets honest expectations instead of users wondering why a 150 request returned ~126. fetchRandomMixSongsUntilFull now scales batch size, max-batch ceiling and dup-streak budget with target size; when no Settings-level mix filter is active, the first call asks for the full target so a 150 mix can finish in a single round-trip on most libraries. The loop falls through to top up with deduped follow-up calls if the server returns fewer than requested. * docs(changelog): add #445 Random Mix playlist size selector entry * chore(credits): add #445 to Psychotoxical contributions
This commit is contained in:
committed by
GitHub
parent
1799e90e04
commit
3b4d54431b
+49
-12
@@ -2,7 +2,7 @@ import React, { useEffect, useMemo, useState } from 'react';
|
||||
import { getGenres, SubsonicSong, SubsonicGenre, star, unstar } from '../api/subsonic';
|
||||
import { usePlayerStore, songToTrack } from '../store/playerStore';
|
||||
import { usePreviewStore } from '../store/previewStore';
|
||||
import { useAuthStore } from '../store/authStore';
|
||||
import { useAuthStore, RANDOM_MIX_SIZE_OPTIONS } from '../store/authStore';
|
||||
import { Play, RefreshCw, ChevronDown, ChevronRight, ChevronUp, Heart, Square } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useDragDrop } from '../contexts/DragDropContext';
|
||||
@@ -56,6 +56,8 @@ export default function RandomMix() {
|
||||
mixMinRatingSong,
|
||||
mixMinRatingAlbum,
|
||||
mixMinRatingArtist,
|
||||
randomMixSize,
|
||||
setRandomMixSize,
|
||||
} = useAuthStore();
|
||||
|
||||
const mixRatingCfg = useMemo(
|
||||
@@ -88,10 +90,10 @@ export default function RandomMix() {
|
||||
const [genreMixLoading, setGenreMixLoading] = useState(false);
|
||||
const [genreMixComplete, setGenreMixComplete] = useState(false);
|
||||
|
||||
const fetchSongs = () => {
|
||||
const fetchSongs = (overrideSize?: number) => {
|
||||
setLoading(true);
|
||||
setSongs([]);
|
||||
fetchRandomMixSongsUntilFull(getMixMinRatingsConfigFromAuth())
|
||||
fetchRandomMixSongsUntilFull(getMixMinRatingsConfigFromAuth(), { targetSize: overrideSize ?? randomMixSize })
|
||||
.then(list => {
|
||||
setSongs(list);
|
||||
const st = new Set<string>();
|
||||
@@ -163,7 +165,7 @@ export default function RandomMix() {
|
||||
}
|
||||
};
|
||||
|
||||
const loadGenreMix = async (genre: string) => {
|
||||
const loadGenreMix = async (genre: string, overrideSize?: number) => {
|
||||
setGenreMixLoading(true);
|
||||
setGenreMixComplete(false);
|
||||
setGenreMixSongs([]);
|
||||
@@ -171,6 +173,7 @@ export default function RandomMix() {
|
||||
const list = await fetchRandomMixSongsUntilFull(getMixMinRatingsConfigFromAuth(), {
|
||||
genre,
|
||||
timeout: 45000,
|
||||
targetSize: overrideSize ?? randomMixSize,
|
||||
});
|
||||
setGenreMixSongs(list);
|
||||
} catch {}
|
||||
@@ -195,7 +198,7 @@ export default function RandomMix() {
|
||||
<div style={{ display: 'flex', gap: '0.5rem' }}>
|
||||
<button
|
||||
className="btn btn-surface"
|
||||
onClick={selectedGenre ? () => loadGenreMix(selectedGenre) : fetchSongs}
|
||||
onClick={selectedGenre ? () => loadGenreMix(selectedGenre) : () => fetchSongs()}
|
||||
disabled={selectedGenre ? genreMixLoading : loading}
|
||||
data-tooltip={selectedGenre
|
||||
? t('randomMix.remixTooltipGenre', { genre: selectedGenre })
|
||||
@@ -215,7 +218,7 @@ export default function RandomMix() {
|
||||
disabled={isDisabled}
|
||||
>
|
||||
{isGenreLoading ? (
|
||||
<><div className="spinner" style={{ width: 14, height: 14, borderWidth: 2 }} /> {Math.min(genreMixSongs.length, 50)} / 50</>
|
||||
<><div className="spinner" style={{ width: 14, height: 14, borderWidth: 2 }} /> {Math.min(genreMixSongs.length, randomMixSize)} / {randomMixSize}</>
|
||||
) : (
|
||||
<><Play size={18} fill="currentColor" /> {t('randomMix.playAll')}</>
|
||||
)}
|
||||
@@ -241,24 +244,58 @@ export default function RandomMix() {
|
||||
{isMobile ? (
|
||||
<button
|
||||
className="btn btn-ghost"
|
||||
style={{ width: '100%', justifyContent: 'space-between', fontSize: 12, fontWeight: 600, textTransform: 'uppercase', letterSpacing: '0.06em', color: 'var(--text-muted)', padding: '0' }}
|
||||
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: 11, fontWeight: 600, textTransform: 'uppercase', letterSpacing: '0.06em', color: 'var(--text-muted)', marginBottom: '0.5rem' }}>
|
||||
<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 }}>
|
||||
<p style={{ fontSize: 12, color: 'var(--text-muted)', marginBottom: '0.75rem', lineHeight: 1.5 }}>
|
||||
{/* ── Mix Settings ── */}
|
||||
<div style={{ fontSize: 10, fontWeight: 600, textTransform: 'uppercase', letterSpacing: '0.06em', color: 'var(--text-muted)', marginBottom: '0.4rem' }}>
|
||||
{t('randomMix.mixSettingsHeader')}
|
||||
</div>
|
||||
|
||||
<p style={{ fontSize: 11, color: 'var(--text-muted)', marginTop: 0, marginBottom: '0.6rem', lineHeight: 1.45, fontStyle: 'italic' }}>
|
||||
{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>
|
||||
|
||||
{/* ── divider ── */}
|
||||
<div style={{ borderTop: '1px solid var(--border)', margin: '0.85rem 0' }} />
|
||||
|
||||
{/* ── Exclusions ── */}
|
||||
<div style={{ fontSize: 10, fontWeight: 600, textTransform: 'uppercase', letterSpacing: '0.06em', color: 'var(--text-muted)', marginBottom: '0.5rem' }}>
|
||||
{t('randomMix.exclusionsHeader')}
|
||||
</div>
|
||||
|
||||
<p style={{ fontSize: 11, color: 'var(--text-muted)', marginBottom: '0.6rem', lineHeight: 1.45 }}>
|
||||
{t('randomMix.filterPanelDesc')}
|
||||
</p>
|
||||
|
||||
<label style={{ display: 'flex', alignItems: 'flex-start', gap: '0.5rem', cursor: 'pointer', fontSize: 13, marginBottom: '0.75rem' }}>
|
||||
<label style={{ display: 'flex', alignItems: 'flex-start', gap: '0.5rem', cursor: 'pointer', fontSize: 13, marginBottom: '0.6rem' }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={excludeAudiobooks}
|
||||
@@ -340,14 +377,14 @@ export default function RandomMix() {
|
||||
{isMobile ? (
|
||||
<button
|
||||
className="btn btn-ghost"
|
||||
style={{ width: '100%', justifyContent: 'space-between', fontSize: 12, fontWeight: 600, textTransform: 'uppercase', letterSpacing: '0.06em', color: 'var(--text-muted)', padding: '0' }}
|
||||
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: 11, fontWeight: 600, textTransform: 'uppercase', letterSpacing: '0.06em', color: 'var(--text-muted)', marginBottom: '0.75rem' }}>
|
||||
<div style={{ fontSize: 14, fontWeight: 700, textTransform: 'uppercase', letterSpacing: '0.05em', color: 'var(--text-secondary)', marginBottom: '0.85rem' }}>
|
||||
{t('randomMix.genreMixTitle')}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -351,6 +351,7 @@ const CONTRIBUTORS = [
|
||||
'Audio: frame-align gapless-off track-separation silence (fixes mono-channel playback after natural track end) (PR #439)',
|
||||
'Settings: 3-state animation mode (Full / Reduced / Static) — replaces boolean reduce-animations toggle (PR #441)',
|
||||
'Tracks: Highly Rated rail and per-card star display, with cache layer for ndListSongs (PR #443)',
|
||||
'Random Mix: playlist-size picker (50/75/100/125/150) and filter-panel layout cleanup (PR #445)',
|
||||
],
|
||||
},
|
||||
] as const;
|
||||
|
||||
Reference in New Issue
Block a user