Add queue controls

This commit is contained in:
jeffvli
2022-12-10 05:41:56 -08:00
parent f48560ef60
commit b6c81183e9
9 changed files with 362 additions and 139 deletions
@@ -1,6 +1,6 @@
import type { Ref } from 'react';
import { forwardRef, useRef } from 'react';
import { useClickOutside, useMergedRef } from '@mantine/hooks';
import { useMergedRef } from '@mantine/hooks';
import type {
ICellRendererParams,
ValueGetterParams,
@@ -175,17 +175,8 @@ export const VirtualTable = forwardRef(
const mergedRef = useMergedRef(ref, tableRef);
const tableContainerRef = useClickOutside(() => {
if (tableRef?.current) {
tableRef?.current.api.deselectAll();
}
});
return (
<TableWrapper
ref={tableContainerRef}
className="ag-theme-alpine-dark"
>
<TableWrapper className="ag-theme-alpine-dark">
<AgGridReact
ref={mergedRef}
suppressMoveWhenRowDragging
@@ -1,19 +1,12 @@
import type { ChangeEvent } from 'react';
import { Stack } from '@mantine/core';
import { useDisclosure } from '@mantine/hooks';
import type { Variants } from 'framer-motion';
import { motion } from 'framer-motion';
import { RiListSettingsLine } from 'react-icons/ri';
import styled from 'styled-components';
import { Button } from '/@/components/button';
import { Popover } from '/@/components/popover';
import { MultiSelect } from '/@/components/select';
import { Slider } from '/@/components/slider';
import { Switch } from '/@/components/switch';
import { Text } from '/@/components/text';
import { useSettingsStore } from '/@/store/settings.store';
import type { TableType } from '/@/types';
import { TableColumn } from '/@/types';
import { MultiSelect } from '/@/components/select';
export const tableColumns = [
{ label: 'Row Index', value: TableColumn.ROW_INDEX },
@@ -38,13 +31,6 @@ export const tableColumns = [
{ label: 'Date Added', value: TableColumn.DATE_ADDED },
];
const Container = styled(motion.div)`
position: absolute;
right: 0;
bottom: 0;
z-index: 500;
`;
interface TableConfigDropdownProps {
type: TableType;
}
@@ -52,15 +38,6 @@ interface TableConfigDropdownProps {
export const TableConfigDropdown = ({ type }: TableConfigDropdownProps) => {
const setSettings = useSettingsStore((state) => state.setSettings);
const tableConfig = useSettingsStore((state) => state.tables);
const [opened, handlers] = useDisclosure(false);
const containerVariants: Variants = {
animate: {
opacity: 0.2,
},
initial: {
opacity: 0,
},
};
const handleAddOrRemoveColumns = (values: TableColumn[]) => {
const existingColumns = tableConfig[type].columns;
@@ -146,69 +123,45 @@ export const TableConfigDropdown = ({ type }: TableConfigDropdownProps) => {
};
return (
<Container
animate="animate"
initial="initial"
variants={containerVariants}
whileHover={{ opacity: 1 }}
<Stack
p="1rem"
spacing="xl"
>
<Popover
opened={opened}
position="top-start"
withArrow={false}
>
<Popover.Target>
<Button
compact
variant="subtle"
onClick={() => handlers.toggle()}
>
<RiListSettingsLine size={20} />
</Button>
</Popover.Target>
<Popover.Dropdown>
<Stack
p="1rem"
spacing="xl"
>
<Stack spacing="xs">
<Text>Table Columns</Text>
<MultiSelect
clearable
data={tableColumns}
defaultValue={tableConfig[type]?.columns.map((column) => column.column)}
dropdownPosition="top"
width={300}
onChange={handleAddOrRemoveColumns}
/>
</Stack>
<Stack spacing="xs">
<Text>Row Height</Text>
<Slider
defaultValue={tableConfig[type]?.rowHeight}
max={100}
min={25}
sx={{ width: 150 }}
onChangeEnd={handleUpdateRowHeight}
/>
</Stack>
<Stack spacing="xs">
<Text>Auto Fit Columns</Text>
<Switch
defaultChecked={tableConfig[type]?.autoFit}
onChange={handleUpdateAutoFit}
/>
</Stack>
<Stack spacing="xs">
<Text>Follow Current Song</Text>
<Switch
defaultChecked={tableConfig[type]?.followCurrentSong}
onChange={handleUpdateFollow}
/>
</Stack>
</Stack>
</Popover.Dropdown>
</Popover>
</Container>
<Stack spacing="xs">
<Text>Table Columns</Text>
<MultiSelect
clearable
data={tableColumns}
defaultValue={tableConfig[type]?.columns.map((column) => column.column)}
dropdownPosition="top"
width={300}
onChange={handleAddOrRemoveColumns}
/>
</Stack>
<Stack spacing="xs">
<Text>Row Height</Text>
<Slider
defaultValue={tableConfig[type]?.rowHeight}
max={100}
min={25}
sx={{ width: 150 }}
onChangeEnd={handleUpdateRowHeight}
/>
</Stack>
<Stack spacing="xs">
<Text>Auto Fit Columns</Text>
<Switch
defaultChecked={tableConfig[type]?.autoFit}
onChange={handleUpdateAutoFit}
/>
</Stack>
<Stack spacing="xs">
<Text>Follow Current Song</Text>
<Switch
defaultChecked={tableConfig[type]?.followCurrentSong}
onChange={handleUpdateFollow}
/>
</Stack>
</Stack>
);
};
@@ -0,0 +1,24 @@
import type { AgGridReact as AgGridReactType } from '@ag-grid-community/react/lib/agGridReact';
import { Stack } from '@mantine/core';
import { PlayQueue } from '/@/features/now-playing/components/play-queue';
import { PlayQueueListControls } from './play-queue-list-controls';
import { useRef } from 'react';
import type { Song } from '/@/api/types';
export const DrawerPlayQueue = () => {
const queueRef = useRef<{ grid: AgGridReactType<Song> } | null>(null);
return (
<Stack
pb="1rem"
sx={{ height: '100%' }}
>
<PlayQueue type="sideQueue" />
<PlayQueueListControls
gridApi={queueRef.current?.grid.api}
gridColumnApi={queueRef.current?.grid?.columnApi}
type="sideQueue"
/>
</Stack>
);
};
@@ -0,0 +1,137 @@
import type { AgGridReact as AgGridReactType } from '@ag-grid-community/react/lib/agGridReact';
import { Group } from '@mantine/core';
import {
RiArrowDownLine,
RiArrowUpLine,
RiShuffleLine,
RiDeleteBinLine,
RiListSettingsLine,
RiEraserLine,
} from 'react-icons/ri';
import type { Song } from '/@/api/types';
import { TableConfigDropdown, Button, Popover } from '/@/components';
import { useQueueControls } from '/@/store';
import type { TableType } from '/@/types';
import { mpvPlayer } from '#preload';
interface PlayQueueListOptionsProps {
gridApi?: AgGridReactType<Song>['api'];
gridColumnApi?: AgGridReactType<Song>['columnApi'];
type: TableType;
}
export const PlayQueueListControls = ({ type, gridApi }: PlayQueueListOptionsProps) => {
const { clearQueue, moveToBottomOfQueue, moveToTopOfQueue, shuffleQueue, removeFromQueue } =
useQueueControls();
const handleMoveToBottom = () => {
const selectedRows = gridApi?.getSelectedRows();
const uniqueIds = selectedRows?.map((row) => row.uniqueId);
if (!uniqueIds?.length) return;
const playerData = moveToBottomOfQueue(uniqueIds);
mpvPlayer.setQueueNext(playerData);
};
const handleMoveToTop = () => {
const selectedRows = gridApi?.getSelectedRows();
const uniqueIds = selectedRows?.map((row) => row.uniqueId);
if (!uniqueIds?.length) return;
const playerData = moveToTopOfQueue(uniqueIds);
mpvPlayer.setQueueNext(playerData);
};
const handleRemoveSelected = () => {
const selectedRows = gridApi?.getSelectedRows();
const uniqueIds = selectedRows?.map((row) => row.uniqueId);
if (!uniqueIds?.length) return;
const playerData = removeFromQueue(uniqueIds);
mpvPlayer.setQueueNext(playerData);
};
const handleClearQueue = () => {
const playerData = clearQueue();
mpvPlayer.setQueue(playerData);
mpvPlayer.stop();
};
const handleShuffleQueue = () => {
const playerData = shuffleQueue();
mpvPlayer.setQueueNext(playerData);
};
return (
<Group
position="apart"
px="1rem"
sx={{ alignItems: 'center' }}
>
<Group>
<Button
compact
size="sm"
tooltip={{ label: 'Shuffle queue' }}
variant="default"
onClick={handleShuffleQueue}
>
<RiShuffleLine size={15} />
</Button>
<Button
compact
size="sm"
tooltip={{ label: 'Move selected to top' }}
variant="default"
onClick={handleMoveToTop}
>
<RiArrowUpLine size={15} />
</Button>
<Button
compact
size="sm"
tooltip={{ label: 'Move selected to bottom' }}
variant="default"
onClick={handleMoveToBottom}
>
<RiArrowDownLine size={15} />
</Button>
<Button
compact
size="sm"
tooltip={{ label: 'Remove selected' }}
variant="default"
onClick={handleRemoveSelected}
>
<RiEraserLine size={15} />
</Button>
<Button
compact
size="sm"
tooltip={{ label: 'Clear queue' }}
variant="default"
onClick={handleClearQueue}
>
<RiDeleteBinLine size={15} />
</Button>
</Group>
<Group>
<Popover>
<Popover.Target>
<Button
compact
size="sm"
tooltip={{ label: 'Configure' }}
variant="default"
>
<RiListSettingsLine size={15} />
</Button>
</Popover.Target>
<Popover.Dropdown>
<TableConfigDropdown type={type} />
</Popover.Dropdown>
</Popover>
</Group>
</Group>
);
};
@@ -1,9 +1,10 @@
import { useEffect, useMemo, useRef } from 'react';
import { forwardRef, useEffect, useImperativeHandle, useMemo, useRef } from 'react';
import type {
CellDoubleClickedEvent,
ColDef,
RowClassRules,
RowDragEvent,
RowNode,
} from '@ag-grid-community/core';
import '@ag-grid-community/styles/ag-theme-alpine.css';
import { VirtualGridAutoSizerContainer, VirtualGridContainer, getColumnDefs } from '/@/components';
@@ -16,17 +17,19 @@ import {
} from '/@/store';
import { useSettingsStore } from '/@/store/settings.store';
import type { QueueSong, TableType } from '/@/types';
import type { AgGridReact as AgGridReactType } from '@ag-grid-community/react/lib/agGridReact';
import { ErrorBoundary } from 'react-error-boundary';
import { mpvPlayer } from '#preload';
import { VirtualTable } from '/@/components/virtual-table';
import { ErrorFallback } from '/@/features/action-required';
import type { Song } from '/@/api/types';
type QueueProps = {
type: TableType;
};
export const PlayQueue = ({ type }: QueueProps) => {
const gridRef = useRef<any>(null);
export const PlayQueue = forwardRef(({ type }: QueueProps, ref: any) => {
const gridRef = useRef<AgGridReactType<Song> | null | any>(null);
const queue = useDefaultQueue();
const { reorderQueue, setCurrentTrack } = useQueueControls();
const currentSong = useCurrentSong();
@@ -35,6 +38,12 @@ export const PlayQueue = ({ type }: QueueProps) => {
const { setAppStore } = useAppStoreActions();
const tableConfig = useSettingsStore((state) => state.tables[type]);
useImperativeHandle(ref, () => ({
get grid() {
return gridRef?.current;
},
}));
const columnDefs = useMemo(() => getColumnDefs(tableConfig.columns), [tableConfig.columns]);
const defaultColumnDefs: ColDef = useMemo(() => {
return {
@@ -71,19 +80,22 @@ export const PlayQueue = ({ type }: QueueProps) => {
const { api } = gridRef?.current || {};
clearTimeout(timeout);
timeout = setTimeout(() => api.redrawRows(), 250);
timeout = setTimeout(() => api?.redrawRows(), 250);
};
const handleGridReady = () => {
const { api } = gridRef?.current || {};
const currentNode = api.getRowNode(currentSong?.uniqueId);
api.ensureNodeVisible(currentNode, 'middle');
if (currentSong?.uniqueId) {
const currentNode = api?.getRowNode(currentSong?.uniqueId);
api?.ensureNodeVisible(currentNode, 'middle');
}
};
const handleColumnChange = () => {
const { columnApi } = gridRef?.current || {};
const columnsOrder = columnApi.getAllGridColumns();
const columnsOrder = columnApi?.getAllGridColumns();
if (!columnsOrder) return;
const columnsInSettings = useSettingsStore.getState().tables[type].columns;
@@ -114,7 +126,7 @@ export const PlayQueue = ({ type }: QueueProps) => {
const handleGridSizeChange = () => {
if (tableConfig.autoFit) {
gridRef?.current.api.sizeColumnsToFit();
gridRef?.current?.api.sizeColumnsToFit();
}
};
@@ -134,10 +146,12 @@ export const PlayQueue = ({ type }: QueueProps) => {
return;
}
const currentNode = api.getRowNode(currentSong?.uniqueId);
const previousNode = api.getRowNode(previousSong?.uniqueId);
const currentNode = currentSong?.uniqueId ? api.getRowNode(currentSong.uniqueId) : undefined;
const previousNode = previousSong?.uniqueId
? api.getRowNode(previousSong?.uniqueId)
: undefined;
const rowNodes = [currentNode, previousNode];
const rowNodes = [currentNode, previousNode].filter((e) => e !== undefined) as RowNode<any>[];
if (rowNodes) {
api.redrawRows({ rowNodes });
@@ -186,8 +200,6 @@ export const PlayQueue = ({ type }: QueueProps) => {
rowData={queue}
rowHeight={tableConfig.rowHeight || 40}
rowSelection="multiple"
// onCellClicked={(e) => console.log('clicked', e)}
// onCellContextMenu={(e) => console.log(e)}
onCellDoubleClicked={handlePlayByRowClick}
onColumnMoved={handleColumnChange}
onColumnResized={handleColumnChange}
@@ -198,7 +210,6 @@ export const PlayQueue = ({ type }: QueueProps) => {
/>
</VirtualGridAutoSizerContainer>
</VirtualGridContainer>
{/* <TableConfigDropdown type={type} /> */}
</ErrorBoundary>
);
};
});
@@ -0,0 +1,28 @@
import { useRef } from 'react';
import type { AgGridReact as AgGridReactType } from '@ag-grid-community/react/lib/agGridReact';
import { Stack } from '@mantine/core';
import { PlayQueue } from '/@/features/now-playing/components/play-queue';
import { PlayQueueListControls } from './play-queue-list-controls';
import type { Song } from '/@/api/types';
export const SidebarPlayQueue = () => {
const queueRef = useRef<{ grid: AgGridReactType<Song> } | null>(null);
return (
<Stack
pb="1rem"
pt="2.5rem"
sx={{ height: '100%' }}
>
<PlayQueue
ref={queueRef}
type="sideQueue"
/>
<PlayQueueListControls
gridApi={queueRef.current?.grid.api}
gridColumnApi={queueRef.current?.grid.columnApi}
type="sideQueue"
/>
</Stack>
);
};
@@ -1 +1,4 @@
export * from './components/play-queue';
export * from './components/sidebar-play-queue';
export * from './components/drawer-play-queue';
export * from './components/play-queue-list-controls';
@@ -15,7 +15,7 @@ import { constrainRightSidebarWidth, constrainSidebarWidth } from '/@/utils';
import { Playerbar } from '/@/features/player';
import { Sidebar } from '/@/features/sidebar/components/sidebar';
import { useAppStoreActions } from '/@/store/app.store';
import { PlayQueue } from '/@/features/now-playing';
import { DrawerPlayQueue, SidebarPlayQueue } from '/@/features/now-playing';
if (!isElectron()) {
useSettingsStore.getState().setSettings({
@@ -111,20 +111,6 @@ const QueueDrawerArea = styled(motion.div)`
user-select: none;
`;
const SideQueueContainer = styled.div`
width: 100%;
height: 100%;
.ag-root ::-webkit-scrollbar-track-piece {
background: var(--main-bg);
}
.ag-theme-alpine-dark {
--ag-background-color: var(--sidebar-bg) !important;
--ag-odd-row-background-color: var(--sidebar-bg) !important;
}
`;
interface DefaultLayoutProps {
shell?: boolean;
}
@@ -183,10 +169,9 @@ export const DefaultLayout = ({ shell }: DefaultLayoutProps) => {
x: '50vw',
},
open: {
boxShadow: '4px 4px 10px 0px rgba(0,0,0,.75)',
boxShadow: '2px 2px 10px 10px rgba(0,0,0,.1)',
height: 'calc(100vh - 150px)',
minWidth: '400px',
opacity: 0.98,
position: 'absolute',
right: '20px',
top: '50px',
@@ -315,9 +300,7 @@ export const DefaultLayout = ({ shell }: DefaultLayoutProps) => {
}, 50);
}}
>
<SideQueueContainer>
<PlayQueue type="sideDrawerQueue" />
</SideQueueContainer>
<DrawerPlayQueue />
</QueueDrawer>
)}
</AnimatePresence>
@@ -344,9 +327,7 @@ export const DefaultLayout = ({ shell }: DefaultLayoutProps) => {
startResizing('right');
}}
/>
<SideQueueContainer>
<PlayQueue type="sideQueue" />
</SideQueueContainer>
<SidebarPlayQueue />
</RightSidebarContainer>
)}
</AnimatePresence>
+98 -3
View File
@@ -1,4 +1,5 @@
import map from 'lodash/map';
import merge from 'lodash/merge';
import shuffle from 'lodash/shuffle';
import { nanoid } from 'nanoid/non-secure';
import create from 'zustand';
@@ -59,16 +60,18 @@ export interface PlayerSlice extends PlayerState {
autoNext: () => PlayerData;
checkIsFirstTrack: () => boolean;
checkIsLastTrack: () => boolean;
// getNextTrack: () => QueueSong;
// getPreviousTrack: () => QueueSong;
clearQueue: () => PlayerData;
getPlayerData: () => PlayerData;
getQueueData: () => QueueData;
moveToBottomOfQueue: (uniqueIds: string[]) => PlayerData;
moveToTopOfQueue: (uniqueIds: string[]) => PlayerData;
next: () => PlayerData;
pause: () => void;
play: () => void;
player1: () => QueueSong | undefined;
player2: () => QueueSong | undefined;
previous: () => PlayerData;
removeFromQueue: (uniqueIds: string[]) => PlayerData;
reorderQueue: (rowUniqueIds: string[], afterUniqueId?: string) => PlayerData;
setCurrentIndex: (index: number) => PlayerData;
setCurrentTime: (time: number) => void;
@@ -79,6 +82,7 @@ export interface PlayerSlice extends PlayerState {
setShuffledIndex: (index: number) => PlayerData;
setStore: (data: Partial<PlayerState>) => void;
setVolume: (volume: number) => void;
shuffleQueue: () => PlayerData;
};
}
@@ -232,6 +236,19 @@ export const usePlayerStore = create<PlayerSlice>()(
return currentIndex === get().queue.default.length - 1;
},
clearQueue: () => {
set((state) => {
state.queue.default = [];
state.queue.shuffled = [];
state.queue.sorted = [];
state.current.index = 0;
state.current.shuffledIndex = 0;
state.current.player = 1;
state.current.song = undefined;
});
return get().actions.getPlayerData();
},
getPlayerData: () => {
const queue = get().queue.default;
const currentPlayer = get().current.player;
@@ -370,6 +387,46 @@ export const usePlayerStore = create<PlayerSlice>()(
previous: queue[get().current.index - 1],
};
},
moveToBottomOfQueue: (uniqueIds) => {
const queue = get().queue.default;
const songsToMove = queue.filter((song) => uniqueIds.includes(song.uniqueId));
const songsToStay = queue.filter((song) => !uniqueIds.includes(song.uniqueId));
const reorderedQueue = [...songsToStay, ...songsToMove];
const currentSongUniqueId = get().current.song?.uniqueId;
const newCurrentSongIndex = reorderedQueue.findIndex(
(song) => song.uniqueId === currentSongUniqueId,
);
set((state) => {
state.current.index = newCurrentSongIndex;
state.queue.default = reorderedQueue;
});
return get().actions.getPlayerData();
},
moveToTopOfQueue: (uniqueIds) => {
const queue = get().queue.default;
const songsToMove = queue.filter((song) => uniqueIds.includes(song.uniqueId));
const songsToStay = queue.filter((song) => !uniqueIds.includes(song.uniqueId));
const reorderedQueue = [...songsToMove, ...songsToStay];
const currentSongUniqueId = get().current.song?.uniqueId;
const newCurrentSongIndex = reorderedQueue.findIndex(
(song) => song.uniqueId === currentSongUniqueId,
);
set((state) => {
state.current.index = newCurrentSongIndex;
state.queue.default = reorderedQueue;
});
return get().actions.getPlayerData();
},
next: () => {
const isLastTrack = get().actions.checkIsLastTrack();
const repeat = get().repeat;
@@ -472,6 +529,17 @@ export const usePlayerStore = create<PlayerSlice>()(
return get().actions.getPlayerData();
},
removeFromQueue: (uniqueIds) => {
const queue = get().queue.default;
const newQueue = queue.filter((song) => !uniqueIds.includes(song.uniqueId));
set((state) => {
state.queue.default = newQueue;
});
return get().actions.getPlayerData();
},
reorderQueue: (rowUniqueIds: string[], afterUniqueId?: string) => {
// Don't move if dropping on top of a selected row
if (afterUniqueId && rowUniqueIds.includes(afterUniqueId)) {
@@ -633,6 +701,22 @@ export const usePlayerStore = create<PlayerSlice>()(
state.volume = volume;
});
},
shuffleQueue: () => {
const queue = get().queue.default;
const shuffledQueue = shuffle(queue);
const currentSongUniqueId = get().current.song?.uniqueId;
const newCurrentSongIndex = shuffledQueue.findIndex(
(song) => song.uniqueId === currentSongUniqueId,
);
set((state) => {
state.current.index = newCurrentSongIndex;
state.queue.default = shuffledQueue;
});
return get().actions.getPlayerData();
},
},
current: {
index: 0,
@@ -658,7 +742,13 @@ export const usePlayerStore = create<PlayerSlice>()(
})),
{ name: 'store_player' },
),
{ name: 'store_player' },
{
merge: (persistedState, currentState) => {
return merge(currentState, persistedState);
},
name: 'store_player',
version: 1,
},
),
);
@@ -686,10 +776,15 @@ export const useQueueControls = () =>
usePlayerStore(
(state) => ({
addToQueue: state.actions.addToQueue,
clearQueue: state.actions.clearQueue,
moveToBottomOfQueue: state.actions.moveToBottomOfQueue,
moveToTopOfQueue: state.actions.moveToTopOfQueue,
removeFromQueue: state.actions.removeFromQueue,
reorderQueue: state.actions.reorderQueue,
setCurrentIndex: state.actions.setCurrentIndex,
setCurrentTrack: state.actions.setCurrentTrack,
setShuffledIndex: state.actions.setShuffledIndex,
shuffleQueue: state.actions.shuffleQueue,
}),
shallow,
);