Implement Navidrome sharing (#575)

* add share item feature

* take care of (mostly) everything

* bugfixes

* allow clicking on notification to open url

* readd the missing modal after router migration

* remove unnecessary extension

---------

Co-authored-by: Kendall Garner <17521368+kgarner7@users.noreply.github.com>
This commit is contained in:
Benjamin 2024-04-21 22:03:22 -05:00 committed by GitHub
parent 0d03b66fe5
commit cb2597d2c8
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
14 changed files with 303 additions and 4 deletions

View file

@ -19,7 +19,8 @@ export const SONG_CONTEXT_MENU_ITEMS: SetContextMenuItems = [
{ divider: true, id: 'addToPlaylist' },
{ id: 'addToFavorites' },
{ divider: true, id: 'removeFromFavorites' },
{ children: true, disabled: false, id: 'setRating' },
{ children: true, disabled: false, divider: true, id: 'setRating' },
{ divider: true, id: 'shareItem' },
{ divider: true, id: 'showDetails' },
];
@ -53,7 +54,8 @@ export const ALBUM_CONTEXT_MENU_ITEMS: SetContextMenuItems = [
{ divider: true, id: 'addToPlaylist' },
{ id: 'addToFavorites' },
{ id: 'removeFromFavorites' },
{ children: true, disabled: false, id: 'setRating' },
{ children: true, disabled: false, divider: true, id: 'setRating' },
{ divider: true, id: 'shareItem' },
{ divider: true, id: 'showDetails' },
];

View file

@ -11,6 +11,8 @@ import {
import { closeAllModals, openContextModal, openModal } from '@mantine/modals';
import { AnimatePresence } from 'framer-motion';
import isElectron from 'is-electron';
import { ServerFeature } from '/@/renderer/api/features-types';
import { hasFeature } from '/@/renderer/api/utils';
import { useTranslation } from 'react-i18next';
import {
RiAddBoxFill,
@ -25,6 +27,7 @@ import {
RiPlayListAddFill,
RiStarFill,
RiCloseCircleLine,
RiShareForwardFill,
RiInformationFill,
} from 'react-icons/ri';
import { AnyLibraryItems, LibraryItem, ServerType, AnyLibraryItem } from '/@/renderer/api/types';
@ -78,7 +81,7 @@ const ContextMenuContext = createContext<ContextMenuContextProps>({
},
});
const JELLYFIN_IGNORED_MENU_ITEMS: ContextMenuItemType[] = ['setRating'];
const JELLYFIN_IGNORED_MENU_ITEMS: ContextMenuItemType[] = ['setRating', 'shareItem'];
// const NAVIDROME_IGNORED_MENU_ITEMS: ContextMenuItemType[] = [];
// const SUBSONIC_IGNORED_MENU_ITEMS: ContextMenuItemType[] = [];
@ -602,6 +605,22 @@ export const ContextMenuProvider = ({ children }: ContextMenuProviderProps) => {
}
}, [ctx.dataNodes, moveToTopOfQueue, playbackType]);
const handleShareItem = useCallback(() => {
if (!ctx.dataNodes && !ctx.data) return;
const uniqueIds = ctx.data.map((node) => node.id);
openContextModal({
innerProps: {
itemIds: uniqueIds,
resourceType: ctx.data[0].itemType,
},
modal: 'shareItem',
size: 'md',
title: t('page.contextMenu.shareItem', { postProcess: 'sentenceCase' }),
});
}, [ctx.data, ctx.dataNodes, t]);
const handleRemoveSelected = useCallback(() => {
const uniqueIds = ctx.dataNodes?.map((row) => row.data.uniqueId);
if (!uniqueIds?.length) return;
@ -787,6 +806,13 @@ export const ContextMenuProvider = ({ children }: ContextMenuProviderProps) => {
onClick: () => {},
rightIcon: <RiArrowRightSFill size="1.2rem" />,
},
shareItem: {
disabled: !hasFeature(server, ServerFeature.SHARING_ALBUM_SONG),
id: 'shareItem',
label: t('page.contextMenu.shareItem', { postProcess: 'sentenceCase' }),
leftIcon: <RiShareForwardFill size="1.1rem" />,
onClick: handleShareItem,
},
showDetails: {
disabled: ctx.data?.length !== 1 || !ctx.data[0].itemType,
id: 'showDetails',
@ -810,6 +836,8 @@ export const ContextMenuProvider = ({ children }: ContextMenuProviderProps) => {
handleOpenItemDetails,
handlePlay,
handleUpdateRating,
handleShareItem,
server,
]);
const mergedRef = useMergedRef(ref, clickOutsideRef);

View file

@ -28,6 +28,7 @@ export type ContextMenuItemType =
| 'addToFavorites'
| 'removeFromFavorites'
| 'setRating'
| 'shareItem'
| 'deletePlaylist'
| 'createPlaylist'
| 'moveToBottomOfQueue'

View file

@ -0,0 +1,144 @@
import { Box, Group, Stack, TextInput } from '@mantine/core';
import { DateTimePicker } from '@mantine/dates';
import { useForm } from '@mantine/form';
import { closeModal, ContextModalProps } from '@mantine/modals';
import { Button, Switch, toast } from '/@/renderer/components';
import { useCurrentServer } from '/@/renderer/store';
import { useTranslation } from 'react-i18next';
import { useShareItem } from '../mutations/share-item-mutation';
export const ShareItemContextModal = ({
id,
innerProps,
}: ContextModalProps<{
itemIds: string[];
resourceType: string;
}>) => {
const { t } = useTranslation();
const { itemIds, resourceType } = innerProps;
const server = useCurrentServer();
const shareItemMutation = useShareItem({});
// Uses the same default as Navidrome: 1 year
const defaultDate = new Date();
defaultDate.setFullYear(defaultDate.getFullYear() + 1);
const form = useForm({
initialValues: {
allowDownloading: false,
description: '',
expires: defaultDate,
},
validate: {
expires: (value) =>
value > new Date()
? null
: t('form.shareItem.expireInvalid', {
postProcess: 'sentenceCase',
}),
},
});
const handleSubmit = form.onSubmit(async (values) => {
shareItemMutation.mutate(
{
body: {
description: values.description,
downloadable: values.allowDownloading,
expires: values.expires.getTime(),
resourceIds: itemIds.join(),
resourceType,
},
serverId: server?.id,
},
{
onError: () => {
toast.error({
message: t('form.shareItem.createFailed', {
postProcess: 'sentenceCase',
}),
});
},
onSuccess: (_data) => {
if (!server) throw new Error('Server not found');
if (!_data?.id) throw new Error('Failed to share item');
const shareUrl = `${server.url}/share/${_data.id}`;
navigator.clipboard.writeText(shareUrl);
toast.success({
autoClose: 5000,
id: 'share-item-toast',
message: t('form.shareItem.success', {
postProcess: 'sentenceCase',
}),
onClick: (a) => {
if (!(a.target instanceof HTMLElement)) return;
// Make sure we weren't clicking close (otherwise clicking close /also/ opens the url)
if (a.target.nodeName !== 'svg') {
window.open(shareUrl);
toast.hide('share-item-toast');
}
},
});
},
},
);
closeModal(id);
return null;
});
return (
<Box p="1rem">
<form onSubmit={handleSubmit}>
<Stack>
<TextInput
label={t('form.shareItem.description', {
postProcess: 'titleCase',
})}
{...form.getInputProps('description')}
/>
<Switch
defaultChecked={false}
label={t('form.shareItem.allowDownloading', {
postProcess: 'titleCase',
})}
{...form.getInputProps('allowDownloading')}
/>
<DateTimePicker
clearable
label={t('form.shareItem.setExpiration', {
postProcess: 'titleCase',
})}
minDate={new Date()}
placeholder={defaultDate.toLocaleDateString()}
popoverProps={{ withinPortal: true }}
valueFormat="MM/DD/YYYY HH:mm"
{...form.getInputProps('expires')}
/>
<Group position="right">
<Group>
<Button
size="md"
variant="subtle"
onClick={() => closeModal(id)}
>
{t('common.cancel', { postProcess: 'titleCase' })}
</Button>
<Button
size="md"
type="submit"
variant="filled"
>
{t('common.share', { postProcess: 'titleCase' })}
</Button>
</Group>
</Group>
</Stack>
</form>
</Box>
);
};

View file

@ -0,0 +1,2 @@
export * from './components/share-item-context-modal';
export * from './mutations/share-item-mutation';

View file

@ -0,0 +1,24 @@
import { useMutation } from '@tanstack/react-query';
import { AnyLibraryItems, ShareItemResponse, ShareItemArgs } from '/@/renderer/api/types';
import { AxiosError } from 'axios';
import { api } from '/@/renderer/api';
import { MutationHookArgs } from '/@/renderer/lib/react-query';
import { getServerById } from '/@/renderer/store';
export const useShareItem = (args: MutationHookArgs) => {
const { options } = args || {};
return useMutation<
ShareItemResponse,
AxiosError,
Omit<ShareItemArgs, 'server' | 'apiClientProps'>,
{ previous: { items: AnyLibraryItems } | undefined }
>({
mutationFn: (args) => {
const server = getServerById(args.serverId);
if (!server) throw new Error('Server not found');
return api.controller.shareItem({ ...args, apiClientProps: { server } });
},
...options,
});
};