mirror of
https://github.com/jeffvli/feishin.git
synced 2026-05-14 20:40:21 +02:00
reimplement smart playlists
This commit is contained in:
@@ -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>
|
||||
|
||||
+13
-50
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user