diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 89f61fa84..5bc4bcb0c 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -101,6 +101,7 @@ "setting": "setting", "setting_one": "setting", "setting_other": "settings", + "share": "share", "size": "size", "sortOrder": "order", "title": "title", @@ -257,6 +258,14 @@ "input_optionMatchAll": "match all", "input_optionMatchAny": "match any" }, + "shareItem": { + "allowDownloading": "allow downloading", + "description": "description", + "setExpiration": "set expiration", + "success": "share link copied to clipboard (or click here to open)", + "expireInvalid": "expiration must be in the future", + "createFailed": "failed to create share (is sharing enabled?)" + }, "updateServer": { "success": "server updated successfully", "title": "update server" @@ -315,6 +324,7 @@ "removeFromPlaylist": "$t(action.removeFromPlaylist)", "removeFromQueue": "$t(action.removeFromQueue)", "setRating": "$t(action.setRating)", + "shareItem": "share item", "showDetails": "get info" }, "fullscreenPlayer": { diff --git a/src/renderer/api/controller.ts b/src/renderer/api/controller.ts index f9414f6ab..6face864b 100644 --- a/src/renderer/api/controller.ts +++ b/src/renderer/api/controller.ts @@ -8,6 +8,7 @@ import type { AlbumArtistDetailArgs, AlbumArtistListArgs, SetRatingArgs, + ShareItemArgs, GenreListArgs, CreatePlaylistArgs, DeletePlaylistArgs, @@ -55,6 +56,7 @@ import type { SimilarSongsArgs, Song, ServerType, + ShareItemResponse, } from '/@/renderer/api/types'; import { DeletePlaylistResponse, RandomSongListArgs } from './types'; import { ndController } from '/@/renderer/api/navidrome/navidrome-controller'; @@ -102,6 +104,7 @@ export type ControllerEndpoint = Partial<{ scrobble: (args: ScrobbleArgs) => Promise; search: (args: SearchArgs) => Promise; setRating: (args: SetRatingArgs) => Promise; + shareItem: (args: ShareItemArgs) => Promise; updatePlaylist: (args: UpdatePlaylistArgs) => Promise; }>; @@ -149,6 +152,7 @@ const endpoints: ApiController = { scrobble: jfController.scrobble, search: jfController.search, setRating: undefined, + shareItem: undefined, updatePlaylist: jfController.updatePlaylist, }, navidrome: { @@ -188,6 +192,7 @@ const endpoints: ApiController = { scrobble: ssController.scrobble, search: ssController.search3, setRating: ssController.setRating, + shareItem: ndController.shareItem, updatePlaylist: ndController.updatePlaylist, }, subsonic: { @@ -223,6 +228,7 @@ const endpoints: ApiController = { scrobble: ssController.scrobble, search: ssController.search3, setRating: undefined, + shareItem: undefined, updatePlaylist: undefined, }, }; @@ -457,6 +463,15 @@ const updateRating = async (args: SetRatingArgs) => { )?.(args); }; +const shareItem = async (args: ShareItemArgs) => { + return ( + apiController( + 'shareItem', + args.apiClientProps.server?.type, + ) as ControllerEndpoint['shareItem'] + )?.(args); +}; + const getTopSongList = async (args: TopSongListArgs) => { return ( apiController( @@ -555,6 +570,7 @@ export const controller = { removeFromPlaylist, scrobble, search, + shareItem, updatePlaylist, updateRating, }; diff --git a/src/renderer/api/features-types.ts b/src/renderer/api/features-types.ts index 779c846eb..f1ccc3e76 100644 --- a/src/renderer/api/features-types.ts +++ b/src/renderer/api/features-types.ts @@ -4,6 +4,7 @@ export enum ServerFeature { LYRICS_MULTIPLE_STRUCTURED = 'lyricsMultipleStructured', LYRICS_SINGLE_STRUCTURED = 'lyricsSingleStructured', PLAYLISTS_SMART = 'playlistsSmart', + SHARING_ALBUM_SONG = 'sharingAlbumSong', } export type ServerFeatures = Partial>; diff --git a/src/renderer/api/navidrome/navidrome-api.ts b/src/renderer/api/navidrome/navidrome-api.ts index b9eb93f9f..535a1535d 100644 --- a/src/renderer/api/navidrome/navidrome-api.ts +++ b/src/renderer/api/navidrome/navidrome-api.ts @@ -157,6 +157,16 @@ export const contract = c.router({ 500: resultWithHeaders(ndType._response.error), }, }, + shareItem: { + body: ndType._parameters.shareItem, + method: 'POST', + path: 'share', + responses: { + 200: resultWithHeaders(ndType._response.shareItem), + 404: resultWithHeaders(ndType._response.error), + 500: resultWithHeaders(ndType._response.error), + }, + }, updatePlaylist: { body: ndType._parameters.updatePlaylist, method: 'PUT', diff --git a/src/renderer/api/navidrome/navidrome-controller.ts b/src/renderer/api/navidrome/navidrome-controller.ts index b427ef97d..232df3d32 100644 --- a/src/renderer/api/navidrome/navidrome-controller.ts +++ b/src/renderer/api/navidrome/navidrome-controller.ts @@ -47,6 +47,8 @@ import { genreListSortMap, ServerInfo, ServerInfoArgs, + ShareItemArgs, + ShareItemResponse, SimilarSongsArgs, Song, } from '../types'; @@ -484,7 +486,10 @@ const removeFromPlaylist = async ( return null; }; +// The order should be in decreasing version, as the highest version match +// will automatically consider all lower versions matched const VERSION_INFO: Array<[string, Record]> = [ + ['0.49.3', { [ServerFeature.SHARING_ALBUM_SONG]: [1] }], ['0.48.0', { [ServerFeature.PLAYLISTS_SMART]: [1] }], ]; @@ -544,11 +549,34 @@ const getServerInfo = async (args: ServerInfoArgs): Promise => { const features: ServerFeatures = { lyricsMultipleStructured: !!navidromeFeatures[SubsonicExtensions.SONG_LYRICS], playlistsSmart: !!navidromeFeatures[ServerFeature.PLAYLISTS_SMART], + sharingAlbumSong: !!navidromeFeatures[ServerFeature.SHARING_ALBUM_SONG], }; return { features, id: apiClientProps.server?.id, version: ping.body.serverVersion! }; }; +const shareItem = async (args: ShareItemArgs): Promise => { + const { body, apiClientProps } = args; + + const res = await ndApiClient(apiClientProps).shareItem({ + body: { + description: body.description, + downloadable: body.downloadable, + expires: body.expires, + resourceIds: body.resourceIds, + resourceType: body.resourceType, + }, + }); + + if (res.status !== 200) { + throw new Error('Failed to share item'); + } + + return { + id: res.body.data.id, + }; +}; + const getSimilarSongs = async (args: SimilarSongsArgs): Promise => { const { apiClientProps, query } = args; @@ -620,5 +648,6 @@ export const ndController = { getSongList, getUserList, removeFromPlaylist, + shareItem, updatePlaylist, }; diff --git a/src/renderer/api/navidrome/navidrome-types.ts b/src/renderer/api/navidrome/navidrome-types.ts index b63b5442b..f7f587ee9 100644 --- a/src/renderer/api/navidrome/navidrome-types.ts +++ b/src/renderer/api/navidrome/navidrome-types.ts @@ -343,6 +343,18 @@ const removeFromPlaylistParameters = z.object({ id: z.array(z.string()), }); +const shareItem = z.object({ + id: z.string(), +}); + +const shareItemParameters = z.object({ + description: z.string(), + downloadable: z.boolean(), + expires: z.number(), + resourceIds: z.string(), + resourceType: z.string(), +}); + export const ndType = { _enum: { albumArtistList: ndAlbumArtistListSort, @@ -361,6 +373,7 @@ export const ndType = { genreList: genreListParameters, playlistList: playlistListParameters, removeFromPlaylist: removeFromPlaylistParameters, + shareItem: shareItemParameters, songList: songListParameters, updatePlaylist: updatePlaylistParameters, userList: userListParameters, @@ -382,6 +395,7 @@ export const ndType = { playlistSong, playlistSongList, removeFromPlaylist, + shareItem, song, songList, updatePlaylist, diff --git a/src/renderer/api/types.ts b/src/renderer/api/types.ts index 96889aec3..78d77d557 100644 --- a/src/renderer/api/types.ts +++ b/src/renderer/api/types.ts @@ -766,6 +766,19 @@ export type RatingQuery = { export type SetRatingArgs = { query: RatingQuery; serverId?: string } & BaseEndpointArgs; +// Sharing +export type ShareItemResponse = { id: string } | undefined; + +export type ShareItemBody = { + description: string; + downloadable: boolean; + expires: number; + resourceIds: string; + resourceType: string; +}; + +export type ShareItemArgs = { body: ShareItemBody; serverId?: string } & BaseEndpointArgs; + // Add to playlist export type AddToPlaylistResponse = null | undefined; diff --git a/src/renderer/features/context-menu/context-menu-items.tsx b/src/renderer/features/context-menu/context-menu-items.tsx index eecef08a3..33ae47433 100644 --- a/src/renderer/features/context-menu/context-menu-items.tsx +++ b/src/renderer/features/context-menu/context-menu-items.tsx @@ -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' }, ]; diff --git a/src/renderer/features/context-menu/context-menu-provider.tsx b/src/renderer/features/context-menu/context-menu-provider.tsx index 914051e63..5bb0a11a6 100644 --- a/src/renderer/features/context-menu/context-menu-provider.tsx +++ b/src/renderer/features/context-menu/context-menu-provider.tsx @@ -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({ }, }); -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: , }, + shareItem: { + disabled: !hasFeature(server, ServerFeature.SHARING_ALBUM_SONG), + id: 'shareItem', + label: t('page.contextMenu.shareItem', { postProcess: 'sentenceCase' }), + leftIcon: , + 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); diff --git a/src/renderer/features/context-menu/events.ts b/src/renderer/features/context-menu/events.ts index ac3a0d8af..0e7242997 100644 --- a/src/renderer/features/context-menu/events.ts +++ b/src/renderer/features/context-menu/events.ts @@ -28,6 +28,7 @@ export type ContextMenuItemType = | 'addToFavorites' | 'removeFromFavorites' | 'setRating' + | 'shareItem' | 'deletePlaylist' | 'createPlaylist' | 'moveToBottomOfQueue' diff --git a/src/renderer/features/sharing/components/share-item-context-modal.tsx b/src/renderer/features/sharing/components/share-item-context-modal.tsx new file mode 100644 index 000000000..ca9150937 --- /dev/null +++ b/src/renderer/features/sharing/components/share-item-context-modal.tsx @@ -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 ( + +
+ + + + + + + + + + + +
+
+ ); +}; diff --git a/src/renderer/features/sharing/index.ts b/src/renderer/features/sharing/index.ts new file mode 100644 index 000000000..74f988e20 --- /dev/null +++ b/src/renderer/features/sharing/index.ts @@ -0,0 +1,2 @@ +export * from './components/share-item-context-modal'; +export * from './mutations/share-item-mutation'; diff --git a/src/renderer/features/sharing/mutations/share-item-mutation.ts b/src/renderer/features/sharing/mutations/share-item-mutation.ts new file mode 100644 index 000000000..ae774df0a --- /dev/null +++ b/src/renderer/features/sharing/mutations/share-item-mutation.ts @@ -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, + { 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, + }); +}; diff --git a/src/renderer/router/app-router.tsx b/src/renderer/router/app-router.tsx index 5749c1384..81086c124 100644 --- a/src/renderer/router/app-router.tsx +++ b/src/renderer/router/app-router.tsx @@ -7,6 +7,7 @@ import { TitlebarOutlet } from '/@/renderer/router/titlebar-outlet'; import { ModalsProvider } from '@mantine/modals'; import { BaseContextModal } from '/@/renderer/components'; import { AddToPlaylistContextModal } from '/@/renderer/features/playlists'; +import { ShareItemContextModal } from '/@/renderer/features/sharing'; const NowPlayingRoute = lazy( () => import('/@/renderer/features/now-playing/routes/now-playing-route'), @@ -80,7 +81,11 @@ export const AppRouter = () => { transition: 'fade', }, }} - modals={{ addToPlaylist: AddToPlaylistContextModal, base: BaseContextModal }} + modals={{ + addToPlaylist: AddToPlaylistContextModal, + base: BaseContextModal, + shareItem: ShareItemContextModal, + }} > }>