reimplement smart playlists

This commit is contained in:
jeffvli
2025-11-28 21:27:27 -08:00
parent 9d3c44ef15
commit 06d0c715af
8 changed files with 381 additions and 193 deletions
@@ -43,18 +43,29 @@ export const CreatePlaylistForm = ({ onCancel }: CreatePlaylistFormProps) => {
},
});
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 (
<form onSubmit={handleSubmit}>
<Stack>
<TextInput
data-autofocus
label={t('form.createPlaylist.input', {
context: 'name',
postProcess: 'titleCase',
})}
required
{...form.getInputProps('name')}
/>
{server?.type === ServerType.NAVIDROME && (
<Textarea
autosize
label={t('form.createPlaylist.input', {
context: 'description',
postProcess: 'titleCase',
})}
minRows={5}
{...form.getInputProps('comment')}
/>
)}
<Group>
{isPublicDisplayed && (
<Switch
{step === 1 && (
<>
<TextInput
data-autofocus
label={t('form.createPlaylist.input', {
context: 'public',
context: 'name',
postProcess: 'titleCase',
})}
{...form.getInputProps('public', {
type: 'checkbox',
})}
required
{...form.getInputProps('name')}
/>
)}
{server?.type === ServerType.NAVIDROME &&
hasFeature(server, ServerFeature.PLAYLISTS_SMART) && (
<Switch
label="Is smart playlist?"
onChange={(e) => setIsSmartPlaylist(e.currentTarget.checked)}
{server?.type === ServerType.NAVIDROME && (
<Textarea
autosize
label={t('form.createPlaylist.input', {
context: 'description',
postProcess: 'titleCase',
})}
minRows={5}
{...form.getInputProps('comment')}
/>
)}
</Group>
{server?.type === ServerType.NAVIDROME && isSmartPlaylist && (
<Group>
{isPublicDisplayed && (
<Switch
label={t('form.createPlaylist.input', {
context: 'public',
postProcess: 'titleCase',
})}
{...form.getInputProps('public', {
type: 'checkbox',
})}
/>
)}
{server?.type === ServerType.NAVIDROME &&
hasFeature(server, ServerFeature.PLAYLISTS_SMART) && (
<Switch
checked={isSmartPlaylist}
label="Is smart playlist?"
onChange={(e) => {
const next = e.currentTarget.checked;
setIsSmartPlaylist(next);
if (!next) {
setStep(1);
}
}}
/>
)}
</Group>
</>
)}
{isSmartPlaylist && step === 2 && (
<Stack pt="1rem">
<Text>Query Editor</Text>
<PlaylistQueryBuilder
isSaving={false}
isSaving={mutation.isPending}
limit={undefined}
query={undefined}
ref={queryBuilderRef}
@@ -155,6 +172,11 @@ export const CreatePlaylistForm = ({ onCancel }: CreatePlaylistFormProps) => {
)}
<Group justify="flex-end">
{isSmartPlaylist && step === 2 && (
<ModalButton onClick={() => setStep(1)} px="2xl" uppercase variant="subtle">
Back
</ModalButton>
)}
<ModalButton onClick={onCancel} px="2xl" uppercase variant="subtle">
{t('common.cancel')}
</ModalButton>
@@ -164,7 +186,9 @@ export const CreatePlaylistForm = ({ onCancel }: CreatePlaylistFormProps) => {
type="submit"
variant="filled"
>
{t('common.create')}
{isSmartPlaylist && step === 1
? t('common.confirm', { postProcess: 'sentenceCase' })
: t('common.create')}
</ModalButton>
</Group>
</Stack>
@@ -1,80 +1,42 @@
import { closeAllModals, openModal } from '@mantine/modals';
import { useQuery } from '@tanstack/react-query';
import { useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { useNavigate, useParams } from 'react-router';
import { useParams } from 'react-router';
import i18n from '/@/i18n/i18n';
import { PLAYLIST_SONG_TABLE_COLUMNS } from '/@/renderer/components/item-list/item-table-list/default-columns';
import { ContextMenuController } from '/@/renderer/features/context-menu/context-menu-controller';
import { playlistsQueries } from '/@/renderer/features/playlists/api/playlists-api';
import { useDeletePlaylist } from '/@/renderer/features/playlists/mutations/delete-playlist-mutation';
import { ListConfigMenu } from '/@/renderer/features/shared/components/list-config-menu';
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 { AppRoute } from '/@/renderer/router/routes';
import { MoreButton } from '/@/renderer/features/shared/components/more-button';
import { useCurrentServerId } from '/@/renderer/store';
import { Divider } from '/@/shared/components/divider/divider';
import { Flex } from '/@/shared/components/flex/flex';
import { Group } from '/@/shared/components/group/group';
import { ConfirmModal } from '/@/shared/components/modal/modal';
import { Text } from '/@/shared/components/text/text';
import { toast } from '/@/shared/components/toast/toast';
import { LibraryItem, SongListSort, SortOrder } from '/@/shared/types/domain-types';
import { ItemListKey, Play } from '/@/shared/types/types';
import { ItemListKey } from '/@/shared/types/types';
export const PlaylistDetailSongListHeaderFilters = () => {
const { t } = useTranslation();
const { playlistId } = useParams() as { playlistId: string };
const serverId = useCurrentServerId();
const navigate = useNavigate();
const detailQuery = useQuery(playlistsQueries.detail({ query: { id: playlistId }, serverId }));
const isSmartPlaylist = detailQuery.data?.rules;
const { ref, ...cq } = useContainerQuery();
const deletePlaylistMutation = useDeletePlaylist({});
const handleDeletePlaylist = useCallback(() => {
const handleMore = (event: React.MouseEvent<HTMLButtonElement>) => {
if (!detailQuery.data) return;
deletePlaylistMutation?.mutate(
{
apiClientProps: { serverId: detailQuery.data._serverId },
query: { id: detailQuery.data.id },
},
{
onError: (err) => {
toast.error({
message: err.message,
title: t('error.genericError', { postProcess: 'sentenceCase' }),
});
},
onSuccess: () => {
navigate(AppRoute.PLAYLISTS, { replace: true });
},
},
);
closeAllModals();
}, [deletePlaylistMutation, detailQuery.data, navigate, t]);
const openDeletePlaylistModal = () => {
openModal({
children: (
<ConfirmModal onConfirm={handleDeletePlaylist}>
<Text>Are you sure you want to delete this playlist?</Text>
</ConfirmModal>
),
title: t('form.deletePlaylist.title', { postProcess: 'sentenceCase' }),
ContextMenuController.call({
cmd: {
items: [detailQuery.data],
type: LibraryItem.PLAYLIST,
},
event,
});
};
return (
<Flex justify="space-between">
<Group gap="sm" ref={ref} w="100%">
<Group gap="sm" w="100%">
<ListSortByDropdown
defaultSortByValue={SongListSort.ID}
itemType={LibraryItem.PLAYLIST_SONG}
@@ -86,6 +48,7 @@ export const PlaylistDetailSongListHeaderFilters = () => {
listKey={ItemListKey.PLAYLIST_SONG}
/>
<ListRefreshButton listKey={ItemListKey.PLAYLIST_SONG} />
<MoreButton onClick={handleMore} />
</Group>
<Group gap="sm" wrap="nowrap">
<ListConfigMenu
@@ -11,12 +11,19 @@ import { LibraryHeaderBar } from '/@/renderer/features/shared/components/library
import { ListSearchInput } from '/@/renderer/features/shared/components/list-search-input';
import { useCurrentServer } from '/@/renderer/store';
import { formatDurationString } from '/@/renderer/utils';
import { Badge } from '/@/shared/components/badge/badge';
import { SpinnerIcon } from '/@/shared/components/spinner/spinner';
import { Stack } from '/@/shared/components/stack/stack';
import { LibraryItem } from '/@/shared/types/domain-types';
export const PlaylistDetailSongListHeader = () => {
interface PlaylistDetailSongListHeaderProps {
isSmartPlaylist?: boolean;
onConvertToSmart?: () => void;
onDelete?: () => void;
onToggleQueryBuilder?: () => void;
}
export const PlaylistDetailSongListHeader = ({
isSmartPlaylist: isSmartPlaylistProp,
}: PlaylistDetailSongListHeaderProps) => {
const { t } = useTranslation();
const { playlistId } = useParams() as { playlistId: string };
const { itemCount } = useListContext();
@@ -26,7 +33,7 @@ export const PlaylistDetailSongListHeader = () => {
);
if (detailQuery.isLoading) return null;
const isSmartPlaylist = detailQuery?.data?.rules;
const isSmartPlaylist = isSmartPlaylistProp ?? detailQuery?.data?.rules;
const playlistDuration = detailQuery?.data?.duration;
return (
@@ -38,15 +45,17 @@ export const PlaylistDetailSongListHeader = () => {
itemType={LibraryItem.PLAYLIST}
/>
<LibraryHeaderBar.Title>{detailQuery?.data?.name}</LibraryHeaderBar.Title>
{!!playlistDuration && <Badge>{formatDurationString(playlistDuration)}</Badge>}
<Badge>
{itemCount === null || itemCount === undefined ? (
<SpinnerIcon />
) : (
itemCount
)}
</Badge>
{isSmartPlaylist && <Badge size="lg">{t('entity.smartPlaylist')}</Badge>}
{isSmartPlaylist && (
<LibraryHeaderBar.Badge>{t('entity.smartPlaylist')}</LibraryHeaderBar.Badge>
)}
{!!playlistDuration && (
<LibraryHeaderBar.Badge>
{formatDurationString(playlistDuration)}
</LibraryHeaderBar.Badge>
)}
<LibraryHeaderBar.Badge isLoading={!itemCount}>
{itemCount}
</LibraryHeaderBar.Badge>
</LibraryHeaderBar>
<ListSearchInput />
</PageHeader>