From 06d0c715afae29255156feb68991898581a2daf6 Mon Sep 17 00:00:00 2001 From: jeffvli Date: Fri, 28 Nov 2025 21:27:27 -0800 Subject: [PATCH] reimplement smart playlists --- .../actions/delete-playlist-action.tsx | 46 +++--- .../components/genre-list-header-filters.tsx | 5 +- .../components/create-playlist-form.tsx | 128 +++++++++------ ...aylist-detail-song-list-header-filters.tsx | 63 ++------ .../playlist-detail-song-list-header.tsx | 35 ++-- .../mutations/delete-playlist-mutation.ts | 52 +++--- .../mutations/playlist-optimistic-updates.ts | 149 ++++++++++++++++++ .../playlist-detail-song-list-route.tsx | 96 +++++++---- 8 files changed, 381 insertions(+), 193 deletions(-) create mode 100644 src/renderer/features/playlists/mutations/playlist-optimistic-updates.ts diff --git a/src/renderer/features/context-menu/actions/delete-playlist-action.tsx b/src/renderer/features/context-menu/actions/delete-playlist-action.tsx index ef8ab53af..ac0f20008 100644 --- a/src/renderer/features/context-menu/actions/delete-playlist-action.tsx +++ b/src/renderer/features/context-menu/actions/delete-playlist-action.tsx @@ -8,6 +8,7 @@ import { AppRoute } from '/@/renderer/router/routes'; import { useCurrentServerId } from '/@/renderer/store'; import { ContextMenu } from '/@/shared/components/context-menu/context-menu'; import { ConfirmModal } from '/@/shared/components/modal/modal'; +import { Text } from '/@/shared/components/text/text'; import { toast } from '/@/shared/components/toast/toast'; import { Playlist } from '/@/shared/types/domain-types'; @@ -21,31 +22,30 @@ export const DeletePlaylistAction = ({ items }: DeletePlaylistActionProps) => { const serverId = useCurrentServerId(); const deletePlaylistMutation = useDeletePlaylist({}); - const handleDeletePlaylist = useCallback(() => { + const handleDeletePlaylist = useCallback(async () => { if (items.length === 0 || !serverId) return; - const playlist = items[0]; + try { + await Promise.all( + items.map((playlist) => + deletePlaylistMutation.mutateAsync({ + apiClientProps: { serverId }, + query: { id: playlist.id }, + }), + ), + ); + + navigate(AppRoute.PLAYLISTS, { replace: true }); + toast.success({ + message: t('action.deletePlaylist', { postProcess: 'sentenceCase' }), + }); + } catch (err: any) { + toast.error({ + message: err.message, + title: t('error.genericError', { postProcess: 'sentenceCase' }), + }); + } - deletePlaylistMutation.mutate( - { - apiClientProps: { serverId }, - query: { id: playlist.id }, - }, - { - onError: (err) => { - toast.error({ - message: err.message, - title: t('error.genericError', { postProcess: 'sentenceCase' }), - }); - }, - onSuccess: () => { - navigate(AppRoute.PLAYLISTS, { replace: true }); - toast.success({ - message: t('action.deletePlaylist', { postProcess: 'sentenceCase' }), - }); - }, - }, - ); closeAllModals(); }, [deletePlaylistMutation, items, navigate, serverId, t]); @@ -55,7 +55,7 @@ export const DeletePlaylistAction = ({ items }: DeletePlaylistActionProps) => { openModal({ children: ( - {t('common.areYouSure', { postProcess: 'sentenceCase' })} + {t('common.areYouSure', { postProcess: 'sentenceCase' })} ), title: t('form.deletePlaylist.title', { postProcess: 'sentenceCase' }), diff --git a/src/renderer/features/genres/components/genre-list-header-filters.tsx b/src/renderer/features/genres/components/genre-list-header-filters.tsx index 7ea7d791b..28e7592c2 100644 --- a/src/renderer/features/genres/components/genre-list-header-filters.tsx +++ b/src/renderer/features/genres/components/genre-list-header-filters.tsx @@ -4,7 +4,6 @@ import { ListFilters } from '/@/renderer/features/shared/components/list-filters import { ListRefreshButton } from '/@/renderer/features/shared/components/list-refresh-button'; import { ListSortByDropdown } from '/@/renderer/features/shared/components/list-sort-by-dropdown'; import { ListSortOrderToggleButton } from '/@/renderer/features/shared/components/list-sort-order-toggle-button'; -import { useContainerQuery } from '/@/renderer/hooks'; import { Divider } from '/@/shared/components/divider/divider'; import { Flex } from '/@/shared/components/flex/flex'; import { Group } from '/@/shared/components/group/group'; @@ -12,11 +11,9 @@ import { GenreListSort, LibraryItem, SortOrder } from '/@/shared/types/domain-ty import { ItemListKey } from '/@/shared/types/types'; export const GenreListHeaderFilters = () => { - const { ref, ...cq } = useContainerQuery(); - return ( - + { }, }); const [isSmartPlaylist, setIsSmartPlaylist] = useState(false); + const [step, setStep] = useState<1 | 2>(1); const handleSubmit = form.onSubmit((values) => { - if (isSmartPlaylist) { - values._custom!.navidrome = { - ...values._custom?.navidrome, - rules: queryBuilderRef.current?.getFilters(), - }; + if (!server) return; + + // If creating a smart playlist and we're on the first step, advance to step 2 + // to configure the query instead of submitting immediately. + if (isSmartPlaylist && step === 1) { + setStep(2); + return; } const smartPlaylist = queryBuilderRef.current?.getFilters(); - if (!server) return; + const rules = + isSmartPlaylist && smartPlaylist?.filters + ? { + ...convertQueryGroupToNDQuery(smartPlaylist.filters), + limit: smartPlaylist.extraFilters.limit, + order: smartPlaylist.extraFilters.sortOrder, + sort: smartPlaylist.extraFilters.sortBy, + } + : undefined; mutation.mutate( { @@ -64,16 +75,10 @@ export const CreatePlaylistForm = ({ onCancel }: CreatePlaylistFormProps) => { _custom: { navidrome: { ...values._custom?.navidrome, - rules: - isSmartPlaylist && smartPlaylist?.filters - ? { - ...convertQueryGroupToNDQuery(smartPlaylist.filters), - limit: smartPlaylist.extraFilters.limit, - order: smartPlaylist.extraFilters.sortOrder, - sort: smartPlaylist.extraFilters.sortBy, - } - : undefined, + rules, }, + // Top-level rules field is what Navidrome expects for smart playlists. + ...(rules ? { rules } : {}), }, }, }, @@ -100,51 +105,63 @@ export const CreatePlaylistForm = ({ onCancel }: CreatePlaylistFormProps) => { return (
- - {server?.type === ServerType.NAVIDROME && ( -