From 82c414d7bce8881904007995c2ee36445ea98b37 Mon Sep 17 00:00:00 2001 From: cucadmuh <49571317+cucadmuh@users.noreply.github.com> Date: Thu, 4 Jun 2026 00:22:29 +0300 Subject: [PATCH] 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 --- CHANGELOG.md | 10 ++ .../playlists/PlaylistsSmartEditor.tsx | 103 +++++++++++++++--- src/config/settingsCredits.ts | 4 - src/hooks/usePendingSmartPolling.ts | 6 +- src/pages/Playlists.tsx | 4 +- src/styles/components/playlists-page.css | 7 ++ src/styles/themes/button-variants.css | 3 + src/utils/playlist/playlistsSmart.test.ts | 38 +++++++ src/utils/playlist/playlistsSmart.ts | 29 ++++- src/utils/playlist/runPlaylistLoad.ts | 7 +- .../playlist/runPlaylistsOpenSmartEditor.ts | 11 +- src/utils/playlist/runPlaylistsSaveSmart.ts | 5 +- 12 files changed, 194 insertions(+), 33 deletions(-) create mode 100644 src/utils/playlist/playlistsSmart.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index ecae8d99..98f1a458 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 ` setSmartFilters(v => ({ ...v, limit: e.target.value }))} /> {t('smartPlaylists.limitHint', { max: LIMIT_MAX })} - + setSmartFilters(v => ({ ...v, sort }))} + />
{t('smartPlaylists.sectionGenres')}
-
+
{t('smartPlaylists.genreMode')} - - + +
setGenreQuery(e.target.value)} style={{ marginBottom: '0.75rem' }} />
@@ -61,7 +99,15 @@ export default function PlaylistsSmartEditor({
{t('smartPlaylists.availableGenres')}
{availableGenres.map(g => ( - + ))}
@@ -69,7 +115,15 @@ export default function PlaylistsSmartEditor({
{t('smartPlaylists.selectedGenres')}
{smartFilters.selectedGenres.map(g => ( - + ))}
@@ -77,10 +131,22 @@ export default function PlaylistsSmartEditor({
{t('smartPlaylists.sectionYearsAndFilters')}
-
+
{t('smartPlaylists.yearMode')} - - + +
{t('smartPlaylists.fromYear')}: {smartFilters.yearFrom} @@ -113,6 +179,7 @@ export default function PlaylistsSmartEditor({
-
diff --git a/src/config/settingsCredits.ts b/src/config/settingsCredits.ts index 07dc3d2c..73735beb 100644 --- a/src/config/settingsCredits.ts +++ b/src/config/settingsCredits.ts @@ -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)', ], }, { diff --git a/src/hooks/usePendingSmartPolling.ts b/src/hooks/usePendingSmartPolling.ts index 73e29372..df6eb14b 100644 --- a/src/hooks/usePendingSmartPolling.ts +++ b/src/hooks/usePendingSmartPolling.ts @@ -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, diff --git a/src/pages/Playlists.tsx b/src/pages/Playlists.tsx index 4ac547e6..37b27116 100644 --- a/src/pages/Playlists.tsx +++ b/src/pages/Playlists.tsx @@ -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, }); diff --git a/src/styles/components/playlists-page.css b/src/styles/components/playlists-page.css index ca68caec..caa64136 100644 --- a/src/styles/components/playlists-page.css +++ b/src/styles/components/playlists-page.css @@ -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); +} + diff --git a/src/styles/themes/button-variants.css b/src/styles/themes/button-variants.css index 7895c450..2fa9df7f 100644 --- a/src/styles/themes/button-variants.css +++ b/src/styles/themes/button-variants.css @@ -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 { diff --git a/src/utils/playlist/playlistsSmart.test.ts b/src/utils/playlist/playlistsSmart.test.ts new file mode 100644 index 00000000..1f620d32 --- /dev/null +++ b/src/utils/playlist/playlistsSmart.test.ts @@ -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[]; + 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[]; + 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'); + }); +}); diff --git a/src/utils/playlist/playlistsSmart.ts b/src/utils/playlist/playlistsSmart.ts index c01affb2..5ed3c6bb 100644 --- a/src/utils/playlist/playlistsSmart.ts +++ b/src/utils/playlist/playlistsSmart.ts @@ -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 { +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 { const all: Record[] = []; 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 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 { diff --git a/src/utils/playlist/runPlaylistLoad.ts b/src/utils/playlist/runPlaylistLoad.ts index d6ebdc43..538ac2c8 100644 --- a/src/utils/playlist/runPlaylistLoad.ts +++ b/src/utils/playlist/runPlaylistLoad.ts @@ -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 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); } diff --git a/src/utils/playlist/runPlaylistsOpenSmartEditor.ts b/src/utils/playlist/runPlaylistsOpenSmartEditor.ts index 6f01ae1c..a742ac3c 100644 --- a/src/utils/playlist/runPlaylistsOpenSmartEditor.ts +++ b/src/utils/playlist/runPlaylistsOpenSmartEditor.ts @@ -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>; setEditingSmartId: React.Dispatch>; @@ -22,7 +23,7 @@ export interface RunPlaylistsOpenSmartEditorDeps { export async function runPlaylistsOpenSmartEditor(deps: RunPlaylistsOpenSmartEditorDeps): Promise { 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 diff --git a/src/utils/playlist/runPlaylistsSaveSmart.ts b/src/utils/playlist/runPlaylistsSaveSmart.ts index 69ce6476..8684826a 100644 --- a/src/utils/playlist/runPlaylistsSaveSmart.ts +++ b/src/utils/playlist/runPlaylistsSaveSmart.ts @@ -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; @@ -26,7 +27,7 @@ export interface RunPlaylistsSaveSmartDeps { export async function runPlaylistsSaveSmart(deps: RunPlaylistsSaveSmartDeps): Promise { 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);