import { createContext, Fragment, ReactNode, useState, useMemo, useCallback } from 'react'; import { RowNode } from '@ag-grid-community/core'; import { Divider, Group, Portal, Stack } from '@mantine/core'; import { useClickOutside, useMergedRef, useResizeObserver, useSetState, useViewportSize, } from '@mantine/hooks'; import { closeAllModals, openContextModal, openModal } from '@mantine/modals'; import { AnimatePresence } from 'framer-motion'; import isElectron from 'is-electron'; import { useTranslation } from 'react-i18next'; import { RiAddBoxFill, RiAddCircleFill, RiArrowDownLine, RiArrowRightSFill, RiArrowUpLine, RiDeleteBinFill, RiDislikeFill, RiHeartFill, RiPlayFill, RiPlayListAddFill, RiStarFill, RiCloseCircleLine, } from 'react-icons/ri'; import { AnyLibraryItems, LibraryItem, ServerType, AnyLibraryItem } from '/@/renderer/api/types'; import { ConfirmModal, ContextMenu, ContextMenuButton, HoverCard, Rating, Text, toast, } from '/@/renderer/components'; import { ContextMenuItemType, OpenContextMenuProps, useContextMenuEvents, } from '/@/renderer/features/context-menu/events'; import { usePlayQueueAdd } from '/@/renderer/features/player'; import { useDeletePlaylist } from '/@/renderer/features/playlists'; import { useRemoveFromPlaylist } from '/@/renderer/features/playlists/mutations/remove-from-playlist-mutation'; import { useCreateFavorite, useDeleteFavorite, useSetRating } from '/@/renderer/features/shared'; import { useAuthStore, useCurrentServer, usePlayerStore, useQueueControls, } from '/@/renderer/store'; import { usePlaybackType } from '/@/renderer/store/settings.store'; import { Play, PlaybackType } from '/@/renderer/types'; type ContextMenuContextProps = { closeContextMenu: () => void; openContextMenu: (args: OpenContextMenuProps) => void; }; type ContextMenuItem = { children?: ContextMenuItem[]; disabled?: boolean; id: string; label: string | ReactNode; leftIcon?: ReactNode; onClick?: (...args: any) => any; rightIcon?: ReactNode; }; const ContextMenuContext = createContext({ closeContextMenu: () => {}, openContextMenu: (args: OpenContextMenuProps) => { return args; }, }); const JELLYFIN_IGNORED_MENU_ITEMS: ContextMenuItemType[] = ['setRating']; // const NAVIDROME_IGNORED_MENU_ITEMS: ContextMenuItemType[] = []; // const SUBSONIC_IGNORED_MENU_ITEMS: ContextMenuItemType[] = []; const mpvPlayer = isElectron() ? window.electron.mpvPlayer : null; const remote = isElectron() ? window.electron.remote : null; export interface ContextMenuProviderProps { children: ReactNode; } export const ContextMenuProvider = ({ children }: ContextMenuProviderProps) => { const { t } = useTranslation(); const [opened, setOpened] = useState(false); const clickOutsideRef = useClickOutside(() => setOpened(false)); const viewport = useViewportSize(); const server = useCurrentServer(); const serverType = server?.type; const [ref, menuRect] = useResizeObserver(); const [ctx, setCtx] = useSetState({ data: [], dataNodes: [], menuItems: [], resetGridCache: undefined, tableApi: undefined, type: LibraryItem.SONG, xPos: 0, yPos: 0, }); const handlePlayQueueAdd = usePlayQueueAdd(); const openContextMenu = useCallback( (args: OpenContextMenuProps) => { const { xPos, yPos, menuItems, data, type, tableApi, dataNodes, context, resetGridCache, } = args; const serverType = data[0]?.serverType || useAuthStore.getState().currentServer?.type; let validMenuItems = menuItems; if (serverType === ServerType.JELLYFIN) { validMenuItems = menuItems.filter( (item) => !JELLYFIN_IGNORED_MENU_ITEMS.includes(item.id), ); } // If the context menu dimension can't be automatically calculated, calculate it manually // This is a hacky way since resize observer may not automatically recalculate when not rendered const menuHeight = menuRect.height || (menuItems.length + 1) * 50; const menuWidth = menuRect.width || 220; const shouldReverseY = yPos + menuHeight > viewport.height; const shouldReverseX = xPos + menuWidth > viewport.width; const calculatedXPos = shouldReverseX ? xPos - menuWidth : xPos; const calculatedYPos = shouldReverseY ? yPos - menuHeight : yPos; setCtx({ context, data, dataNodes, menuItems: validMenuItems, resetGridCache, tableApi, type, xPos: calculatedXPos, yPos: calculatedYPos, }); setOpened(true); }, [menuRect.height, menuRect.width, setCtx, viewport.height, viewport.width], ); const closeContextMenu = useCallback(() => { setOpened(false); setCtx({ data: [], dataNodes: [], menuItems: [], tableApi: undefined, type: LibraryItem.SONG, xPos: 0, yPos: 0, }); }, [setCtx]); useContextMenuEvents({ closeContextMenu, openContextMenu, }); const handlePlay = useCallback( (playType: Play) => { switch (ctx.type) { case LibraryItem.ALBUM: handlePlayQueueAdd?.({ byItemType: { id: ctx.data.map((item) => item.id), type: ctx.type }, playType, }); break; case LibraryItem.ARTIST: handlePlayQueueAdd?.({ byItemType: { id: ctx.data.map((item) => item.id), type: ctx.type }, playType, }); break; case LibraryItem.ALBUM_ARTIST: handlePlayQueueAdd?.({ byItemType: { id: ctx.data.map((item) => item.id), type: ctx.type }, playType, }); break; case LibraryItem.GENRE: handlePlayQueueAdd?.({ byItemType: { id: ctx.data.map((item) => item.id), type: ctx.type }, playType, }); break; case LibraryItem.SONG: handlePlayQueueAdd?.({ byData: ctx.data, playType }); break; case LibraryItem.PLAYLIST: for (const item of ctx.data) { handlePlayQueueAdd?.({ byItemType: { id: [item.id], type: ctx.type }, playType, }); } break; } }, [ctx.data, ctx.type, handlePlayQueueAdd], ); const deletePlaylistMutation = useDeletePlaylist({}); const handleDeletePlaylist = useCallback(() => { for (const item of ctx.data) { deletePlaylistMutation?.mutate( { query: { id: item.id }, serverId: item.serverId }, { onError: (err) => { toast.error({ message: err.message, title: t('error.genericError', { postProcess: 'sentenceCase' }), }); }, onSuccess: () => { toast.success({ message: `Playlist has been deleted`, }); ctx.tableApi?.refreshInfiniteCache(); ctx.resetGridCache?.(); }, }, ); } closeAllModals(); }, [ctx, deletePlaylistMutation, t]); const openDeletePlaylistModal = useCallback(() => { openModal({ children: ( {t('common.areYouSure', { postProcess: 'sentenceCase' })}
    {ctx.data.map((item) => (
  • {item.name}
  • ))}
), title: t('page.contextMenu.deletePlaylist', { postProcess: 'titleCase' }), }); }, [ctx.data, handleDeletePlaylist, t]); const createFavoriteMutation = useCreateFavorite({}); const deleteFavoriteMutation = useDeleteFavorite({}); const handleAddToFavorites = useCallback(() => { if (!ctx.dataNodes && !ctx.data) return; if (ctx.dataNodes) { const nodesToFavorite = ctx.dataNodes.filter((item) => !item.data.userFavorite); const nodesByServerId = nodesToFavorite.reduce((acc, node) => { if (!acc[node.data.serverId]) { acc[node.data.serverId] = []; } acc[node.data.serverId].push(node); return acc; }, {} as Record[]>); for (const serverId of Object.keys(nodesByServerId)) { const nodes = nodesByServerId[serverId]; const items = nodes.map((node) => node.data); createFavoriteMutation.mutate( { query: { id: items.map((item) => item.id), type: ctx.type, }, serverId, }, { onError: (err) => { toast.error({ message: err.message, title: t('error.genericError', { postProcess: 'sentenceCase' }), }); }, onSuccess: () => { for (const node of nodes) { node.setData({ ...node.data, userFavorite: true }); } }, }, ); } } else { const itemsToFavorite = ctx.data.filter((item) => !item.userFavorite); const itemsByServerId = (itemsToFavorite as any[]).reduce((acc, item) => { if (!acc[item.serverId]) { acc[item.serverId] = []; } acc[item.serverId].push(item); return acc; }, {} as Record); for (const serverId of Object.keys(itemsByServerId)) { const items = itemsByServerId[serverId]; createFavoriteMutation.mutate( { query: { id: items.map((item: AnyLibraryItem) => item.id), type: ctx.type, }, serverId, }, { onError: (err) => { toast.error({ message: err.message, title: t('error.genericError', { postProcess: 'sentenceCase' }), }); }, }, ); } } }, [createFavoriteMutation, ctx.data, ctx.dataNodes, ctx.type, t]); const handleRemoveFromFavorites = useCallback(() => { if (!ctx.dataNodes && !ctx.data) return; if (ctx.dataNodes) { const nodesToUnfavorite = ctx.dataNodes.filter((item) => item.data.userFavorite); const nodesByServerId = nodesToUnfavorite.reduce((acc, node) => { if (!acc[node.data.serverId]) { acc[node.data.serverId] = []; } acc[node.data.serverId].push(node); return acc; }, {} as Record[]>); for (const serverId of Object.keys(nodesByServerId)) { const idsToUnfavorite = nodesByServerId[serverId].map((node) => node.data.id); deleteFavoriteMutation.mutate( { query: { id: idsToUnfavorite, type: ctx.type, }, serverId, }, { onSuccess: () => { for (const node of nodesToUnfavorite) { node.setData({ ...node.data, userFavorite: false }); } }, }, ); } } else { const itemsToUnfavorite = ctx.data.filter((item) => item.userFavorite); const itemsByServerId = (itemsToUnfavorite as any[]).reduce((acc, item) => { if (!acc[item.serverId]) { acc[item.serverId] = []; } acc[item.serverId].push(item); return acc; }, {} as Record); for (const serverId of Object.keys(itemsByServerId)) { const idsToUnfavorite = itemsByServerId[serverId].map( (item: AnyLibraryItem) => item.id, ); deleteFavoriteMutation.mutate({ query: { id: idsToUnfavorite, type: ctx.type, }, serverId, }); } } }, [ctx.data, ctx.dataNodes, ctx.type, deleteFavoriteMutation]); const handleAddToPlaylist = useCallback(() => { if (!ctx.dataNodes && !ctx.data) return; const albumId: string[] = []; const artistId: string[] = []; const songId: string[] = []; const genreId: string[] = []; if (ctx.dataNodes) { for (const node of ctx.dataNodes) { switch (node.data.itemType) { case LibraryItem.ALBUM: albumId.push(node.data.id); break; case LibraryItem.ARTIST: artistId.push(node.data.id); break; case LibraryItem.GENRE: genreId.push(node.data.id); break; case LibraryItem.SONG: songId.push(node.data.id); break; } } } else { for (const item of ctx.data) { switch (item.itemType) { case LibraryItem.ALBUM: albumId.push(item.id); break; case LibraryItem.ALBUM_ARTIST: artistId.push(item.id); break; case LibraryItem.ARTIST: artistId.push(item.id); break; case LibraryItem.GENRE: genreId.push(item.id); break; case LibraryItem.SONG: songId.push(item.id); break; } } } openContextModal({ innerProps: { albumId: albumId.length > 0 ? albumId : undefined, artistId: artistId.length > 0 ? artistId : undefined, genreId: genreId.length > 0 ? genreId : undefined, songId: songId.length > 0 ? songId : undefined, }, modal: 'addToPlaylist', size: 'md', title: t('page.contextMenu.addToPlaylist', { postProcess: 'sentenceCase' }), }); }, [ctx.data, ctx.dataNodes, t]); const removeFromPlaylistMutation = useRemoveFromPlaylist(); const handleRemoveFromPlaylist = useCallback(() => { const songId = (serverType === ServerType.NAVIDROME || ServerType.JELLYFIN ? ctx.dataNodes?.map((node) => node.data.playlistItemId) : ctx.dataNodes?.map((node) => node.data.id)) || []; const confirm = () => { removeFromPlaylistMutation.mutate( { query: { id: ctx.context.playlistId, songId, }, serverId: ctx.data?.[0]?.serverId, }, { onError: (err) => { toast.error({ message: err.message, title: t('error.genericError', { postProcess: 'sentenceCase' }), }); }, onSuccess: () => { ctx.context?.tableRef?.current?.api?.refreshInfiniteCache(); closeAllModals(); }, }, ); }; openModal({ children: ( {t('common.areYouSure', { postProcess: 'sentenceCase' })} ), title: t('page.contextMenu.removeFromPlaylist', { postProcess: 'sentenceCase' }), }); }, [ ctx.context?.playlistId, ctx.context?.tableRef, ctx.data, ctx.dataNodes, removeFromPlaylistMutation, serverType, t, ]); const updateRatingMutation = useSetRating({}); const handleUpdateRating = useCallback( (rating: number) => { if (!ctx.dataNodes || !ctx.data) return; let uniqueServerIds: string[] = []; let items: AnyLibraryItems = []; if (ctx.dataNodes) { uniqueServerIds = ctx.dataNodes.reduce((acc, node) => { if (!acc.includes(node.data.serverId)) { acc.push(node.data.serverId); } return acc; }, [] as string[]); } else { uniqueServerIds = ctx.data.reduce((acc, item) => { if (!acc.includes(item.serverId)) { acc.push(item.serverId); } return acc; }, [] as string[]); } for (const serverId of uniqueServerIds) { if (ctx.dataNodes) { items = ctx.dataNodes .filter((node) => node.data.serverId === serverId) .map((node) => node.data); } else { items = ctx.data.filter((item) => item.serverId === serverId); } updateRatingMutation.mutate( { query: { item: items, rating, }, serverId, }, { onSuccess: () => { if (ctx.dataNodes) { for (const node of ctx.dataNodes) { node.setData({ ...node.data, userRating: rating }); } } }, }, ); } }, [ctx.data, ctx.dataNodes, updateRatingMutation], ); const playbackType = usePlaybackType(); const { moveToBottomOfQueue, moveToTopOfQueue, removeFromQueue } = useQueueControls(); const handleMoveToBottom = useCallback(() => { const uniqueIds = ctx.dataNodes?.map((row) => row.data.uniqueId); if (!uniqueIds?.length) return; const playerData = moveToBottomOfQueue(uniqueIds); if (playbackType === PlaybackType.LOCAL) { mpvPlayer!.setQueueNext(playerData); } }, [ctx.dataNodes, moveToBottomOfQueue, playbackType]); const handleMoveToTop = useCallback(() => { const uniqueIds = ctx.dataNodes?.map((row) => row.data.uniqueId); if (!uniqueIds?.length) return; const playerData = moveToTopOfQueue(uniqueIds); if (playbackType === PlaybackType.LOCAL) { mpvPlayer!.setQueueNext(playerData); } }, [ctx.dataNodes, moveToTopOfQueue, playbackType]); const handleRemoveSelected = useCallback(() => { const uniqueIds = ctx.dataNodes?.map((row) => row.data.uniqueId); if (!uniqueIds?.length) return; const currentSong = usePlayerStore.getState().current.song; const playerData = removeFromQueue(uniqueIds); const isCurrentSongRemoved = currentSong && uniqueIds.includes(currentSong?.uniqueId); if (playbackType === PlaybackType.LOCAL) { if (isCurrentSongRemoved) { mpvPlayer!.setQueue(playerData); } else { mpvPlayer!.setQueueNext(playerData); } } ctx.tableApi?.redrawRows(); if (isCurrentSongRemoved) { remote?.updateSong({ song: playerData.current.song }); } }, [ctx.dataNodes, ctx.tableApi, playbackType, removeFromQueue]); const handleDeselectAll = useCallback(() => { ctx.tableApi?.deselectAll(); }, [ctx.tableApi]); const contextMenuItems: Record = useMemo(() => { return { addToFavorites: { id: 'addToFavorites', label: t('page.contextMenu.addToFavorites', { postProcess: 'sentenceCase' }), leftIcon: , onClick: handleAddToFavorites, }, addToPlaylist: { id: 'addToPlaylist', label: t('page.contextMenu.addToPlaylist', { postProcess: 'sentenceCase' }), leftIcon: , onClick: handleAddToPlaylist, }, createPlaylist: { id: 'createPlaylist', label: t('page.contextMenu.createPlaylist', { postProcess: 'sentenceCase' }), onClick: () => {}, }, deletePlaylist: { id: 'deletePlaylist', label: t('page.contextMenu.deletePlaylist', { postProcess: 'sentenceCase' }), leftIcon: , onClick: openDeletePlaylistModal, }, deselectAll: { id: 'deselectAll', label: t('page.contextMenu.deselectAll', { postProcess: 'sentenceCase' }), leftIcon: , onClick: handleDeselectAll, }, moveToBottomOfQueue: { id: 'moveToBottomOfQueue', label: t('page.contextMenu.moveToBottom', { postProcess: 'sentenceCase' }), leftIcon: , onClick: handleMoveToBottom, }, moveToTopOfQueue: { id: 'moveToTopOfQueue', label: t('page.contextMenu.moveToTop', { postProcess: 'sentenceCase' }), leftIcon: , onClick: handleMoveToTop, }, play: { id: 'play', label: t('page.contextMenu.play', { postProcess: 'sentenceCase' }), leftIcon: , onClick: () => handlePlay(Play.NOW), }, playLast: { id: 'playLast', label: t('page.contextMenu.addLast', { postProcess: 'sentenceCase' }), leftIcon: , onClick: () => handlePlay(Play.LAST), }, playNext: { id: 'playNext', label: t('page.contextMenu.addNext', { postProcess: 'sentenceCase' }), leftIcon: , onClick: () => handlePlay(Play.NEXT), }, removeFromFavorites: { id: 'removeFromFavorites', label: t('page.contextMenu.removeFromFavorites', { postProcess: 'sentenceCase' }), leftIcon: , onClick: handleRemoveFromFavorites, }, removeFromPlaylist: { id: 'removeFromPlaylist', label: t('page.contextMenu.removeFromPlaylist', { postProcess: 'sentenceCase' }), leftIcon: , onClick: handleRemoveFromPlaylist, }, removeFromQueue: { id: 'removeSongs', label: t('page.contextMenu.removeFromQueue', { postProcess: 'sentenceCase' }), leftIcon: , onClick: handleRemoveSelected, }, setRating: { children: [ { id: 'zeroStar', label: ( ), onClick: () => handleUpdateRating(0), }, { id: 'oneStar', label: ( ), onClick: () => handleUpdateRating(1), }, { id: 'twoStar', label: ( ), onClick: () => handleUpdateRating(2), }, { id: 'threeStar', label: ( ), onClick: () => handleUpdateRating(3), }, { id: 'fourStar', label: ( ), onClick: () => handleUpdateRating(4), }, { id: 'fiveStar', label: ( ), onClick: () => handleUpdateRating(5), }, ], id: 'setRating', label: 'Set rating', leftIcon: , onClick: () => {}, rightIcon: , }, }; }, [ handleAddToFavorites, handleAddToPlaylist, handleDeselectAll, handleMoveToBottom, handleMoveToTop, handlePlay, handleRemoveFromFavorites, handleRemoveFromPlaylist, handleRemoveSelected, handleUpdateRating, openDeletePlaylistModal, t, ]); const mergedRef = useMergedRef(ref, clickOutsideRef); const providerValue = useMemo( () => ({ closeContextMenu, openContextMenu, }), [closeContextMenu, openContextMenu], ); return ( {opened && ( {ctx.menuItems?.map((item) => { return ( {item.children ? ( {contextMenuItems[item.id].label} {contextMenuItems[ item.id ].children?.map((child) => ( {child.label} ))} ) : ( {contextMenuItems[item.id].label} )} {item.divider && ( )} ); })} {t('page.contextMenu.numberSelected', { count: ctx.data?.length || 0, postProcess: 'lowerCase', })} )} {children} ); };