mirror of
https://github.com/jeffvli/feishin.git
synced 2026-05-10 04:30:25 +02:00
reimplement smart playlists
This commit is contained in:
@@ -8,6 +8,7 @@ import { AppRoute } from '/@/renderer/router/routes';
|
|||||||
import { useCurrentServerId } from '/@/renderer/store';
|
import { useCurrentServerId } from '/@/renderer/store';
|
||||||
import { ContextMenu } from '/@/shared/components/context-menu/context-menu';
|
import { ContextMenu } from '/@/shared/components/context-menu/context-menu';
|
||||||
import { ConfirmModal } from '/@/shared/components/modal/modal';
|
import { ConfirmModal } from '/@/shared/components/modal/modal';
|
||||||
|
import { Text } from '/@/shared/components/text/text';
|
||||||
import { toast } from '/@/shared/components/toast/toast';
|
import { toast } from '/@/shared/components/toast/toast';
|
||||||
import { Playlist } from '/@/shared/types/domain-types';
|
import { Playlist } from '/@/shared/types/domain-types';
|
||||||
|
|
||||||
@@ -21,31 +22,30 @@ export const DeletePlaylistAction = ({ items }: DeletePlaylistActionProps) => {
|
|||||||
const serverId = useCurrentServerId();
|
const serverId = useCurrentServerId();
|
||||||
const deletePlaylistMutation = useDeletePlaylist({});
|
const deletePlaylistMutation = useDeletePlaylist({});
|
||||||
|
|
||||||
const handleDeletePlaylist = useCallback(() => {
|
const handleDeletePlaylist = useCallback(async () => {
|
||||||
if (items.length === 0 || !serverId) return;
|
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();
|
closeAllModals();
|
||||||
}, [deletePlaylistMutation, items, navigate, serverId, t]);
|
}, [deletePlaylistMutation, items, navigate, serverId, t]);
|
||||||
|
|
||||||
@@ -55,7 +55,7 @@ export const DeletePlaylistAction = ({ items }: DeletePlaylistActionProps) => {
|
|||||||
openModal({
|
openModal({
|
||||||
children: (
|
children: (
|
||||||
<ConfirmModal onConfirm={handleDeletePlaylist}>
|
<ConfirmModal onConfirm={handleDeletePlaylist}>
|
||||||
{t('common.areYouSure', { postProcess: 'sentenceCase' })}
|
<Text>{t('common.areYouSure', { postProcess: 'sentenceCase' })}</Text>
|
||||||
</ConfirmModal>
|
</ConfirmModal>
|
||||||
),
|
),
|
||||||
title: t('form.deletePlaylist.title', { postProcess: 'sentenceCase' }),
|
title: t('form.deletePlaylist.title', { postProcess: 'sentenceCase' }),
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import { ListFilters } from '/@/renderer/features/shared/components/list-filters
|
|||||||
import { ListRefreshButton } from '/@/renderer/features/shared/components/list-refresh-button';
|
import { ListRefreshButton } from '/@/renderer/features/shared/components/list-refresh-button';
|
||||||
import { ListSortByDropdown } from '/@/renderer/features/shared/components/list-sort-by-dropdown';
|
import { ListSortByDropdown } from '/@/renderer/features/shared/components/list-sort-by-dropdown';
|
||||||
import { ListSortOrderToggleButton } from '/@/renderer/features/shared/components/list-sort-order-toggle-button';
|
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 { Divider } from '/@/shared/components/divider/divider';
|
||||||
import { Flex } from '/@/shared/components/flex/flex';
|
import { Flex } from '/@/shared/components/flex/flex';
|
||||||
import { Group } from '/@/shared/components/group/group';
|
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';
|
import { ItemListKey } from '/@/shared/types/types';
|
||||||
|
|
||||||
export const GenreListHeaderFilters = () => {
|
export const GenreListHeaderFilters = () => {
|
||||||
const { ref, ...cq } = useContainerQuery();
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Flex justify="space-between">
|
<Flex justify="space-between">
|
||||||
<Group gap="sm" ref={ref} w="100%">
|
<Group gap="sm" w="100%">
|
||||||
<ListSortByDropdown
|
<ListSortByDropdown
|
||||||
defaultSortByValue={GenreListSort.NAME}
|
defaultSortByValue={GenreListSort.NAME}
|
||||||
itemType={LibraryItem.GENRE}
|
itemType={LibraryItem.GENRE}
|
||||||
|
|||||||
@@ -43,18 +43,29 @@ export const CreatePlaylistForm = ({ onCancel }: CreatePlaylistFormProps) => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
const [isSmartPlaylist, setIsSmartPlaylist] = useState(false);
|
const [isSmartPlaylist, setIsSmartPlaylist] = useState(false);
|
||||||
|
const [step, setStep] = useState<1 | 2>(1);
|
||||||
|
|
||||||
const handleSubmit = form.onSubmit((values) => {
|
const handleSubmit = form.onSubmit((values) => {
|
||||||
if (isSmartPlaylist) {
|
if (!server) return;
|
||||||
values._custom!.navidrome = {
|
|
||||||
...values._custom?.navidrome,
|
// If creating a smart playlist and we're on the first step, advance to step 2
|
||||||
rules: queryBuilderRef.current?.getFilters(),
|
// to configure the query instead of submitting immediately.
|
||||||
};
|
if (isSmartPlaylist && step === 1) {
|
||||||
|
setStep(2);
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const smartPlaylist = queryBuilderRef.current?.getFilters();
|
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(
|
mutation.mutate(
|
||||||
{
|
{
|
||||||
@@ -64,16 +75,10 @@ export const CreatePlaylistForm = ({ onCancel }: CreatePlaylistFormProps) => {
|
|||||||
_custom: {
|
_custom: {
|
||||||
navidrome: {
|
navidrome: {
|
||||||
...values._custom?.navidrome,
|
...values._custom?.navidrome,
|
||||||
rules:
|
rules,
|
||||||
isSmartPlaylist && smartPlaylist?.filters
|
|
||||||
? {
|
|
||||||
...convertQueryGroupToNDQuery(smartPlaylist.filters),
|
|
||||||
limit: smartPlaylist.extraFilters.limit,
|
|
||||||
order: smartPlaylist.extraFilters.sortOrder,
|
|
||||||
sort: smartPlaylist.extraFilters.sortBy,
|
|
||||||
}
|
|
||||||
: undefined,
|
|
||||||
},
|
},
|
||||||
|
// Top-level rules field is what Navidrome expects for smart playlists.
|
||||||
|
...(rules ? { rules } : {}),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -100,51 +105,63 @@ export const CreatePlaylistForm = ({ onCancel }: CreatePlaylistFormProps) => {
|
|||||||
return (
|
return (
|
||||||
<form onSubmit={handleSubmit}>
|
<form onSubmit={handleSubmit}>
|
||||||
<Stack>
|
<Stack>
|
||||||
<TextInput
|
{step === 1 && (
|
||||||
data-autofocus
|
<>
|
||||||
label={t('form.createPlaylist.input', {
|
<TextInput
|
||||||
context: 'name',
|
data-autofocus
|
||||||
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
|
|
||||||
label={t('form.createPlaylist.input', {
|
label={t('form.createPlaylist.input', {
|
||||||
context: 'public',
|
context: 'name',
|
||||||
postProcess: 'titleCase',
|
postProcess: 'titleCase',
|
||||||
})}
|
})}
|
||||||
{...form.getInputProps('public', {
|
required
|
||||||
type: 'checkbox',
|
{...form.getInputProps('name')}
|
||||||
})}
|
|
||||||
/>
|
/>
|
||||||
)}
|
{server?.type === ServerType.NAVIDROME && (
|
||||||
{server?.type === ServerType.NAVIDROME &&
|
<Textarea
|
||||||
hasFeature(server, ServerFeature.PLAYLISTS_SMART) && (
|
autosize
|
||||||
<Switch
|
label={t('form.createPlaylist.input', {
|
||||||
label="Is smart playlist?"
|
context: 'description',
|
||||||
onChange={(e) => setIsSmartPlaylist(e.currentTarget.checked)}
|
postProcess: 'titleCase',
|
||||||
|
})}
|
||||||
|
minRows={5}
|
||||||
|
{...form.getInputProps('comment')}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</Group>
|
<Group>
|
||||||
{server?.type === ServerType.NAVIDROME && isSmartPlaylist && (
|
{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">
|
<Stack pt="1rem">
|
||||||
<Text>Query Editor</Text>
|
<Text>Query Editor</Text>
|
||||||
<PlaylistQueryBuilder
|
<PlaylistQueryBuilder
|
||||||
isSaving={false}
|
isSaving={mutation.isPending}
|
||||||
limit={undefined}
|
limit={undefined}
|
||||||
query={undefined}
|
query={undefined}
|
||||||
ref={queryBuilderRef}
|
ref={queryBuilderRef}
|
||||||
@@ -155,6 +172,11 @@ export const CreatePlaylistForm = ({ onCancel }: CreatePlaylistFormProps) => {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<Group justify="flex-end">
|
<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">
|
<ModalButton onClick={onCancel} px="2xl" uppercase variant="subtle">
|
||||||
{t('common.cancel')}
|
{t('common.cancel')}
|
||||||
</ModalButton>
|
</ModalButton>
|
||||||
@@ -164,7 +186,9 @@ export const CreatePlaylistForm = ({ onCancel }: CreatePlaylistFormProps) => {
|
|||||||
type="submit"
|
type="submit"
|
||||||
variant="filled"
|
variant="filled"
|
||||||
>
|
>
|
||||||
{t('common.create')}
|
{isSmartPlaylist && step === 1
|
||||||
|
? t('common.confirm', { postProcess: 'sentenceCase' })
|
||||||
|
: t('common.create')}
|
||||||
</ModalButton>
|
</ModalButton>
|
||||||
</Group>
|
</Group>
|
||||||
</Stack>
|
</Stack>
|
||||||
|
|||||||
+13
-50
@@ -1,80 +1,42 @@
|
|||||||
import { closeAllModals, openModal } from '@mantine/modals';
|
|
||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
import { useCallback } from 'react';
|
import { useParams } from 'react-router';
|
||||||
import { useTranslation } from 'react-i18next';
|
|
||||||
import { useNavigate, 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 { 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 { 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 { ListConfigMenu } from '/@/renderer/features/shared/components/list-config-menu';
|
||||||
import { ListRefreshButton } from '/@/renderer/features/shared/components/list-refresh-button';
|
import { ListRefreshButton } from '/@/renderer/features/shared/components/list-refresh-button';
|
||||||
import { ListSortByDropdown } from '/@/renderer/features/shared/components/list-sort-by-dropdown';
|
import { ListSortByDropdown } from '/@/renderer/features/shared/components/list-sort-by-dropdown';
|
||||||
import { ListSortOrderToggleButton } from '/@/renderer/features/shared/components/list-sort-order-toggle-button';
|
import { ListSortOrderToggleButton } from '/@/renderer/features/shared/components/list-sort-order-toggle-button';
|
||||||
import { useContainerQuery } from '/@/renderer/hooks';
|
import { MoreButton } from '/@/renderer/features/shared/components/more-button';
|
||||||
import { AppRoute } from '/@/renderer/router/routes';
|
|
||||||
import { useCurrentServerId } from '/@/renderer/store';
|
import { useCurrentServerId } from '/@/renderer/store';
|
||||||
import { Divider } from '/@/shared/components/divider/divider';
|
import { Divider } from '/@/shared/components/divider/divider';
|
||||||
import { Flex } from '/@/shared/components/flex/flex';
|
import { Flex } from '/@/shared/components/flex/flex';
|
||||||
import { Group } from '/@/shared/components/group/group';
|
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 { LibraryItem, SongListSort, SortOrder } from '/@/shared/types/domain-types';
|
||||||
import { ItemListKey, Play } from '/@/shared/types/types';
|
import { ItemListKey } from '/@/shared/types/types';
|
||||||
|
|
||||||
export const PlaylistDetailSongListHeaderFilters = () => {
|
export const PlaylistDetailSongListHeaderFilters = () => {
|
||||||
const { t } = useTranslation();
|
|
||||||
const { playlistId } = useParams() as { playlistId: string };
|
const { playlistId } = useParams() as { playlistId: string };
|
||||||
const serverId = useCurrentServerId();
|
const serverId = useCurrentServerId();
|
||||||
|
|
||||||
const navigate = useNavigate();
|
|
||||||
|
|
||||||
const detailQuery = useQuery(playlistsQueries.detail({ query: { id: playlistId }, serverId }));
|
const detailQuery = useQuery(playlistsQueries.detail({ query: { id: playlistId }, serverId }));
|
||||||
|
|
||||||
const isSmartPlaylist = detailQuery.data?.rules;
|
const handleMore = (event: React.MouseEvent<HTMLButtonElement>) => {
|
||||||
|
|
||||||
const { ref, ...cq } = useContainerQuery();
|
|
||||||
|
|
||||||
const deletePlaylistMutation = useDeletePlaylist({});
|
|
||||||
|
|
||||||
const handleDeletePlaylist = useCallback(() => {
|
|
||||||
if (!detailQuery.data) return;
|
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 = () => {
|
ContextMenuController.call({
|
||||||
openModal({
|
cmd: {
|
||||||
children: (
|
items: [detailQuery.data],
|
||||||
<ConfirmModal onConfirm={handleDeletePlaylist}>
|
type: LibraryItem.PLAYLIST,
|
||||||
<Text>Are you sure you want to delete this playlist?</Text>
|
},
|
||||||
</ConfirmModal>
|
event,
|
||||||
),
|
|
||||||
title: t('form.deletePlaylist.title', { postProcess: 'sentenceCase' }),
|
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Flex justify="space-between">
|
<Flex justify="space-between">
|
||||||
<Group gap="sm" ref={ref} w="100%">
|
<Group gap="sm" w="100%">
|
||||||
<ListSortByDropdown
|
<ListSortByDropdown
|
||||||
defaultSortByValue={SongListSort.ID}
|
defaultSortByValue={SongListSort.ID}
|
||||||
itemType={LibraryItem.PLAYLIST_SONG}
|
itemType={LibraryItem.PLAYLIST_SONG}
|
||||||
@@ -86,6 +48,7 @@ export const PlaylistDetailSongListHeaderFilters = () => {
|
|||||||
listKey={ItemListKey.PLAYLIST_SONG}
|
listKey={ItemListKey.PLAYLIST_SONG}
|
||||||
/>
|
/>
|
||||||
<ListRefreshButton listKey={ItemListKey.PLAYLIST_SONG} />
|
<ListRefreshButton listKey={ItemListKey.PLAYLIST_SONG} />
|
||||||
|
<MoreButton onClick={handleMore} />
|
||||||
</Group>
|
</Group>
|
||||||
<Group gap="sm" wrap="nowrap">
|
<Group gap="sm" wrap="nowrap">
|
||||||
<ListConfigMenu
|
<ListConfigMenu
|
||||||
|
|||||||
@@ -11,12 +11,19 @@ import { LibraryHeaderBar } from '/@/renderer/features/shared/components/library
|
|||||||
import { ListSearchInput } from '/@/renderer/features/shared/components/list-search-input';
|
import { ListSearchInput } from '/@/renderer/features/shared/components/list-search-input';
|
||||||
import { useCurrentServer } from '/@/renderer/store';
|
import { useCurrentServer } from '/@/renderer/store';
|
||||||
import { formatDurationString } from '/@/renderer/utils';
|
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 { Stack } from '/@/shared/components/stack/stack';
|
||||||
import { LibraryItem } from '/@/shared/types/domain-types';
|
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 { t } = useTranslation();
|
||||||
const { playlistId } = useParams() as { playlistId: string };
|
const { playlistId } = useParams() as { playlistId: string };
|
||||||
const { itemCount } = useListContext();
|
const { itemCount } = useListContext();
|
||||||
@@ -26,7 +33,7 @@ export const PlaylistDetailSongListHeader = () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
if (detailQuery.isLoading) return null;
|
if (detailQuery.isLoading) return null;
|
||||||
const isSmartPlaylist = detailQuery?.data?.rules;
|
const isSmartPlaylist = isSmartPlaylistProp ?? detailQuery?.data?.rules;
|
||||||
const playlistDuration = detailQuery?.data?.duration;
|
const playlistDuration = detailQuery?.data?.duration;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -38,15 +45,17 @@ export const PlaylistDetailSongListHeader = () => {
|
|||||||
itemType={LibraryItem.PLAYLIST}
|
itemType={LibraryItem.PLAYLIST}
|
||||||
/>
|
/>
|
||||||
<LibraryHeaderBar.Title>{detailQuery?.data?.name}</LibraryHeaderBar.Title>
|
<LibraryHeaderBar.Title>{detailQuery?.data?.name}</LibraryHeaderBar.Title>
|
||||||
{!!playlistDuration && <Badge>{formatDurationString(playlistDuration)}</Badge>}
|
{isSmartPlaylist && (
|
||||||
<Badge>
|
<LibraryHeaderBar.Badge>{t('entity.smartPlaylist')}</LibraryHeaderBar.Badge>
|
||||||
{itemCount === null || itemCount === undefined ? (
|
)}
|
||||||
<SpinnerIcon />
|
{!!playlistDuration && (
|
||||||
) : (
|
<LibraryHeaderBar.Badge>
|
||||||
itemCount
|
{formatDurationString(playlistDuration)}
|
||||||
)}
|
</LibraryHeaderBar.Badge>
|
||||||
</Badge>
|
)}
|
||||||
{isSmartPlaylist && <Badge size="lg">{t('entity.smartPlaylist')}</Badge>}
|
<LibraryHeaderBar.Badge isLoading={!itemCount}>
|
||||||
|
{itemCount}
|
||||||
|
</LibraryHeaderBar.Badge>
|
||||||
</LibraryHeaderBar>
|
</LibraryHeaderBar>
|
||||||
<ListSearchInput />
|
<ListSearchInput />
|
||||||
</PageHeader>
|
</PageHeader>
|
||||||
|
|||||||
@@ -3,6 +3,11 @@ import { AxiosError } from 'axios';
|
|||||||
|
|
||||||
import { api } from '/@/renderer/api';
|
import { api } from '/@/renderer/api';
|
||||||
import { queryKeys } from '/@/renderer/api/query-keys';
|
import { queryKeys } from '/@/renderer/api/query-keys';
|
||||||
|
import {
|
||||||
|
applyDeletePlaylistOptimisticUpdates,
|
||||||
|
PreviousQueryData,
|
||||||
|
restorePlaylistQueryData,
|
||||||
|
} from '/@/renderer/features/playlists/mutations/playlist-optimistic-updates';
|
||||||
import { MutationHookArgs } from '/@/renderer/lib/react-query';
|
import { MutationHookArgs } from '/@/renderer/lib/react-query';
|
||||||
import { DeletePlaylistArgs, DeletePlaylistResponse } from '/@/shared/types/domain-types';
|
import { DeletePlaylistArgs, DeletePlaylistResponse } from '/@/shared/types/domain-types';
|
||||||
|
|
||||||
@@ -10,25 +15,32 @@ export const useDeletePlaylist = (args: MutationHookArgs) => {
|
|||||||
const { options } = args || {};
|
const { options } = args || {};
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
return useMutation<DeletePlaylistResponse, AxiosError, DeletePlaylistArgs, null>({
|
return useMutation<DeletePlaylistResponse, AxiosError, DeletePlaylistArgs, PreviousQueryData[]>(
|
||||||
mutationFn: (args) => {
|
{
|
||||||
return api.controller.deletePlaylist({
|
mutationFn: (args) => {
|
||||||
...args,
|
return api.controller.deletePlaylist({
|
||||||
apiClientProps: { serverId: args.apiClientProps.serverId },
|
...args,
|
||||||
});
|
apiClientProps: { serverId: args.apiClientProps.serverId },
|
||||||
|
});
|
||||||
|
},
|
||||||
|
onError: (_error, _variables, context) => {
|
||||||
|
if (context) {
|
||||||
|
restorePlaylistQueryData(queryClient, context);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onMutate: (variables) => {
|
||||||
|
queryClient.cancelQueries({
|
||||||
|
queryKey: queryKeys.playlists.list(variables.apiClientProps.serverId),
|
||||||
|
});
|
||||||
|
return applyDeletePlaylistOptimisticUpdates(queryClient, variables);
|
||||||
|
},
|
||||||
|
onSuccess: (_data, variables) => {
|
||||||
|
queryClient.invalidateQueries({
|
||||||
|
exact: false,
|
||||||
|
queryKey: queryKeys.playlists.list(variables.apiClientProps.serverId),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
...options,
|
||||||
},
|
},
|
||||||
onMutate: (variables) => {
|
);
|
||||||
queryClient.cancelQueries({
|
|
||||||
queryKey: queryKeys.playlists.list(variables.apiClientProps.serverId),
|
|
||||||
});
|
|
||||||
return null;
|
|
||||||
},
|
|
||||||
onSuccess: (_data, variables) => {
|
|
||||||
queryClient.invalidateQueries({
|
|
||||||
exact: false,
|
|
||||||
queryKey: queryKeys.playlists.list(variables.apiClientProps.serverId),
|
|
||||||
});
|
|
||||||
},
|
|
||||||
...options,
|
|
||||||
});
|
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,149 @@
|
|||||||
|
import { QueryClient } from '@tanstack/react-query';
|
||||||
|
|
||||||
|
import { queryKeys } from '/@/renderer/api/query-keys';
|
||||||
|
import { infiniteLoaderDataQueryKey } from '/@/renderer/components/item-list/helpers/item-list-infinite-loader';
|
||||||
|
import {
|
||||||
|
DeletePlaylistArgs,
|
||||||
|
LibraryItem,
|
||||||
|
Playlist,
|
||||||
|
PlaylistListResponse,
|
||||||
|
} from '/@/shared/types/domain-types';
|
||||||
|
|
||||||
|
export interface PreviousQueryData {
|
||||||
|
data: unknown;
|
||||||
|
queryKey: readonly unknown[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export const applyDeletePlaylistOptimisticUpdates = (
|
||||||
|
queryClient: QueryClient,
|
||||||
|
variables: DeletePlaylistArgs,
|
||||||
|
): PreviousQueryData[] => {
|
||||||
|
const previousQueries: PreviousQueryData[] = [];
|
||||||
|
const playlistId = variables.query.id;
|
||||||
|
|
||||||
|
// Update detail query - remove it
|
||||||
|
const detailQueryKey = queryKeys.playlists.detail(
|
||||||
|
variables.apiClientProps.serverId,
|
||||||
|
playlistId,
|
||||||
|
);
|
||||||
|
|
||||||
|
const detailQueries = queryClient.getQueriesData({
|
||||||
|
exact: false,
|
||||||
|
queryKey: detailQueryKey,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (detailQueries.length) {
|
||||||
|
detailQueries.forEach(([queryKey, data]) => {
|
||||||
|
if (data) {
|
||||||
|
previousQueries.push({ data, queryKey });
|
||||||
|
queryClient.setQueryData(queryKey, undefined);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update list queries - remove the playlist from items
|
||||||
|
const listQueryKey = queryKeys.playlists.list(variables.apiClientProps.serverId);
|
||||||
|
|
||||||
|
const listQueries = queryClient.getQueriesData({
|
||||||
|
exact: false,
|
||||||
|
queryKey: listQueryKey,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (listQueries.length) {
|
||||||
|
listQueries.forEach(([queryKey, data]) => {
|
||||||
|
if (data) {
|
||||||
|
previousQueries.push({ data, queryKey });
|
||||||
|
queryClient.setQueryData(queryKey, (prev: PlaylistListResponse | undefined) => {
|
||||||
|
if (prev) {
|
||||||
|
return {
|
||||||
|
...prev,
|
||||||
|
items: prev.items.filter((item: Playlist) => item.id !== playlistId),
|
||||||
|
totalRecordCount: Math.max(
|
||||||
|
0,
|
||||||
|
(prev.totalRecordCount || prev.items.length) - 1,
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return prev;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update infinite loader queries - remove the playlist from data array
|
||||||
|
const infiniteLoaderQueryKey = infiniteLoaderDataQueryKey(
|
||||||
|
variables.apiClientProps.serverId,
|
||||||
|
LibraryItem.PLAYLIST,
|
||||||
|
);
|
||||||
|
|
||||||
|
const infiniteLoaderQueries = queryClient.getQueriesData({
|
||||||
|
exact: false,
|
||||||
|
queryKey: infiniteLoaderQueryKey,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (infiniteLoaderQueries.length) {
|
||||||
|
infiniteLoaderQueries.forEach(([queryKey, data]) => {
|
||||||
|
if (data) {
|
||||||
|
previousQueries.push({ data, queryKey });
|
||||||
|
queryClient.setQueryData(
|
||||||
|
queryKey,
|
||||||
|
(
|
||||||
|
prev:
|
||||||
|
| undefined
|
||||||
|
| {
|
||||||
|
data: unknown[];
|
||||||
|
pagesLoaded: Record<string, boolean>;
|
||||||
|
},
|
||||||
|
) => {
|
||||||
|
if (prev && prev.data) {
|
||||||
|
return {
|
||||||
|
...prev,
|
||||||
|
data: prev.data.filter((item: any) => {
|
||||||
|
if (!item || !item.id) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return item.id !== playlistId;
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return prev;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update songList query - remove it
|
||||||
|
const songListQueryKey = queryKeys.playlists.songList(
|
||||||
|
variables.apiClientProps.serverId,
|
||||||
|
playlistId,
|
||||||
|
);
|
||||||
|
|
||||||
|
const songListQueries = queryClient.getQueriesData({
|
||||||
|
exact: false,
|
||||||
|
queryKey: songListQueryKey,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (songListQueries.length) {
|
||||||
|
songListQueries.forEach(([queryKey, data]) => {
|
||||||
|
if (data) {
|
||||||
|
previousQueries.push({ data, queryKey });
|
||||||
|
queryClient.setQueryData(queryKey, undefined);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return previousQueries;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const restorePlaylistQueryData = (
|
||||||
|
queryClient: QueryClient,
|
||||||
|
previousQueries: PreviousQueryData[],
|
||||||
|
): void => {
|
||||||
|
previousQueries.forEach(({ data, queryKey }) => {
|
||||||
|
queryClient.setQueryData(queryKey, data);
|
||||||
|
});
|
||||||
|
};
|
||||||
@@ -1,11 +1,10 @@
|
|||||||
import { closeAllModals, openModal } from '@mantine/modals';
|
import { closeAllModals, openModal } from '@mantine/modals';
|
||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
import { motion } from 'motion/react';
|
import { motion } from 'motion/react';
|
||||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { generatePath, useNavigate, useParams } from 'react-router';
|
import { generatePath, useNavigate, useParams } from 'react-router';
|
||||||
|
|
||||||
import { ItemListHandle } from '/@/renderer/components/item-list/types';
|
|
||||||
import { ListContext } from '/@/renderer/context/list-context';
|
import { ListContext } from '/@/renderer/context/list-context';
|
||||||
import { playlistsQueries } from '/@/renderer/features/playlists/api/playlists-api';
|
import { playlistsQueries } from '/@/renderer/features/playlists/api/playlists-api';
|
||||||
import { PlaylistDetailSongListContent } from '/@/renderer/features/playlists/components/playlist-detail-song-list-content';
|
import { PlaylistDetailSongListContent } from '/@/renderer/features/playlists/components/playlist-detail-song-list-content';
|
||||||
@@ -22,15 +21,15 @@ import { useCurrentServer } from '/@/renderer/store';
|
|||||||
import { ActionIcon } from '/@/shared/components/action-icon/action-icon';
|
import { ActionIcon } from '/@/shared/components/action-icon/action-icon';
|
||||||
import { Box } from '/@/shared/components/box/box';
|
import { Box } from '/@/shared/components/box/box';
|
||||||
import { Group } from '/@/shared/components/group/group';
|
import { Group } from '/@/shared/components/group/group';
|
||||||
|
import { ConfirmModal } from '/@/shared/components/modal/modal';
|
||||||
import { Text } from '/@/shared/components/text/text';
|
import { Text } from '/@/shared/components/text/text';
|
||||||
import { toast } from '/@/shared/components/toast/toast';
|
import { toast } from '/@/shared/components/toast/toast';
|
||||||
import { ServerType, SongListSort } from '/@/shared/types/domain-types';
|
import { ServerType, SongListSort } from '/@/shared/types/domain-types';
|
||||||
import { ItemListKey, Play } from '/@/shared/types/types';
|
import { ItemListKey } from '/@/shared/types/types';
|
||||||
|
|
||||||
const PlaylistDetailSongListRoute = () => {
|
const PlaylistDetailSongListRoute = () => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const tableRef = useRef<ItemListHandle | null>(null);
|
|
||||||
const { playlistId } = useParams() as { playlistId: string };
|
const { playlistId } = useParams() as { playlistId: string };
|
||||||
const server = useCurrentServer();
|
const server = useCurrentServer();
|
||||||
|
|
||||||
@@ -44,6 +43,8 @@ const PlaylistDetailSongListRoute = () => {
|
|||||||
filter: Record<string, any>,
|
filter: Record<string, any>,
|
||||||
extraFilters: { limit?: number; sortBy?: string; sortOrder?: string },
|
extraFilters: { limit?: number; sortBy?: string; sortOrder?: string },
|
||||||
) => {
|
) => {
|
||||||
|
if (!detailQuery?.data) return;
|
||||||
|
|
||||||
const rules = {
|
const rules = {
|
||||||
...filter,
|
...filter,
|
||||||
limit: extraFilters.limit || undefined,
|
limit: extraFilters.limit || undefined,
|
||||||
@@ -51,19 +52,15 @@ const PlaylistDetailSongListRoute = () => {
|
|||||||
sort: extraFilters.sortBy || 'dateAdded',
|
sort: extraFilters.sortBy || 'dateAdded',
|
||||||
};
|
};
|
||||||
|
|
||||||
if (!detailQuery?.data) return;
|
|
||||||
|
|
||||||
createPlaylistMutation.mutate(
|
createPlaylistMutation.mutate(
|
||||||
{
|
{
|
||||||
apiClientProps: { serverId: detailQuery?.data?._serverId },
|
apiClientProps: { serverId: detailQuery?.data?._serverId },
|
||||||
body: {
|
body: {
|
||||||
_custom: {
|
_custom: {
|
||||||
navidrome: {
|
owner: detailQuery?.data?.owner || '',
|
||||||
owner: detailQuery?.data?.owner || '',
|
ownerId: detailQuery?.data?.ownerId || '',
|
||||||
ownerId: detailQuery?.data?.ownerId || '',
|
rules,
|
||||||
rules,
|
sync: detailQuery?.data?.sync || false,
|
||||||
sync: detailQuery?.data?.sync || false,
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
comment: detailQuery?.data?.description || '',
|
comment: detailQuery?.data?.description || '',
|
||||||
name: detailQuery?.data?.name,
|
name: detailQuery?.data?.name,
|
||||||
@@ -94,6 +91,15 @@ const PlaylistDetailSongListRoute = () => {
|
|||||||
filter: Record<string, any>,
|
filter: Record<string, any>,
|
||||||
extraFilters: { limit?: number; sortBy?: string; sortOrder?: string },
|
extraFilters: { limit?: number; sortBy?: string; sortOrder?: string },
|
||||||
) => {
|
) => {
|
||||||
|
if (!detailQuery?.data) return;
|
||||||
|
|
||||||
|
const rules = {
|
||||||
|
...filter,
|
||||||
|
limit: extraFilters.limit || undefined,
|
||||||
|
order: extraFilters.sortOrder || 'desc',
|
||||||
|
sort: extraFilters.sortBy || 'dateAdded',
|
||||||
|
};
|
||||||
|
|
||||||
openModal({
|
openModal({
|
||||||
children: (
|
children: (
|
||||||
<SaveAsPlaylistForm
|
<SaveAsPlaylistForm
|
||||||
@@ -102,14 +108,11 @@ const PlaylistDetailSongListRoute = () => {
|
|||||||
navidrome: {
|
navidrome: {
|
||||||
owner: detailQuery?.data?.owner || '',
|
owner: detailQuery?.data?.owner || '',
|
||||||
ownerId: detailQuery?.data?.ownerId || '',
|
ownerId: detailQuery?.data?.ownerId || '',
|
||||||
rules: {
|
rules,
|
||||||
...filter,
|
|
||||||
limit: extraFilters.limit || undefined,
|
|
||||||
order: extraFilters.sortOrder || 'desc',
|
|
||||||
sort: extraFilters.sortBy || 'dateAdded',
|
|
||||||
},
|
|
||||||
sync: detailQuery?.data?.sync || false,
|
sync: detailQuery?.data?.sync || false,
|
||||||
},
|
},
|
||||||
|
rules,
|
||||||
|
sync: detailQuery?.data?.sync || false,
|
||||||
},
|
},
|
||||||
comment: detailQuery?.data?.description || '',
|
comment: detailQuery?.data?.description || '',
|
||||||
name: detailQuery?.data?.name,
|
name: detailQuery?.data?.name,
|
||||||
@@ -130,6 +133,41 @@ const PlaylistDetailSongListRoute = () => {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const openDeletePlaylistModal = () => {
|
||||||
|
openModal({
|
||||||
|
children: (
|
||||||
|
<ConfirmModal
|
||||||
|
onConfirm={() => {
|
||||||
|
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();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text>Are you sure you want to delete this playlist?</Text>
|
||||||
|
</ConfirmModal>
|
||||||
|
),
|
||||||
|
title: t('form.deletePlaylist.title', { postProcess: 'sentenceCase' }),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
const isSmartPlaylist =
|
const isSmartPlaylist =
|
||||||
!detailQuery?.isLoading &&
|
!detailQuery?.isLoading &&
|
||||||
detailQuery?.data?.rules &&
|
detailQuery?.data?.rules &&
|
||||||
@@ -158,13 +196,6 @@ const PlaylistDetailSongListRoute = () => {
|
|||||||
|
|
||||||
const [itemCount, setItemCount] = useState<number | undefined>(undefined);
|
const [itemCount, setItemCount] = useState<number | undefined>(undefined);
|
||||||
|
|
||||||
const handlePlay = (_play: Play) => {
|
|
||||||
// handlePlayQueueAdd?.({
|
|
||||||
// byData: filterSortedSongs,
|
|
||||||
// playType: play,
|
|
||||||
// });
|
|
||||||
};
|
|
||||||
|
|
||||||
const providerValue = useMemo(() => {
|
const providerValue = useMemo(() => {
|
||||||
return {
|
return {
|
||||||
customFilters: undefined,
|
customFilters: undefined,
|
||||||
@@ -175,7 +206,6 @@ const PlaylistDetailSongListRoute = () => {
|
|||||||
};
|
};
|
||||||
}, [playlistId, itemCount]);
|
}, [playlistId, itemCount]);
|
||||||
|
|
||||||
// Update item count when playlist songs are loaded
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (
|
if (
|
||||||
playlistSongs.data?.totalRecordCount !== undefined &&
|
playlistSongs.data?.totalRecordCount !== undefined &&
|
||||||
@@ -190,12 +220,16 @@ const PlaylistDetailSongListRoute = () => {
|
|||||||
<ListContext.Provider value={providerValue}>
|
<ListContext.Provider value={providerValue}>
|
||||||
<LibraryContainer>
|
<LibraryContainer>
|
||||||
<PlaylistDetailSongListHeader
|
<PlaylistDetailSongListHeader
|
||||||
handlePlay={handlePlay}
|
isSmartPlaylist={!!isSmartPlaylist}
|
||||||
handleToggleShowQueryBuilder={handleToggleShowQueryBuilder}
|
onConvertToSmart={() => {
|
||||||
itemCount={itemCount}
|
if (!isSmartPlaylist) {
|
||||||
tableRef={tableRef}
|
setShowQueryBuilder(true);
|
||||||
|
setIsQueryBuilderExpanded(true);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onDelete={() => openDeletePlaylistModal()}
|
||||||
|
onToggleQueryBuilder={handleToggleShowQueryBuilder}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{(isSmartPlaylist || showQueryBuilder) && (
|
{(isSmartPlaylist || showQueryBuilder) && (
|
||||||
<motion.div>
|
<motion.div>
|
||||||
<Box h="100%" mah="35vh" p="md" w="100%">
|
<Box h="100%" mah="35vh" p="md" w="100%">
|
||||||
|
|||||||
Reference in New Issue
Block a user