Add context menu to queue items

This commit is contained in:
jeffvli 2023-02-08 11:46:39 -08:00
parent 17d5ef1f6b
commit ffb7f915c3
6 changed files with 118 additions and 24 deletions

View file

@ -1,5 +1,16 @@
import { SetContextMenuItems } from '/@/renderer/features/context-menu/events'; import { SetContextMenuItems } from '/@/renderer/features/context-menu/events';
export const QUEUE_CONTEXT_MENU_ITEMS: SetContextMenuItems = [
{ divider: true, id: 'removeFromQueue' },
{ id: 'moveToBottomOfQueue' },
{ divider: true, id: 'moveToTopOfQueue' },
{ divider: true, id: 'addToPlaylist' },
{ id: 'addToFavorites' },
{ divider: true, id: 'removeFromFavorites' },
{ children: true, disabled: false, id: 'setRating' },
{ disabled: false, id: 'deselectAll' },
];
export const SONG_CONTEXT_MENU_ITEMS: SetContextMenuItems = [ export const SONG_CONTEXT_MENU_ITEMS: SetContextMenuItems = [
{ id: 'play' }, { id: 'play' },
{ id: 'playLast' }, { id: 'playLast' },

View file

@ -1,3 +1,4 @@
import { createContext, Fragment, ReactNode, useState, useMemo, useCallback } from 'react';
import { RowNode } from '@ag-grid-community/core'; import { RowNode } from '@ag-grid-community/core';
import { Divider, Group, Portal, Stack } from '@mantine/core'; import { Divider, Group, Portal, Stack } from '@mantine/core';
import { import {
@ -9,17 +10,20 @@ import {
} from '@mantine/hooks'; } from '@mantine/hooks';
import { closeAllModals, openContextModal, openModal } from '@mantine/modals'; import { closeAllModals, openContextModal, openModal } from '@mantine/modals';
import { AnimatePresence } from 'framer-motion'; import { AnimatePresence } from 'framer-motion';
import { createContext, Fragment, ReactNode, useState, useMemo, useCallback } from 'react'; import isElectron from 'is-electron';
import { import {
RiAddBoxFill, RiAddBoxFill,
RiAddCircleFill, RiAddCircleFill,
RiArrowDownLine,
RiArrowRightSFill, RiArrowRightSFill,
RiArrowUpLine,
RiDeleteBinFill, RiDeleteBinFill,
RiDislikeFill, RiDislikeFill,
RiHeartFill, RiHeartFill,
RiPlayFill, RiPlayFill,
RiPlayListAddFill, RiPlayListAddFill,
RiStarFill, RiStarFill,
RiCloseCircleLine,
} from 'react-icons/ri'; } from 'react-icons/ri';
import { AnyLibraryItems, LibraryItem, ServerType } from '/@/renderer/api/types'; import { AnyLibraryItems, LibraryItem, ServerType } from '/@/renderer/api/types';
import { import {
@ -40,8 +44,9 @@ import { usePlayQueueAdd } from '/@/renderer/features/player';
import { useDeletePlaylist } from '/@/renderer/features/playlists'; import { useDeletePlaylist } from '/@/renderer/features/playlists';
import { useRemoveFromPlaylist } from '/@/renderer/features/playlists/mutations/remove-from-playlist-mutation'; import { useRemoveFromPlaylist } from '/@/renderer/features/playlists/mutations/remove-from-playlist-mutation';
import { useCreateFavorite, useDeleteFavorite, useUpdateRating } from '/@/renderer/features/shared'; import { useCreateFavorite, useDeleteFavorite, useUpdateRating } from '/@/renderer/features/shared';
import { useAuthStore, useCurrentServer } from '/@/renderer/store'; import { useAuthStore, useCurrentServer, useQueueControls } from '/@/renderer/store';
import { Play } from '/@/renderer/types'; import { usePlayerType } from '/@/renderer/store/settings.store';
import { Play, PlaybackType } from '/@/renderer/types';
type ContextMenuContextProps = { type ContextMenuContextProps = {
closeContextMenu: () => void; closeContextMenu: () => void;
@ -69,6 +74,8 @@ const JELLYFIN_IGNORED_MENU_ITEMS: ContextMenuItemType[] = ['setRating'];
// const NAVIDROME_IGNORED_MENU_ITEMS: ContextMenuItemType[] = []; // const NAVIDROME_IGNORED_MENU_ITEMS: ContextMenuItemType[] = [];
// const SUBSONIC_IGNORED_MENU_ITEMS: ContextMenuItemType[] = []; // const SUBSONIC_IGNORED_MENU_ITEMS: ContextMenuItemType[] = [];
const mpvPlayer = isElectron() ? window.electron.mpvPlayer : null;
export interface ContextMenuProviderProps { export interface ContextMenuProviderProps {
children: React.ReactNode; children: React.ReactNode;
} }
@ -85,7 +92,7 @@ export const ContextMenuProvider = ({ children }: ContextMenuProviderProps) => {
data: [], data: [],
dataNodes: [], dataNodes: [],
menuItems: [], menuItems: [],
tableRef: undefined, tableApi: undefined,
type: LibraryItem.SONG, type: LibraryItem.SONG,
xPos: 0, xPos: 0,
yPos: 0, yPos: 0,
@ -94,7 +101,7 @@ export const ContextMenuProvider = ({ children }: ContextMenuProviderProps) => {
const handlePlayQueueAdd = usePlayQueueAdd(); const handlePlayQueueAdd = usePlayQueueAdd();
const openContextMenu = (args: OpenContextMenuProps) => { const openContextMenu = (args: OpenContextMenuProps) => {
const { xPos, yPos, menuItems, data, type, tableRef, dataNodes, context } = args; const { xPos, yPos, menuItems, data, type, tableApi, dataNodes, context } = args;
const serverType = data[0]?.serverType || useAuthStore.getState().currentServer?.type; const serverType = data[0]?.serverType || useAuthStore.getState().currentServer?.type;
let validMenuItems = menuItems; let validMenuItems = menuItems;
@ -119,7 +126,7 @@ export const ContextMenuProvider = ({ children }: ContextMenuProviderProps) => {
data, data,
dataNodes, dataNodes,
menuItems: validMenuItems, menuItems: validMenuItems,
tableRef, tableApi,
type, type,
xPos: calculatedXPos, xPos: calculatedXPos,
yPos: calculatedYPos, yPos: calculatedYPos,
@ -133,7 +140,7 @@ export const ContextMenuProvider = ({ children }: ContextMenuProviderProps) => {
data: [], data: [],
dataNodes: [], dataNodes: [],
menuItems: [], menuItems: [],
tableRef: undefined, tableApi: undefined,
type: LibraryItem.SONG, type: LibraryItem.SONG,
xPos: 0, xPos: 0,
yPos: 0, yPos: 0,
@ -464,11 +471,51 @@ export const ContextMenuProvider = ({ children }: ContextMenuProviderProps) => {
[ctx.data, ctx.dataNodes, updateRatingMutation], [ctx.data, ctx.dataNodes, updateRatingMutation],
); );
const playerType = usePlayerType();
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 (playerType === PlaybackType.LOCAL) {
mpvPlayer.setQueueNext(playerData);
}
}, [ctx.dataNodes, moveToBottomOfQueue, playerType]);
const handleMoveToTop = useCallback(() => {
const uniqueIds = ctx.dataNodes?.map((row) => row.data.uniqueId);
if (!uniqueIds?.length) return;
const playerData = moveToTopOfQueue(uniqueIds);
if (playerType === PlaybackType.LOCAL) {
mpvPlayer.setQueueNext(playerData);
}
}, [ctx.dataNodes, moveToTopOfQueue, playerType]);
const handleRemoveSelected = useCallback(() => {
const uniqueIds = ctx.dataNodes?.map((row) => row.data.uniqueId);
if (!uniqueIds?.length) return;
const playerData = removeFromQueue(uniqueIds);
if (playerType === PlaybackType.LOCAL) {
mpvPlayer.setQueueNext(playerData);
}
}, [ctx.dataNodes, playerType, removeFromQueue]);
const handleDeselectAll = useCallback(() => {
ctx.tableApi?.deselectAll();
}, [ctx.tableApi]);
const contextMenuItems: Record<ContextMenuItemType, ContextMenuItem> = useMemo(() => { const contextMenuItems: Record<ContextMenuItemType, ContextMenuItem> = useMemo(() => {
return { return {
addToFavorites: { addToFavorites: {
id: 'addToFavorites', id: 'addToFavorites',
label: 'Add to favorites', label: 'Add favorite',
leftIcon: <RiHeartFill size="1.1rem" />, leftIcon: <RiHeartFill size="1.1rem" />,
onClick: handleAddToFavorites, onClick: handleAddToFavorites,
}, },
@ -485,6 +532,24 @@ export const ContextMenuProvider = ({ children }: ContextMenuProviderProps) => {
leftIcon: <RiDeleteBinFill size="1.1rem" />, leftIcon: <RiDeleteBinFill size="1.1rem" />,
onClick: openDeletePlaylistModal, onClick: openDeletePlaylistModal,
}, },
deselectAll: {
id: 'deselectAll',
label: 'Deselect all',
leftIcon: <RiCloseCircleLine size="1.1rem" />,
onClick: handleDeselectAll,
},
moveToBottomOfQueue: {
id: 'moveToBottomOfQueue',
label: 'Move to bottom',
leftIcon: <RiArrowDownLine size="1.1rem" />,
onClick: handleMoveToBottom,
},
moveToTopOfQueue: {
id: 'moveToTopOfQueue',
label: 'Move to top',
leftIcon: <RiArrowUpLine size="1.1rem" />,
onClick: handleMoveToTop,
},
play: { play: {
id: 'play', id: 'play',
label: 'Play', label: 'Play',
@ -505,7 +570,7 @@ export const ContextMenuProvider = ({ children }: ContextMenuProviderProps) => {
}, },
removeFromFavorites: { removeFromFavorites: {
id: 'removeFromFavorites', id: 'removeFromFavorites',
label: 'Remove from favorites', label: 'Remove favorite',
leftIcon: <RiDislikeFill size="1.1rem" />, leftIcon: <RiDislikeFill size="1.1rem" />,
onClick: handleRemoveFromFavorites, onClick: handleRemoveFromFavorites,
}, },
@ -515,6 +580,12 @@ export const ContextMenuProvider = ({ children }: ContextMenuProviderProps) => {
leftIcon: <RiDeleteBinFill size="1.1rem" />, leftIcon: <RiDeleteBinFill size="1.1rem" />,
onClick: handleRemoveFromPlaylist, onClick: handleRemoveFromPlaylist,
}, },
removeFromQueue: {
id: 'moveToBottomOfQueue',
label: 'Remove songs',
leftIcon: <RiDeleteBinFill size="1.1rem" />,
onClick: handleRemoveSelected,
},
setRating: { setRating: {
children: [ children: [
{ {
@ -594,9 +665,13 @@ export const ContextMenuProvider = ({ children }: ContextMenuProviderProps) => {
}, [ }, [
handleAddToFavorites, handleAddToFavorites,
handleAddToPlaylist, handleAddToPlaylist,
handleDeselectAll,
handleMoveToBottom,
handleMoveToTop,
handlePlay, handlePlay,
handleRemoveFromFavorites, handleRemoveFromFavorites,
handleRemoveFromPlaylist, handleRemoveFromPlaylist,
handleRemoveSelected,
handleUpdateRating, handleUpdateRating,
openDeletePlaylistModal, openDeletePlaylistModal,
]); ]);

View file

@ -1,7 +1,5 @@
import { RowNode } from '@ag-grid-community/core'; import { GridOptions, RowNode } from '@ag-grid-community/core';
import type { AgGridReact as AgGridReactType } from '@ag-grid-community/react/lib/agGridReact';
import { createUseExternalEvents } from '@mantine/utils'; import { createUseExternalEvents } from '@mantine/utils';
import { MutableRefObject } from 'react';
import { LibraryItem } from '/@/renderer/api/types'; import { LibraryItem } from '/@/renderer/api/types';
export type OpenContextMenuProps = { export type OpenContextMenuProps = {
@ -9,7 +7,7 @@ export type OpenContextMenuProps = {
data: any[]; data: any[];
dataNodes?: RowNode[]; dataNodes?: RowNode[];
menuItems: SetContextMenuItems; menuItems: SetContextMenuItems;
tableRef?: MutableRefObject<AgGridReactType | null>; tableApi?: GridOptions['api'];
type: LibraryItem; type: LibraryItem;
xPos: number; xPos: number;
yPos: number; yPos: number;
@ -30,7 +28,11 @@ export type ContextMenuItemType =
| 'removeFromFavorites' | 'removeFromFavorites'
| 'setRating' | 'setRating'
| 'deletePlaylist' | 'deletePlaylist'
| 'createPlaylist'; | 'createPlaylist'
| 'moveToBottomOfQueue'
| 'moveToTopOfQueue'
| 'removeFromQueue'
| 'deselectAll';
export type SetContextMenuItems = { export type SetContextMenuItems = {
children?: boolean; children?: boolean;

View file

@ -30,6 +30,7 @@ export const useHandleTableContextMenu = (
data: selectedRows, data: selectedRows,
dataNodes: selectedNodes, dataNodes: selectedNodes,
menuItems: contextMenuItems, menuItems: contextMenuItems,
tableApi: e.api,
type: itemType, type: itemType,
xPos: clickEvent.clientX, xPos: clickEvent.clientX,
yPos: clickEvent.clientY, yPos: clickEvent.clientY,

View file

@ -100,15 +100,6 @@ export const PlayQueueListControls = ({ type, tableRef }: PlayQueueListOptionsPr
> >
<RiShuffleLine size="1.1rem" /> <RiShuffleLine size="1.1rem" />
</Button> </Button>
<Button
compact
size="md"
tooltip={{ label: 'Move selected to top' }}
variant="default"
onClick={handleMoveToTop}
>
<RiArrowUpLine size="1.1rem" />
</Button>
<Button <Button
compact compact
size="md" size="md"
@ -118,6 +109,15 @@ export const PlayQueueListControls = ({ type, tableRef }: PlayQueueListOptionsPr
> >
<RiArrowDownLine size="1.1rem" /> <RiArrowDownLine size="1.1rem" />
</Button> </Button>
<Button
compact
size="md"
tooltip={{ label: 'Move selected to top' }}
variant="default"
onClick={handleMoveToTop}
>
<RiArrowUpLine size="1.1rem" />
</Button>
<Button <Button
compact compact
size="md" size="md"

View file

@ -29,7 +29,9 @@ import { ErrorBoundary } from 'react-error-boundary';
import { VirtualTable } from '/@/renderer/components/virtual-table'; import { VirtualTable } from '/@/renderer/components/virtual-table';
import { ErrorFallback } from '/@/renderer/features/action-required'; import { ErrorFallback } from '/@/renderer/features/action-required';
import { PlaybackType, TableType } from '/@/renderer/types'; import { PlaybackType, TableType } from '/@/renderer/types';
import { QueueSong } from '/@/renderer/api/types'; import { LibraryItem, QueueSong } from '/@/renderer/api/types';
import { useHandleTableContextMenu } from '/@/renderer/features/context-menu';
import { QUEUE_CONTEXT_MENU_ITEMS } from '/@/renderer/features/context-menu/context-menu-items';
const mpvPlayer = isElectron() ? window.electron.mpvPlayer : null; const mpvPlayer = isElectron() ? window.electron.mpvPlayer : null;
@ -184,6 +186,8 @@ export const PlayQueue = forwardRef(({ type }: QueueProps, ref: Ref<any>) => {
} }
}, [currentSong, previousSong, tableConfig.followCurrentSong]); }, [currentSong, previousSong, tableConfig.followCurrentSong]);
const handleContextMenu = useHandleTableContextMenu(LibraryItem.SONG, QUEUE_CONTEXT_MENU_ITEMS);
return ( return (
<ErrorBoundary FallbackComponent={ErrorFallback}> <ErrorBoundary FallbackComponent={ErrorFallback}>
<VirtualGridAutoSizerContainer> <VirtualGridAutoSizerContainer>
@ -199,6 +203,7 @@ export const PlayQueue = forwardRef(({ type }: QueueProps, ref: Ref<any>) => {
rowClassRules={rowClassRules} rowClassRules={rowClassRules}
rowData={queue} rowData={queue}
rowHeight={tableConfig.rowHeight || 40} rowHeight={tableConfig.rowHeight || 40}
onCellContextMenu={handleContextMenu}
onCellDoubleClicked={handleDoubleClick} onCellDoubleClicked={handleDoubleClick}
onColumnMoved={handleColumnChange} onColumnMoved={handleColumnChange}
onColumnResized={debouncedColumnChange} onColumnResized={debouncedColumnChange}