diff --git a/src/renderer/app.tsx b/src/renderer/app.tsx
index 1c028999..98de6086 100644
--- a/src/renderer/app.tsx
+++ b/src/renderer/app.tsx
@@ -14,6 +14,7 @@ import { AppRouter } from './router/app-router';
import { useSettingsStore } from './store/settings.store';
import './styles/global.scss';
import '@ag-grid-community/styles/ag-grid.css';
+import { ContextMenuProvider } from '/@/renderer/features/context-menu';
ModuleRegistry.registerModules([ClientSideRowModelModule, InfiniteRowModelModule]);
@@ -98,7 +99,9 @@ export const App = () => {
}}
modals={{ base: BaseContextModal }}
>
-
+
+
+
diff --git a/src/renderer/components/context-menu/index.tsx b/src/renderer/components/context-menu/index.tsx
new file mode 100644
index 00000000..0e68f6ae
--- /dev/null
+++ b/src/renderer/components/context-menu/index.tsx
@@ -0,0 +1,56 @@
+import { forwardRef, ReactNode, Ref } from 'react';
+import { Portal } from '@mantine/core';
+import { motion } from 'framer-motion';
+import styled from 'styled-components';
+import { _Button } from '/@/renderer/components/button';
+
+interface ContextMenuProps {
+ children: ReactNode;
+ maxWidth?: number;
+ minWidth?: number;
+ xPos: number;
+ yPos: number;
+}
+
+const ContextMenuContainer = styled(motion.div)>`
+ position: absolute;
+ top: ${({ yPos }) => yPos}px !important;
+ left: ${({ xPos }) => xPos}px !important;
+ z-index: 1000;
+ min-width: ${({ minWidth }) => minWidth}px;
+ max-width: ${({ maxWidth }) => maxWidth}px;
+ padding: 0.5rem;
+ background: var(--dropdown-menu-bg);
+ border-radius: 5px;
+ box-shadow: 2px 2px 10px 2px rgba(0, 0, 0, 40%);
+`;
+
+export const ContextMenuButton = styled(_Button)`
+ background: var(--dropdown-menu-bg);
+
+ & .mantine-Button-inner {
+ justify-content: flex-start;
+ }
+
+ &:disabled {
+ background: transparent;
+ }
+`;
+
+export const ContextMenu = forwardRef(
+ ({ yPos, xPos, minWidth, maxWidth, children }: ContextMenuProps, ref: Ref) => {
+ return (
+
+
+ {children}
+
+
+ );
+ },
+);
diff --git a/src/renderer/components/index.ts b/src/renderer/components/index.ts
index 04e3534b..682217ae 100644
--- a/src/renderer/components/index.ts
+++ b/src/renderer/components/index.ts
@@ -30,3 +30,4 @@ export * from './tooltip';
export * from './virtual-grid';
export * from './virtual-table';
export * from './motion';
+export * from './context-menu';
diff --git a/src/renderer/features/context-menu/context-menu-items.tsx b/src/renderer/features/context-menu/context-menu-items.tsx
new file mode 100644
index 00000000..014a64f6
--- /dev/null
+++ b/src/renderer/features/context-menu/context-menu-items.tsx
@@ -0,0 +1,31 @@
+import { SetContextMenuItems } from '/@/renderer/features/context-menu/events';
+
+export const SONG_CONTEXT_MENU_ITEMS: SetContextMenuItems = [
+ { id: 'play' },
+ { id: 'playLast' },
+ { id: 'playNext' },
+ { disabled: true, id: 'addToPlaylist' },
+ { disabled: true, id: 'addToFavorites' },
+ { disabled: true, id: 'removeFromFavorites' },
+ { disabled: true, id: 'setRating' },
+];
+
+export const ALBUM_CONTEXT_MENU_ITEMS: SetContextMenuItems = [
+ { id: 'play' },
+ { id: 'playLast' },
+ { id: 'playNext' },
+ { disabled: true, id: 'addToPlaylist' },
+ { disabled: true, id: 'addToFavorites' },
+ { disabled: true, id: 'removeFromFavorites' },
+ { disabled: true, id: 'setRating' },
+];
+
+export const ARTIST_CONTEXT_MENU_ITEMS: SetContextMenuItems = [
+ { id: 'play' },
+ { id: 'playLast' },
+ { id: 'playNext' },
+ { disabled: true, id: 'addToPlaylist' },
+ { disabled: true, id: 'addToFavorites' },
+ { disabled: true, id: 'removeFromFavorites' },
+ { disabled: true, id: 'setRating' },
+];
diff --git a/src/renderer/features/context-menu/context-menu-provider.tsx b/src/renderer/features/context-menu/context-menu-provider.tsx
new file mode 100644
index 00000000..546f89a7
--- /dev/null
+++ b/src/renderer/features/context-menu/context-menu-provider.tsx
@@ -0,0 +1,150 @@
+import { Stack } from '@mantine/core';
+import { useClickOutside, useResizeObserver, useSetState, useViewportSize } from '@mantine/hooks';
+import { createContext, useMemo, useState } from 'react';
+import { ContextMenu, ContextMenuButton } from '/@/renderer/components';
+import {
+ OpenContextMenuProps,
+ SetContextMenuItems,
+ useContextMenuEvents,
+} from '/@/renderer/features/context-menu/events';
+import { useHandlePlayQueueAdd } from '/@/renderer/features/player/hooks/use-handle-playqueue-add';
+import { LibraryItem, Play } from '/@/renderer/types';
+
+type ContextMenuContextProps = {
+ closeContextMenu: () => void;
+ openContextMenu: (args: OpenContextMenuProps) => void;
+};
+
+const ContextMenuContext = createContext({
+ closeContextMenu: () => {},
+ openContextMenu: (args: OpenContextMenuProps) => {
+ return args;
+ },
+});
+
+export interface ContextMenuProviderProps {
+ children: React.ReactNode;
+}
+
+export const ContextMenuProvider = ({ children }: ContextMenuProviderProps) => {
+ const [opened, setOpened] = useState(false);
+ const clickOutsideRef = useClickOutside(() => setOpened(false));
+ const viewport = useViewportSize();
+ const [ref, menuRect] = useResizeObserver();
+ const [ctx, setCtx] = useSetState<{
+ data: any[];
+ menuItems: SetContextMenuItems;
+ type: LibraryItem;
+ xPos: number;
+ yPos: number;
+ }>({
+ data: [],
+ menuItems: [],
+ type: LibraryItem.SONG,
+ xPos: 0,
+ yPos: 0,
+ });
+
+ const handlePlayQueueAdd = useHandlePlayQueueAdd();
+
+ const openContextMenu = (args: OpenContextMenuProps) => {
+ const { xPos, yPos, menuItems, data } = args;
+
+ const shouldReverseY = yPos + menuRect.height > viewport.height;
+ const shouldReverseX = xPos + menuRect.width > viewport.width;
+
+ const calculatedXPos = shouldReverseX ? xPos - menuRect.width : xPos;
+ const calculatedYPos = shouldReverseY ? yPos - menuRect.height : yPos;
+
+ setCtx({ data, menuItems, xPos: calculatedXPos, yPos: calculatedYPos });
+ setOpened(true);
+ };
+
+ const closeContextMenu = () => {
+ setOpened(false);
+ };
+
+ useContextMenuEvents({
+ closeContextMenu,
+ openContextMenu,
+ });
+
+ const contextMenuItems = useMemo(() => {
+ return {
+ addToFavorites: { id: 'addToFavorites', label: 'Add to favorites', onClick: () => {} },
+ addToPlaylist: { id: 'addToPlaylist', label: 'Add to playlist', onClick: () => {} },
+ play: {
+ id: 'play',
+ label: 'Play',
+ onClick: () => {
+ if (ctx.type === LibraryItem.SONG) {
+ handlePlayQueueAdd({ byData: ctx.data, play: Play.NOW });
+ }
+ },
+ },
+ playLast: {
+ id: 'playLast',
+ label: 'Play Last',
+ onClick: () => {
+ if (ctx.type === LibraryItem.SONG) {
+ handlePlayQueueAdd({ byData: ctx.data, play: Play.LAST });
+ }
+ },
+ },
+ playNext: {
+ id: 'playNext',
+ label: 'Play Next',
+ onClick: () => {
+ if (ctx.type === LibraryItem.SONG) {
+ handlePlayQueueAdd({ byData: ctx.data, play: Play.NEXT });
+ }
+ },
+ },
+ removeFromFavorites: {
+ id: 'removeFromFavorites',
+ label: 'Remove from favorites',
+ onClick: () => {},
+ },
+ setRating: { id: 'setRating', label: 'Set rating', onClick: () => {} },
+ };
+ }, [ctx.data, ctx.type, handlePlayQueueAdd]);
+
+ return (
+
+ {opened && (
+
+
+ {ctx.menuItems?.map((item) => {
+ return (
+
+ {contextMenuItems[item.id].label}
+
+ );
+ })}
+
+
+ )}
+
+ {children}
+
+ );
+};
diff --git a/src/renderer/features/context-menu/events.ts b/src/renderer/features/context-menu/events.ts
new file mode 100644
index 00000000..60396b4d
--- /dev/null
+++ b/src/renderer/features/context-menu/events.ts
@@ -0,0 +1,36 @@
+import { createUseExternalEvents } from '@mantine/utils';
+import { LibraryItem } from '/@/renderer/types';
+
+export type OpenContextMenuProps = {
+ data: any[];
+ menuItems: SetContextMenuItems;
+ type: LibraryItem;
+ xPos: number;
+ yPos: number;
+};
+
+export type ContextMenuEvents = {
+ closeContextMenu: () => void;
+ openContextMenu: (args: OpenContextMenuProps) => void;
+};
+
+export type ContextMenuItem =
+ | 'play'
+ | 'playLast'
+ | 'playNext'
+ | 'addToPlaylist'
+ | 'addToFavorites'
+ | 'removeFromFavorites'
+ | 'setRating';
+
+export type SetContextMenuItems = {
+ disabled?: boolean;
+ id: ContextMenuItem;
+ onClick?: () => void;
+}[];
+
+export const [useContextMenuEvents, createEvent] =
+ createUseExternalEvents('context-menu');
+
+export const openContextMenu = createEvent('openContextMenu');
+export const closeContextMenu = createEvent('closeContextMenu');
diff --git a/src/renderer/features/context-menu/index.tsx b/src/renderer/features/context-menu/index.tsx
new file mode 100644
index 00000000..ddb6383a
--- /dev/null
+++ b/src/renderer/features/context-menu/index.tsx
@@ -0,0 +1,2 @@
+export * from './events';
+export * from './context-menu-provider';