initial implementation of play queue for new list

This commit is contained in:
jeffvli
2025-11-08 14:28:31 -08:00
parent d5020b7a43
commit d8222e9c8c
7 changed files with 82 additions and 349 deletions
@@ -27,7 +27,7 @@ export const DrawerPlayQueue = () => {
/> />
</div> </div>
<Flex bg="var(--theme-colors-background)" h="100%" mb="0.6rem"> <Flex bg="var(--theme-colors-background)" h="100%" mb="0.6rem">
<PlayQueue ref={queueRef} searchTerm={search} type="sideQueue" /> <PlayQueue ref={queueRef} searchTerm={search} listKey="sideQueue" />
</Flex> </Flex>
</Flex> </Flex>
); );
@@ -1,21 +1,18 @@
import type { AgGridReact as AgGridReactType } from '@ag-grid-community/react/lib/agGridReact'; import type { AgGridReact as AgGridReactType } from '@ag-grid-community/react/lib/agGridReact';
import isElectron from 'is-electron'; import { type MutableRefObject } from 'react';
import { type MutableRefObject, useCallback } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { TableConfigDropdown } from '/@/renderer/components/virtual-table'; import { SONG_TABLE_COLUMNS } from '/@/renderer/components/virtual-table';
import { usePlayerContext } from '/@/renderer/features/player/context/player-context';
import { updateSong } from '/@/renderer/features/player/update-remote-song'; import { updateSong } from '/@/renderer/features/player/update-remote-song';
import { SearchInput } from '/@/renderer/features/shared/components/search-input'; import { ListConfigMenu } from '/@/renderer/features/shared/components/list-config-menu';
import { usePlaybackType } from '/@/renderer/store/settings.store'; import { usePlaybackType } from '/@/renderer/store/settings.store';
import { ActionIcon } from '/@/shared/components/action-icon/action-icon'; import { ActionIcon } from '/@/shared/components/action-icon/action-icon';
import { Group } from '/@/shared/components/group/group'; import { Group } from '/@/shared/components/group/group';
import { Popover } from '/@/shared/components/popover/popover';
import { Song } from '/@/shared/types/domain-types'; import { Song } from '/@/shared/types/domain-types';
import { ItemListKey } from '/@/shared/types/types'; import { ItemListKey } from '/@/shared/types/types';
const mpvPlayer = isElectron() ? window.api.mpvPlayer : null;
interface PlayQueueListOptionsProps { interface PlayQueueListOptionsProps {
handleSearch: (value: string) => void; handleSearch: (value: string) => void;
searchTerm?: string; searchTerm?: string;
@@ -41,54 +38,49 @@ export const PlayQueueListControls = ({
// const { pause } = usePlayerControls(); // const { pause } = usePlayerControls();
const player = usePlayerContext();
const playbackType = usePlaybackType(); const playbackType = usePlaybackType();
// const setCurrentTime = useSetCurrentTime(); // const setCurrentTime = useSetCurrentTime();
const handleMoveToNext = () => { const handleMoveToNext = () => {
const selectedRows = tableRef?.current?.grid.api.getSelectedRows(); // const selectedRows = tableRef?.current?.grid.api.getSelectedRows();
const uniqueIds = selectedRows?.map((row) => row.uniqueId); // const uniqueIds = selectedRows?.map((row) => row.uniqueId);
if (!uniqueIds?.length) return; // if (!uniqueIds?.length) return;
// // const playerData = moveToNextOfQueue(uniqueIds);
// const playerData = moveToNextOfQueue(uniqueIds); // // if (playbackType === PlaybackType.LOCAL) {
// // setQueueNext(playerData);
// if (playbackType === PlaybackType.LOCAL) { // // }
// setQueueNext(playerData); // player.moveSelectedToNext(selectedRows);
// }
}; };
const handleMoveToBottom = () => { const handleMoveToBottom = () => {
const selectedRows = tableRef?.current?.grid.api.getSelectedRows(); // const selectedRows = tableRef?.current?.grid.api.getSelectedRows();
const uniqueIds = selectedRows?.map((row) => row.uniqueId); // const uniqueIds = selectedRows?.map((row) => row.uniqueId);
if (!uniqueIds?.length) return; // if (!uniqueIds?.length) return;
// const playerData = moveToBottomOfQueue(uniqueIds); // const playerData = moveToBottomOfQueue(uniqueIds);
// if (playbackType === PlaybackType.LOCAL) { // if (playbackType === PlaybackType.LOCAL) {
// setQueueNext(playerData); // setQueueNext(playerData);
// } // }
}; };
const handleMoveToTop = () => { const handleMoveToTop = () => {
const selectedRows = tableRef?.current?.grid.api.getSelectedRows(); // const selectedRows = tableRef?.current?.grid.api.getSelectedRows();
const uniqueIds = selectedRows?.map((row) => row.uniqueId); // const uniqueIds = selectedRows?.map((row) => row.uniqueId);
if (!uniqueIds?.length) return; // if (!uniqueIds?.length) return;
// const playerData = moveToTopOfQueue(uniqueIds); // const playerData = moveToTopOfQueue(uniqueIds);
// if (playbackType === PlaybackType.LOCAL) { // if (playbackType === PlaybackType.LOCAL) {
// setQueueNext(playerData); // setQueueNext(playerData);
// } // }
}; };
const handleRemoveSelected = () => { const handleRemoveSelected = () => {
const selectedRows = tableRef?.current?.grid.api.getSelectedRows(); // const selectedRows = tableRef?.current?.grid.api.getSelectedRows();
const uniqueIds = selectedRows?.map((row) => row.uniqueId); // const uniqueIds = selectedRows?.map((row) => row.uniqueId);
if (!uniqueIds?.length) return; // if (!uniqueIds?.length) return;
// const currentSong = usePlayerStore.getState().current.song; // const currentSong = usePlayerStore.getState().current.song;
// const playerData = removeFromQueue(uniqueIds); // const playerData = removeFromQueue(uniqueIds);
// const isCurrentSongRemoved = currentSong && uniqueIds.includes(currentSong.uniqueId); // const isCurrentSongRemoved = currentSong && uniqueIds.includes(currentSong.uniqueId);
// if (playbackType === PlaybackType.LOCAL) { // if (playbackType === PlaybackType.LOCAL) {
// if (isCurrentSongRemoved) { // if (isCurrentSongRemoved) {
// setQueue(playerData); // setQueue(playerData);
@@ -96,7 +88,6 @@ export const PlayQueueListControls = ({
// setQueueNext(playerData); // setQueueNext(playerData);
// } // }
// } // }
// if (isCurrentSongRemoved) { // if (isCurrentSongRemoved) {
// updateSong(playerData.current.song); // updateSong(playerData.current.song);
// } // }
@@ -123,25 +114,9 @@ export const PlayQueueListControls = ({
// } // }
}; };
const handleSearchTerm = useCallback(
(term: string) => {
handleSearch(term);
tableRef.current?.grid.api.redrawRows();
},
[handleSearch, tableRef],
);
const hasSearch = !!searchTerm;
return ( return (
<Group <Group justify="space-between" px="1rem" py="1rem" w="100%">
justify="space-between" <Group gap="xs">
px="1rem"
py="1rem"
style={{ alignItems: 'center' }}
w="100%"
>
<Group gap="sm">
<ActionIcon <ActionIcon
icon="mediaShuffle" icon="mediaShuffle"
iconProps={{ size: 'lg' }} iconProps={{ size: 'lg' }}
@@ -150,7 +125,7 @@ export const PlayQueueListControls = ({
variant="subtle" variant="subtle"
/> />
<ActionIcon <ActionIcon
disabled={hasSearch} // disabled={hasSearch}
icon="mediaPlayNext" icon="mediaPlayNext"
iconProps={{ size: 'lg' }} iconProps={{ size: 'lg' }}
onClick={handleMoveToNext} onClick={handleMoveToNext}
@@ -158,7 +133,7 @@ export const PlayQueueListControls = ({
variant="subtle" variant="subtle"
/> />
<ActionIcon <ActionIcon
disabled={hasSearch} // disabled={hasSearch}
icon="arrowDownToLine" icon="arrowDownToLine"
iconProps={{ size: 'lg' }} iconProps={{ size: 'lg' }}
onClick={handleMoveToBottom} onClick={handleMoveToBottom}
@@ -166,7 +141,7 @@ export const PlayQueueListControls = ({
variant="subtle" variant="subtle"
/> />
<ActionIcon <ActionIcon
disabled={hasSearch} // disabled={hasSearch}
icon="arrowUpToLine" icon="arrowUpToLine"
iconProps={{ size: 'lg' }} iconProps={{ size: 'lg' }}
onClick={handleMoveToTop} onClick={handleMoveToTop}
@@ -189,27 +164,12 @@ export const PlayQueueListControls = ({
tooltip={{ label: t('action.clearQueue', { postProcess: 'sentenceCase' }) }} tooltip={{ label: t('action.clearQueue', { postProcess: 'sentenceCase' }) }}
variant="subtle" variant="subtle"
/> />
<SearchInput
onChange={(e) => handleSearchTerm(e.target.value)}
value={searchTerm}
/>
</Group> </Group>
<Group> <Group>
<Popover position="top-end" transitionProps={{ transition: 'fade' }}> <ListConfigMenu
<Popover.Target> listKey={ItemListKey.SIDE_QUEUE}
<ActionIcon tableColumnsData={SONG_TABLE_COLUMNS}
icon="settings" />
iconProps={{ size: 'lg' }}
tooltip={{
label: t('common.configure', { postProcess: 'sentenceCase' }),
}}
variant="subtle"
/>
</Popover.Target>
<Popover.Dropdown>
<TableConfigDropdown type={type} />
</Popover.Dropdown>
</Popover>
</Group> </Group>
</Group> </Group>
); );
@@ -1,51 +1,25 @@
import type { RowClassRules, RowDragEvent, RowNode } from '@ag-grid-community/core';
import type { AgGridReact as AgGridReactType } from '@ag-grid-community/react/lib/agGridReact';
import type { Ref } from 'react'; import type { Ref } from 'react';
import '@ag-grid-community/styles/ag-theme-alpine.css'; import { forwardRef, useMemo } from 'react';
import { useMergedRef } from '@mantine/hooks';
import debounce from 'lodash/debounce';
import { forwardRef, useEffect, useImperativeHandle, useMemo, useRef, useState } from 'react';
import { ErrorBoundary } from 'react-error-boundary';
import { getColumnDefs, VirtualTable } from '/@/renderer/components/virtual-table'; import { ItemTableList } from '/@/renderer/components/item-list/item-table-list/item-table-list';
import { ErrorFallback } from '/@/renderer/features/action-required/components/error-fallback'; import { ItemTableListColumn } from '/@/renderer/components/item-list/item-table-list/item-table-list-column';
import { QUEUE_CONTEXT_MENU_ITEMS } from '/@/renderer/features/context-menu/context-menu-items'; import { useListSettings, usePlayerQueue } from '/@/renderer/store';
import { useHandleTableContextMenu } from '/@/renderer/features/context-menu/hooks/use-handle-context-menu';
import { useAppFocus } from '/@/renderer/hooks';
import {
useAppStoreActions,
usePlayerQueue,
usePlayerSong,
usePlayerStatus,
} from '/@/renderer/store';
import {
useSettingsStore,
useSettingsStoreActions,
useTableSettings,
} from '/@/renderer/store/settings.store';
import { searchSongs } from '/@/renderer/utils/search-songs'; import { searchSongs } from '/@/renderer/utils/search-songs';
import { LibraryItem, QueueSong } from '/@/shared/types/domain-types'; import { LibraryItem, QueueSong } from '/@/shared/types/domain-types';
import { ItemListKey } from '/@/shared/types/types'; import { ItemListKey } from '/@/shared/types/types';
type QueueProps = { type QueueProps = {
type: ItemListKey; listKey: ItemListKey;
searchTerm: string | undefined;
}; };
export const PlayQueue = forwardRef(({ searchTerm, type }: QueueProps, ref: Ref<any>) => { export const PlayQueue = forwardRef(({ listKey, searchTerm }: QueueProps, ref: Ref<any>) => {
const tableRef = useRef<AgGridReactType | null>(null); const { table } = useListSettings(listKey);
const mergedRef = useMergedRef(ref, tableRef);
const queue = usePlayerQueue();
const currentSong = usePlayerSong();
const status = usePlayerStatus();
const { setSettings } = useSettingsStoreActions();
const { setAppStore } = useAppStoreActions();
const tableConfig = useTableSettings(type);
const [gridApi, setGridApi] = useState<AgGridReactType | undefined>();
const isFocused = useAppFocus();
const isFocusedRef = useRef<boolean>(isFocused);
const songs = useMemo(() => { const queue = usePlayerQueue();
const data: QueueSong[] = useMemo(() => {
if (searchTerm) { if (searchTerm) {
return searchSongs(queue, searchTerm); return searchSongs(queue, searchTerm);
} }
@@ -53,230 +27,21 @@ export const PlayQueue = forwardRef(({ searchTerm, type }: QueueProps, ref: Ref<
return queue; return queue;
}, [queue, searchTerm]); }, [queue, searchTerm]);
useEffect(() => {
if (tableRef.current) {
setGridApi(tableRef.current);
}
}, []);
useImperativeHandle(ref, () => ({
get grid() {
return gridApi;
},
}));
const columnDefs = useMemo(
() => getColumnDefs(tableConfig.columns, false, 'generic'),
[tableConfig.columns],
);
// const handleDoubleClick = (e: CellDoubleClickedEvent) => {
// const playerData = setCurrentTrack(e.data.uniqueId);
// updateSong(playerData.current.song);
// if (playbackType === PlaybackType.LOCAL) {
// mpvPlayer!.volume(volume);
// setQueue(playerData, false);
// } else {
// const player =
// playerData.current.player === 1
// ? PlayersRef.current?.player1
// : PlayersRef.current?.player2;
// const underlying = player?.getInternalPlayer();
// if (underlying) {
// underlying.currentTime = 0;
// }
// }
// play();
// };
const handleDragStart = () => {
if (type === 'sideDrawerQueue') {
setAppStore({ isReorderingQueue: true });
}
};
let timeout: any;
const handleDragEnd = (e: RowDragEvent<QueueSong>) => {
if (!e.nodes.length) return;
const selectedUniqueIds = e.nodes
.map((node) => node.data?._uniqueId)
.filter((e) => e !== undefined);
// const playerData = reorderQueue(selectedUniqueIds as string[], e.overNode?.data?.uniqueId);
// if (playbackType === PlaybackType.LOCAL) {
// setQueueNext(playerData);
// }
if (type === 'sideDrawerQueue') {
setAppStore({ isReorderingQueue: false });
}
const { api } = tableRef?.current || {};
clearTimeout(timeout);
timeout = setTimeout(() => api?.redrawRows(), 250);
};
const handleGridReady = () => {
const { api } = tableRef?.current || {};
if (currentSong?._uniqueId) {
const currentNode = api?.getRowNode(currentSong?._uniqueId);
if (!currentNode) return;
api?.ensureNodeVisible(currentNode, 'middle');
}
};
const handleColumnChange = () => {
const { columnApi } = tableRef?.current || {};
const columnsOrder = columnApi?.getAllGridColumns();
if (!columnsOrder) return;
const columnsInSettings = useSettingsStore.getState().lists[type].columns;
const updatedColumns: PersistedTableColumn[] = [];
for (const column of columnsOrder) {
const columnInSettings = columnsInSettings.find(
(c) => c.column === column.getColDef().colId,
);
if (columnInSettings) {
updatedColumns.push({
...columnInSettings,
...(!useSettingsStore.getState().lists[type].autoFit && {
width: column.getActualWidth(),
}),
});
}
}
setSettings({
lists: {
...useSettingsStore.getState().lists,
[type]: {
...useSettingsStore.getState().lists[type],
columns: updatedColumns,
},
},
});
};
const debouncedColumnChange = debounce(handleColumnChange, 250);
const handleGridSizeChange = () => {
if (tableConfig.autoFit) {
tableRef?.current?.api?.sizeColumnsToFit();
}
};
const rowClassRules = useMemo<RowClassRules | undefined>(() => {
return {
'current-song': (params) => {
return params.data.uniqueId === currentSong?._uniqueId;
},
};
}, [currentSong?._uniqueId]);
const previousSongRef = useRef<QueueSong | undefined>(undefined);
useEffect(() => {
if (currentSong) {
previousSongRef.current = currentSong;
}
}, [currentSong]);
// Redraw the current song row when the previous song changes
useEffect(() => {
if (tableRef?.current) {
const { api, columnApi } = tableRef?.current || {};
if (api == null || columnApi == null) {
return;
}
const currentNode = currentSong?._uniqueId
? api.getRowNode(currentSong._uniqueId)
: undefined;
const previousNode = previousSongRef.current?._uniqueId
? api.getRowNode(previousSongRef.current?._uniqueId)
: undefined;
const rowNodes = [currentNode, previousNode].filter(
(e) => e !== undefined,
) as RowNode<any>[];
if (rowNodes) {
api.redrawRows({ rowNodes });
if (tableConfig.followCurrentSong) {
if (!currentNode) return;
api.ensureNodeVisible(currentNode, 'middle');
}
}
}
}, [currentSong, previousSongRef, tableConfig.followCurrentSong, status]);
// As a separate rule, update the current row when focus changes. This is
// to prevent queue scrolling when the application loses and then gains focus.
// The body should only fire when focus changes, even though it depends on current song
useEffect(() => {
if (isFocused !== isFocusedRef.current && tableRef?.current) {
const { api, columnApi } = tableRef.current;
if (api == null || columnApi == null) {
return;
}
const currentNode = currentSong?._uniqueId
? api.getRowNode(currentSong._uniqueId)
: undefined;
if (currentNode) {
api.redrawRows({ rowNodes: [currentNode] });
}
isFocusedRef.current = isFocused;
}
}, [currentSong, isFocused]);
const onCellContextMenu = useHandleTableContextMenu(LibraryItem.SONG, QUEUE_CONTEXT_MENU_ITEMS);
return ( return (
<ErrorBoundary FallbackComponent={ErrorFallback}> <ItemTableList
<VirtualGridAutoSizerContainer> CellComponent={ItemTableListColumn}
<VirtualTable columns={table.columns}
alwaysShowHorizontalScroll data={data || []}
autoFitColumns={tableConfig.autoFit} enableAlternateRowColors={table.enableAlternateRowColors}
columnDefs={columnDefs} enableExpansion={false}
context={{ enableHeader={true}
currentSong, enableHorizontalBorders={table.enableHorizontalBorders}
// handleDoubleClick, enableRowHoverHighlight={table.enableRowHoverHighlight}
isFocused, enableSelection={true}
isQueue: true, enableVerticalBorders={table.enableVerticalBorders}
itemType: LibraryItem.SONG, itemType={LibraryItem.ALBUM}
onCellContextMenu, ref={ref}
status, size={table.size}
}} />
deselectOnClickOutside={type === 'fullScreen'}
getRowId={(data) => data.data.uniqueId}
onCellContextMenu={onCellContextMenu}
// onCellDoubleClicked={handleDoubleClick}
onColumnMoved={handleColumnChange}
onColumnResized={debouncedColumnChange}
onDragStarted={handleDragStart}
onGridReady={handleGridReady}
onGridSizeChanged={handleGridSizeChange}
onRowDragEnd={handleDragEnd}
ref={mergedRef}
rowBuffer={50}
rowClassRules={rowClassRules}
rowData={songs}
rowDragEntireRow
rowDragMultiRow
rowHeight={tableConfig.rowHeight || 40}
suppressCellFocus={type === 'fullScreen'}
/>
</VirtualGridAutoSizerContainer>
</ErrorBoundary>
); );
}); });
@@ -1,17 +1,24 @@
import type { AgGridReact as AgGridReactType } from '@ag-grid-community/react/lib/agGridReact';
import { useRef, useState } from 'react'; import { useRef, useState } from 'react';
import { useWindowSettings } from '/@/renderer/store/settings.store'; import { PlayQueue } from '/@/renderer/features/now-playing/components/play-queue';
import { Song } from '/@/shared/types/domain-types'; import { PlayQueueListControls } from '/@/renderer/features/now-playing/components/play-queue-list-controls';
import { Platform } from '/@/shared/types/types'; import { Flex } from '/@/shared/components/flex/flex';
import { ItemListKey } from '/@/shared/types/types';
export const SidebarPlayQueue = () => { export const SidebarPlayQueue = () => {
const queueRef = useRef<null | { grid: AgGridReactType<Song> }>(null); const tableRef = useRef<null>(null);
// const queueRef = useRef<null | { grid: AgGridReactType<Song> }>(null);
const [search, setSearch] = useState<string | undefined>(undefined); const [search, setSearch] = useState<string | undefined>(undefined);
const { windowBarStyle } = useWindowSettings();
const isWeb = windowBarStyle === Platform.WEB; return (
<Flex direction="column" h="100%">
return null; <PlayQueueListControls
handleSearch={setSearch}
searchTerm={search}
tableRef={tableRef}
type={ItemListKey.SIDE_QUEUE}
/>
<PlayQueue listKey={ItemListKey.SIDE_QUEUE} searchTerm={search} />
</Flex>
);
}; };
@@ -15,7 +15,7 @@ import {
} from '/@/renderer/store/full-screen-player.store'; } from '/@/renderer/store/full-screen-player.store';
import { Button } from '/@/shared/components/button/button'; import { Button } from '/@/shared/components/button/button';
import { Group } from '/@/shared/components/group/group'; import { Group } from '/@/shared/components/group/group';
import { PlayerType } from '/@/shared/types/types'; import { ItemListKey, PlayerType } from '/@/shared/types/types';
const Visualizer = lazy(() => const Visualizer = lazy(() =>
import('/@/renderer/features/player/components/visualizer').then((module) => ({ import('/@/renderer/features/player/components/visualizer').then((module) => ({
@@ -99,7 +99,7 @@ export const FullScreenPlayerQueue = () => {
</Group> </Group>
{activeTab === 'queue' ? ( {activeTab === 'queue' ? (
<div className={styles.queueContainer}> <div className={styles.queueContainer}>
<PlayQueue type="fullScreen" /> <PlayQueue listKey={ItemListKey.FULL_SCREEN} searchTerm={undefined} />
</div> </div>
) : activeTab === 'related' ? ( ) : activeTab === 'related' ? (
<div className={styles.queueContainer}> <div className={styles.queueContainer}>
@@ -1,7 +1,8 @@
.right-sidebar-container { .right-sidebar-container {
position: relative; position: relative;
grid-area: right-sidebar; grid-area: right-sidebar;
height: 100%; min-height: 0;
overflow: hidden;
border-left: 1px solid alpha(var(--theme-colors-border), 0.3); border-left: 1px solid alpha(var(--theme-colors-border), 0.3);
.current-song-cell:not(.current-playlist-song-cell) svg { .current-song-cell:not(.current-playlist-song-cell) svg {
+2 -2
View File
@@ -1,8 +1,8 @@
import Fuse from 'fuse.js'; import Fuse from 'fuse.js';
import { Song } from '/@/shared/types/domain-types'; import { QueueSong } from '/@/shared/types/domain-types';
export const searchSongs = (songs: Song[], searchTerm: string) => { export const searchSongs = (songs: QueueSong[], searchTerm: string) => {
const fuse = new Fuse(songs, { const fuse = new Fuse(songs, {
fieldNormWeight: 1, fieldNormWeight: 1,
ignoreLocation: true, ignoreLocation: true,