fix(playlists): Smart Playlist editor theme, toggles, and exclude-all-genres (#970)

* fix(playlists): Smart Playlist editor theme, toggles, and exclude-all-genres

Replace native sort select with CustomSelect, fix mode-button layout shift,
color-code included vs excluded genres, collapse exclude-all to untagged rule,
and handle empty smart playlists without false "not found".

* docs: CHANGELOG and credits for Smart Playlist editor fix (PR #970)

* chore(credits): drop minor fix entries from PR #958 onward
This commit is contained in:
cucadmuh
2026-06-04 00:22:29 +03:00
committed by GitHub
parent c9b2d140d9
commit 82c414d7bc
12 changed files with 194 additions and 33 deletions
+10
View File
@@ -498,6 +498,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
* Floating mode no longer stretches the player bar between sidebar and queue with fixed `left`/`right` — only the centered pill is painted over the page instead of a full-width black band behind the rounded corners.
### Smart Playlist editor — themed sort, stable toggles, exclude-all genres
**By [@cucadmuh](https://github.com/cucadmuh), reported by zunoz on the Psysonic Discord, PR [#970](https://github.com/Psychotoxical/psysonic/pull/970)**
* Sort dropdown uses the themed `CustomSelect` instead of a native `<select>` whose option list followed system styling.
* Include/Exclude genre and year-range mode buttons no longer jump ~1px when selected — matched button box model and disabled hover translate on mode toggles.
* Selected genres are color-coded (primary for include, danger for exclude) so they are distinguishable from available chips.
* Excluding all genres collapses to a single untagged-genre rule instead of hundreds of `notContains` filters that stalled Navidrome; empty smart playlists settle without a false "Playlist not found" after a long spinner.
### In-page browse — virtual scroll and cover-art priority
**By [@cucadmuh](https://github.com/cucadmuh), PR [#783](https://github.com/Psychotoxical/psysonic/pull/783)**
@@ -1,7 +1,8 @@
import React from 'react';
import React, { useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { Plus } from 'lucide-react';
import StarRating from '../StarRating';
import CustomSelect from '../CustomSelect';
import {
LIMIT_MAX, YEAR_MAX, YEAR_MIN, clampYear, defaultSmartFilters,
type SmartFilters,
@@ -27,6 +28,34 @@ export default function PlaylistsSmartEditor({
}: Props) {
const { t } = useTranslation();
const sortOptions = useMemo(() => ([
{ value: '+random', label: t('smartPlaylists.sortRandom') },
{ value: '+title', label: t('smartPlaylists.sortTitleAsc') },
{ value: '-title', label: t('smartPlaylists.sortTitleDesc') },
{ value: '-year', label: t('smartPlaylists.sortYearDesc') },
{ value: '+year', label: t('smartPlaylists.sortYearAsc') },
{ value: '-playcount', label: t('smartPlaylists.sortPlayCountDesc') },
]), [t]);
const selectedGenreChipClass =
smartFilters.genreMode === 'include' ? 'btn btn-primary' : 'btn btn-danger';
const addGenre = (genre: string) => {
setSmartFilters(v => ({
...v,
untaggedGenresOnly: false,
selectedGenres: [...v.selectedGenres, genre],
}));
};
const removeGenre = (genre: string) => {
setSmartFilters(v => ({
...v,
untaggedGenresOnly: false,
selectedGenres: v.selectedGenres.filter(x => x !== genre),
}));
};
return (
<div style={{ marginBottom: '1rem', border: '1px solid var(--border)', borderRadius: 'var(--radius)', padding: '0.9rem', background: 'var(--bg-card)' }}>
<div style={{ display: 'flex', flexDirection: 'column', gap: '1rem' }}>
@@ -38,22 +67,31 @@ export default function PlaylistsSmartEditor({
<input className="input" type="number" min={1} max={LIMIT_MAX} placeholder={t('smartPlaylists.limit')} value={smartFilters.limit} onChange={e => setSmartFilters(v => ({ ...v, limit: e.target.value }))} />
<span style={{ fontSize: 12, color: 'var(--text-muted)' }}>{t('smartPlaylists.limitHint', { max: LIMIT_MAX })}</span>
</div>
<select className="input" value={smartFilters.sort} onChange={e => setSmartFilters(v => ({ ...v, sort: e.target.value }))}>
<option value="+random">{t('smartPlaylists.sortRandom')}</option>
<option value="+title">{t('smartPlaylists.sortTitleAsc')}</option>
<option value="-title">{t('smartPlaylists.sortTitleDesc')}</option>
<option value="-year">{t('smartPlaylists.sortYearDesc')}</option>
<option value="+year">{t('smartPlaylists.sortYearAsc')}</option>
<option value="-playcount">{t('smartPlaylists.sortPlayCountDesc')}</option>
</select>
<CustomSelect
value={smartFilters.sort}
options={sortOptions}
onChange={sort => setSmartFilters(v => ({ ...v, sort }))}
/>
</div>
</section>
<section style={{ border: '1px solid var(--border)', borderRadius: 'var(--radius-sm)', padding: '0.75rem' }}>
<div style={{ fontSize: 13, fontWeight: 600, marginBottom: '0.65rem' }}>{t('smartPlaylists.sectionGenres')}</div>
<div style={{ display: 'flex', gap: '0.5rem', alignItems: 'center', marginBottom: '0.75rem', flexWrap: 'wrap' }}>
<div className="smart-playlist-mode-toggle" style={{ display: 'flex', gap: '0.5rem', alignItems: 'center', marginBottom: '0.75rem', flexWrap: 'wrap' }}>
<span style={{ fontSize: 13, color: 'var(--text-muted)' }}>{t('smartPlaylists.genreMode')}</span>
<button className={`btn ${smartFilters.genreMode === 'include' ? 'btn-primary' : 'btn-surface'}`} onClick={() => setSmartFilters(v => ({ ...v, genreMode: 'include' }))}>{t('smartPlaylists.genreModeInclude')}</button>
<button className={`btn ${smartFilters.genreMode === 'exclude' ? 'btn-primary' : 'btn-surface'}`} onClick={() => setSmartFilters(v => ({ ...v, genreMode: 'exclude' }))}>{t('smartPlaylists.genreModeExclude')}</button>
<button
type="button"
className={`btn ${smartFilters.genreMode === 'include' ? 'btn-primary' : 'btn-surface'}`}
onClick={() => setSmartFilters(v => ({ ...v, genreMode: 'include', untaggedGenresOnly: false }))}
>
{t('smartPlaylists.genreModeInclude')}
</button>
<button
type="button"
className={`btn ${smartFilters.genreMode === 'exclude' ? 'btn-primary' : 'btn-surface'}`}
onClick={() => setSmartFilters(v => ({ ...v, genreMode: 'exclude', untaggedGenresOnly: false }))}
>
{t('smartPlaylists.genreModeExclude')}
</button>
</div>
<input className="input" placeholder={t('smartPlaylists.genreSearchPlaceholder')} value={genreQuery} onChange={e => setGenreQuery(e.target.value)} style={{ marginBottom: '0.75rem' }} />
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.75rem' }}>
@@ -61,7 +99,15 @@ export default function PlaylistsSmartEditor({
<div style={{ fontSize: 12, color: 'var(--text-muted)', marginBottom: '0.5rem' }}>{t('smartPlaylists.availableGenres')}</div>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.35rem' }}>
{availableGenres.map(g => (
<button key={g} className="btn btn-surface" style={{ fontSize: 12, padding: '2px 8px' }} onClick={() => setSmartFilters(v => ({ ...v, selectedGenres: [...v.selectedGenres, g] }))}>{g}</button>
<button
key={g}
type="button"
className="btn btn-surface"
style={{ fontSize: 12, padding: '2px 8px' }}
onClick={() => addGenre(g)}
>
{g}
</button>
))}
</div>
</div>
@@ -69,7 +115,15 @@ export default function PlaylistsSmartEditor({
<div style={{ fontSize: 12, color: 'var(--text-muted)', marginBottom: '0.5rem' }}>{t('smartPlaylists.selectedGenres')}</div>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.35rem' }}>
{smartFilters.selectedGenres.map(g => (
<button key={g} className="btn btn-surface" style={{ fontSize: 12, padding: '2px 8px' }} onClick={() => setSmartFilters(v => ({ ...v, selectedGenres: v.selectedGenres.filter(x => x !== g) }))}>× {g}</button>
<button
key={g}
type="button"
className={selectedGenreChipClass}
style={{ fontSize: 12, padding: '2px 8px' }}
onClick={() => removeGenre(g)}
>
× {g}
</button>
))}
</div>
</div>
@@ -77,10 +131,22 @@ export default function PlaylistsSmartEditor({
</section>
<section style={{ border: '1px solid var(--border)', borderRadius: 'var(--radius-sm)', padding: '0.75rem' }}>
<div style={{ fontSize: 13, fontWeight: 600, marginBottom: '0.65rem' }}>{t('smartPlaylists.sectionYearsAndFilters')}</div>
<div style={{ display: 'flex', gap: '0.5rem', alignItems: 'center', marginBottom: '0.75rem', flexWrap: 'wrap' }}>
<div className="smart-playlist-mode-toggle" style={{ display: 'flex', gap: '0.5rem', alignItems: 'center', marginBottom: '0.75rem', flexWrap: 'wrap' }}>
<span style={{ fontSize: 13, color: 'var(--text-muted)' }}>{t('smartPlaylists.yearMode')}</span>
<button className={`btn ${smartFilters.yearMode === 'include' ? 'btn-primary' : 'btn-surface'}`} onClick={() => setSmartFilters(v => ({ ...v, yearMode: 'include' }))}>{t('smartPlaylists.yearModeInclude')}</button>
<button className={`btn ${smartFilters.yearMode === 'exclude' ? 'btn-primary' : 'btn-surface'}`} onClick={() => setSmartFilters(v => ({ ...v, yearMode: 'exclude' }))}>{t('smartPlaylists.yearModeExclude')}</button>
<button
type="button"
className={`btn ${smartFilters.yearMode === 'include' ? 'btn-primary' : 'btn-surface'}`}
onClick={() => setSmartFilters(v => ({ ...v, yearMode: 'include' }))}
>
{t('smartPlaylists.yearModeInclude')}
</button>
<button
type="button"
className={`btn ${smartFilters.yearMode === 'exclude' ? 'btn-primary' : 'btn-surface'}`}
onClick={() => setSmartFilters(v => ({ ...v, yearMode: 'exclude' }))}
>
{t('smartPlaylists.yearModeExclude')}
</button>
</div>
<div style={{ display: 'flex', justifyContent: 'space-between', fontSize: 12, color: 'var(--text-muted)' }}>
<span>{t('smartPlaylists.fromYear')}: {smartFilters.yearFrom}</span>
@@ -113,6 +179,7 @@ export default function PlaylistsSmartEditor({
</section>
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: '0.5rem' }}>
<button
type="button"
className="btn btn-surface"
onClick={() => {
setCreatingSmart(false);
@@ -123,7 +190,7 @@ export default function PlaylistsSmartEditor({
>
{t('playlists.cancel')}
</button>
<button className="btn btn-primary" onClick={onSave} disabled={creatingSmartBusy}>
<button type="button" className="btn btn-primary" onClick={onSave} disabled={creatingSmartBusy}>
<Plus size={15} /> {editingSmartId ? t('smartPlaylists.save') : t('smartPlaylists.create')}
</button>
</div>
-4
View File
@@ -151,10 +151,6 @@ const CONTRIBUTOR_ENTRIES = [
'Performance Probe: on-demand (ui) cover throughput alongside backfill (lib) cpm (PR #947)',
'Performance Probe: throughput (analysis tpm, cover cpm) measured over a trailing 5s window so the rate reacts promptly instead of coasting on minute-long inertia (PR #948)',
'Cover backfill: follow the smart local/public endpoint switch so off-LAN clients stop fetching covers from the unreachable local address (PR #952)',
'Player: persist volume/repeat/queue visibility/Last.fm cache outside quota-bound queue blob (report: norp on Psysonic Discord) (PR #958)',
'Player transport: custom delay input capped to browser timer limit; preview/countdown aligned for fractional minutes (report: zunoz on Psysonic Discord) (PR #967)',
'Settings: in-page search indexes AudioMuse + shortcut rows; rejects junk fuzzy matches (report: zunoz on Psysonic Discord) (PR #968)',
'Floating player bar: center shrink-wrapped pill instead of full-width background strip (report: Asra on Psysonic Discord) (PR #969)',
],
},
{
+5 -1
View File
@@ -72,7 +72,11 @@ export function usePendingSmartPolling(
// Wait until we see actual content and cover changed from the first placeholder-ish cover.
// Fallback timeout keeps UI from waiting forever on servers that never update cover id.
const hardTimeoutReached = item.attempts >= 18; // ~3 minutes (18 * 10s)
const ready = songCount > 0 && (!placeholderStillThere || hardTimeoutReached);
const emptySettled = songCount === 0 && item.attempts >= 3; // ~30s — valid empty result
const ready =
hardTimeoutReached
|| emptySettled
|| (songCount > 0 && (!placeholderStillThere || hardTimeoutReached));
if (!ready) {
next.push({
...item,
+2 -2
View File
@@ -119,13 +119,13 @@ export default function Playlists() {
};
const handleOpenSmartEditor = (pl: SubsonicPlaylist) => runPlaylistsOpenSmartEditor({
pl, isNavidromeServer, t,
pl, isNavidromeServer, allGenres: genres, t,
setSmartFilters, setEditingSmartId, setGenreQuery,
setCreating, setCreatingSmart, setCreatingSmartBusy,
});
const handleCreateSmart = () => runPlaylistsSaveSmart({
isNavidromeServer, smartFilters, editingSmartId, playlists, fetchPlaylists, t,
isNavidromeServer, smartFilters, allGenres: genres.map(g => g.value), editingSmartId, playlists, fetchPlaylists, t,
setPendingSmart, setCreatingSmart, setEditingSmartId, setSmartFilters,
setGenreQuery, setCreatingSmartBusy,
});
+7
View File
@@ -154,3 +154,10 @@
opacity: 1;
}
/* Smart playlist editor — mode toggles stay aligned when primary/surface swaps. */
.smart-playlist-mode-toggle .btn-primary:hover {
transform: none;
box-shadow: none;
filter: brightness(1.08);
}
+3
View File
@@ -16,6 +16,9 @@
background: var(--accent);
color: var(--ctp-crust);
font-weight: 600;
/* Match .btn-surface border box so toggling primary/surface does not shift layout. */
border: 1px solid transparent;
box-sizing: border-box;
}
.btn-primary:hover {
+38
View File
@@ -0,0 +1,38 @@
import { describe, expect, it } from 'vitest';
import { buildSmartRulesPayload, defaultSmartFilters, parseSmartRulesToFilters } from './playlistsSmart';
describe('buildSmartRulesPayload', () => {
it('collapses exclude-all-genres into an untagged-only rule', () => {
const filters = {
...defaultSmartFilters,
genreMode: 'exclude' as const,
selectedGenres: ['Rock', 'Jazz', 'Pop'],
};
const rules = buildSmartRulesPayload(filters, { allGenres: ['Rock', 'Jazz', 'Pop'] });
const all = rules.all as Record<string, unknown>[];
expect(all.some(r => (r as { is?: { genre?: string } }).is?.genre === '')).toBe(true);
expect(all.filter(r => 'notContains' in r)).toHaveLength(0);
});
it('keeps per-genre exclusions when only some genres are selected', () => {
const filters = {
...defaultSmartFilters,
genreMode: 'exclude' as const,
selectedGenres: ['Rock'],
};
const rules = buildSmartRulesPayload(filters, { allGenres: ['Rock', 'Jazz'] });
const all = rules.all as Record<string, unknown>[];
expect(all).toContainEqual({ notContains: { genre: 'Rock' } });
});
});
describe('parseSmartRulesToFilters', () => {
it('restores untagged-only exclude rules', () => {
const parsed = parseSmartRulesToFilters(
{ all: [{ is: { genre: '' } }], limit: 50, sort: '+random' },
'psy-smart-test',
);
expect(parsed.untaggedGenresOnly).toBe(true);
expect(parsed.genreMode).toBe('exclude');
});
});
+27 -2
View File
@@ -21,6 +21,13 @@ export type SmartFilters = {
yearFrom: number;
yearTo: number;
yearMode: YearMode;
/** Navidrome `{ is: { genre: '' } }` — tracks with no genre tag. */
untaggedGenresOnly: boolean;
};
export type BuildSmartRulesOptions = {
/** Full genre catalog — used to collapse “exclude every genre” into an untagged-only rule. */
allGenres?: string[];
};
export type PendingSmartPlaylist = {
@@ -47,6 +54,7 @@ export const defaultSmartFilters: SmartFilters = {
yearFrom: YEAR_MIN,
yearTo: YEAR_MAX,
yearMode: 'include',
untaggedGenresOnly: false,
};
export function clampYear(v: number): number {
@@ -104,6 +112,10 @@ export function parseSmartRulesToFilters(
const is = asRecord(obj.is);
if (is?.compilation === true) next.compilationOnly = true;
if (is && is.genre === '') {
next.genreMode = 'exclude';
next.untaggedGenresOnly = true;
}
const notContains = asRecord(obj.notContains);
if (notContains && typeof notContains.genre === 'string') excludeGenres.push(notContains.genre);
@@ -147,7 +159,18 @@ export function parseSmartRulesToFilters(
return next;
}
export function buildSmartRulesPayload(filters: SmartFilters): Record<string, unknown> {
function shouldUseUntaggedGenreRule(filters: SmartFilters, allGenres?: string[]): boolean {
if (filters.untaggedGenresOnly) return true;
if (filters.genreMode !== 'exclude' || filters.selectedGenres.length === 0) return false;
if (!allGenres || allGenres.length === 0) return false;
const selected = new Set(filters.selectedGenres);
return allGenres.every(g => selected.has(g));
}
export function buildSmartRulesPayload(
filters: SmartFilters,
opts?: BuildSmartRulesOptions,
): Record<string, unknown> {
const all: Record<string, unknown>[] = [];
if (filters.artistContains.trim()) all.push({ contains: { artist: filters.artistContains.trim() } });
if (filters.albumContains.trim()) all.push({ contains: { album: filters.albumContains.trim() } });
@@ -158,7 +181,9 @@ export function buildSmartRulesPayload(filters: SmartFilters): Record<string, un
else if (filters.excludeUnrated) all.push({ gt: { rating: 0 } });
if (filters.compilationOnly) all.push({ is: { compilation: true } });
if (filters.selectedGenres.length > 0) {
if (shouldUseUntaggedGenreRule(filters, opts?.allGenres)) {
all.push({ is: { genre: '' } });
} else if (filters.selectedGenres.length > 0) {
if (filters.genreMode === 'include') {
all.push({ any: filters.selectedGenres.map(v => ({ contains: { genre: v } })) });
} else {
+6 -1
View File
@@ -2,6 +2,7 @@ import type React from 'react';
import { getPlaylist } from '../../api/subsonicPlaylists';
import { filterSongsToActiveLibrary } from '../../api/subsonicLibrary';
import type { SubsonicPlaylist, SubsonicSong } from '../../api/subsonicTypes';
import { usePlaylistStore } from '../../store/playlistStore';
export interface RunPlaylistLoadDeps {
id: string;
@@ -31,7 +32,11 @@ export async function runPlaylistLoad(deps: RunPlaylistLoadDeps): Promise<void>
setRatings(init);
setStarredSongs(starred);
} catch {
// intentional swallow; load failure leaves loading false + playlist null
const stub = usePlaylistStore.getState().playlists.find(p => p.id === id);
if (stub) {
setPlaylist(stub);
setSongs([]);
}
} finally {
setLoading(false);
}
@@ -1,7 +1,7 @@
import type React from 'react';
import type { TFunction } from 'i18next';
import { ndGetSmartPlaylist, ndListSmartPlaylists } from '../../api/navidromeSmart';
import type { SubsonicPlaylist } from '../../api/subsonicTypes';
import type { SubsonicGenre, SubsonicPlaylist } from '../../api/subsonicTypes';
import {
defaultSmartFilters, displayPlaylistName, isSmartPlaylistName,
parseSmartRulesToFilters, type SmartFilters,
@@ -11,6 +11,7 @@ import { showToast } from '../ui/toast';
export interface RunPlaylistsOpenSmartEditorDeps {
pl: SubsonicPlaylist;
isNavidromeServer: boolean;
allGenres: SubsonicGenre[];
t: TFunction;
setSmartFilters: React.Dispatch<React.SetStateAction<SmartFilters>>;
setEditingSmartId: React.Dispatch<React.SetStateAction<string | null>>;
@@ -22,7 +23,7 @@ export interface RunPlaylistsOpenSmartEditorDeps {
export async function runPlaylistsOpenSmartEditor(deps: RunPlaylistsOpenSmartEditorDeps): Promise<void> {
const {
pl, isNavidromeServer, t,
pl, isNavidromeServer, allGenres, t,
setSmartFilters, setEditingSmartId, setGenreQuery,
setCreating, setCreatingSmart, setCreatingSmartBusy,
} = deps;
@@ -47,7 +48,11 @@ export async function runPlaylistsOpenSmartEditor(deps: RunPlaylistsOpenSmartEdi
) ?? null;
}
if (target) {
setSmartFilters(parseSmartRulesToFilters(target.rules, target.name));
const parsed = parseSmartRulesToFilters(target.rules, target.name);
if (parsed.untaggedGenresOnly) {
parsed.selectedGenres = allGenres.map(g => g.value);
}
setSmartFilters(parsed);
setEditingSmartId(target.id);
} else {
// Fallback: allow editing even if Navidrome smart list endpoint
+3 -2
View File
@@ -12,6 +12,7 @@ import { showToast } from '../ui/toast';
export interface RunPlaylistsSaveSmartDeps {
isNavidromeServer: boolean;
smartFilters: SmartFilters;
allGenres: string[];
editingSmartId: string | null;
playlists: SubsonicPlaylist[];
fetchPlaylists: () => Promise<void>;
@@ -26,7 +27,7 @@ export interface RunPlaylistsSaveSmartDeps {
export async function runPlaylistsSaveSmart(deps: RunPlaylistsSaveSmartDeps): Promise<void> {
const {
isNavidromeServer, smartFilters, editingSmartId, playlists, fetchPlaylists, t,
isNavidromeServer, smartFilters, allGenres, editingSmartId, playlists, fetchPlaylists, t,
setPendingSmart, setCreatingSmart, setEditingSmartId, setSmartFilters,
setGenreQuery, setCreatingSmartBusy,
} = deps;
@@ -47,7 +48,7 @@ export async function runPlaylistsSaveSmart(deps: RunPlaylistsSaveSmartDeps): Pr
ordinal += 1;
}
}
const rules = buildSmartRulesPayload(smartFilters);
const rules = buildSmartRulesPayload(smartFilters, { allGenres });
const fullName = `${SMART_PREFIX}${baseName}`;
if (editingSmartId) {
await ndUpdateSmartPlaylist(editingSmartId, fullName, rules, true);