mirror of
https://github.com/jeffvli/feishin.git
synced 2026-05-08 21:10:12 +02:00
add support for full playlist re-order (#1327)
This commit is contained in:
@@ -295,8 +295,6 @@ export const useDefaultItemListControls = (args?: UseDefaultItemListControlsArgs
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(item, itemType);
|
||||
|
||||
// For context menus, prioritize the itemType prop when it's PLAYLIST_SONG or QUEUE_SONG
|
||||
// This is because playlist/queue songs are Song objects (_itemType: SONG) but need special context menus
|
||||
// Otherwise, use the item's _itemType if available, or fall back to the mapped itemType
|
||||
|
||||
+345
@@ -0,0 +1,345 @@
|
||||
import { useCallback } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useParams } from 'react-router';
|
||||
|
||||
import { getDraggedItems } from '/@/renderer/components/item-list/helpers/get-dragged-items';
|
||||
import {
|
||||
ItemTableListInnerColumn,
|
||||
TableColumnContainer,
|
||||
} from '/@/renderer/components/item-list/item-table-list/item-table-list-column';
|
||||
import { eventEmitter } from '/@/renderer/events/event-emitter';
|
||||
import { useDragDrop } from '/@/renderer/hooks/use-drag-drop';
|
||||
import { ActionIcon, ActionIconGroup } from '/@/shared/components/action-icon/action-icon';
|
||||
import { Stack } from '/@/shared/components/stack/stack';
|
||||
import { Text } from '/@/shared/components/text/text';
|
||||
import { useLongPress } from '/@/shared/hooks/use-long-press';
|
||||
import { LibraryItem } from '/@/shared/types/domain-types';
|
||||
import { DragOperation, DragTarget, DragTargetMap } from '/@/shared/types/drag-and-drop';
|
||||
|
||||
export const PlaylistReorderColumn = (props: ItemTableListInnerColumn) => {
|
||||
const { t } = useTranslation();
|
||||
const { playlistId } = useParams() as { playlistId?: string };
|
||||
const isHeaderEnabled = !!props.enableHeader;
|
||||
const isDataRow = isHeaderEnabled ? props.rowIndex > 0 : true;
|
||||
const item = isDataRow ? props.data[props.rowIndex] : null;
|
||||
|
||||
const isPlaylistSong = props.itemType === LibraryItem.PLAYLIST_SONG;
|
||||
|
||||
const { isDraggedOver, ref: dragRef } = useDragDrop<HTMLButtonElement>({
|
||||
drag: {
|
||||
getId: () => {
|
||||
if (!item || !isDataRow || !isPlaylistSong) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const draggedItems = getDraggedItems(item as any, props.internalState);
|
||||
return draggedItems.map((draggedItem) => draggedItem.id);
|
||||
},
|
||||
getItem: () => {
|
||||
if (!item || !isDataRow || !isPlaylistSong) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const draggedItems = getDraggedItems(item as any, props.internalState);
|
||||
return draggedItems;
|
||||
},
|
||||
itemType: LibraryItem.PLAYLIST_SONG,
|
||||
metadata: { fromReorderHandle: true },
|
||||
onDragStart: () => {
|
||||
if (!item || !isDataRow || !isPlaylistSong) {
|
||||
return;
|
||||
}
|
||||
|
||||
const draggedItems = getDraggedItems(item as any, props.internalState);
|
||||
if (props.internalState) {
|
||||
props.internalState.setDragging(draggedItems);
|
||||
}
|
||||
},
|
||||
onDrop: () => {
|
||||
if (props.internalState) {
|
||||
props.internalState.setDragging([]);
|
||||
}
|
||||
},
|
||||
operation: [DragOperation.REORDER],
|
||||
target: DragTargetMap[LibraryItem.PLAYLIST_SONG] || DragTarget.SONG,
|
||||
},
|
||||
drop: {
|
||||
canDrop: (args) => {
|
||||
// Only allow drops from PLAYLIST_SONG items
|
||||
return (
|
||||
args.source.itemType === LibraryItem.PLAYLIST_SONG &&
|
||||
isPlaylistSong &&
|
||||
isDataRow
|
||||
);
|
||||
},
|
||||
getData: () => {
|
||||
if (!item || !isDataRow) {
|
||||
return {
|
||||
id: [],
|
||||
item: [],
|
||||
itemType: LibraryItem.PLAYLIST_SONG,
|
||||
type: DragTarget.SONG,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
id: [(item as unknown as { id: string }).id],
|
||||
item: [item as unknown as unknown[]],
|
||||
itemType: LibraryItem.PLAYLIST_SONG,
|
||||
type: DragTargetMap[LibraryItem.PLAYLIST_SONG] || DragTarget.SONG,
|
||||
};
|
||||
},
|
||||
onDrag: () => {
|
||||
// Visual feedback is handled by isDraggedOver state
|
||||
},
|
||||
onDragLeave: () => {
|
||||
// Visual feedback is handled by isDraggedOver state
|
||||
},
|
||||
onDrop: (args) => {
|
||||
if (!item || !isDataRow || !isPlaylistSong) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Only handle drops from PLAYLIST_SONG items
|
||||
if (args.source.itemType !== LibraryItem.PLAYLIST_SONG) {
|
||||
return;
|
||||
}
|
||||
|
||||
const sourceItems = (args.source.item || []) as any[];
|
||||
const targetItem = item as any;
|
||||
|
||||
if (
|
||||
sourceItems.length > 0 &&
|
||||
args.edge &&
|
||||
(args.edge === 'top' || args.edge === 'bottom') &&
|
||||
playlistId
|
||||
) {
|
||||
// Emit event to reorder playlist songs
|
||||
eventEmitter.emit('PLAYLIST_REORDER', {
|
||||
edge: args.edge,
|
||||
playlistId,
|
||||
sourceIds: args.source.id,
|
||||
targetId: targetItem.id,
|
||||
});
|
||||
}
|
||||
|
||||
if (props.internalState) {
|
||||
props.internalState.setDragging([]);
|
||||
}
|
||||
},
|
||||
},
|
||||
isEnabled: isPlaylistSong && isDataRow && !!item,
|
||||
});
|
||||
|
||||
const draggedOverEdge: 'bottom' | 'top' | null =
|
||||
isDraggedOver === 'top' || isDraggedOver === 'bottom' ? isDraggedOver : null;
|
||||
|
||||
const getValidDataItems = useCallback(() => {
|
||||
return props.data.filter((d) => d !== null && (d as any).id);
|
||||
}, [props.data]);
|
||||
|
||||
const handleMoveUp = useCallback(() => {
|
||||
if (!item || !isDataRow || !isPlaylistSong || !playlistId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const validItems = getValidDataItems();
|
||||
const selectedItems = getDraggedItems(item as any, props.internalState);
|
||||
const sourceIds = selectedItems.map((draggedItem) => draggedItem.id);
|
||||
|
||||
if (sourceIds.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
let topmostIndex = validItems.length;
|
||||
for (const selectedItem of selectedItems) {
|
||||
const index = validItems.findIndex((d) => (d as any).id === selectedItem.id);
|
||||
if (index !== -1 && index < topmostIndex) {
|
||||
topmostIndex = index;
|
||||
}
|
||||
}
|
||||
|
||||
if (topmostIndex <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const targetItem = validItems[topmostIndex - 1];
|
||||
|
||||
eventEmitter.emit('PLAYLIST_REORDER', {
|
||||
edge: 'top',
|
||||
playlistId,
|
||||
sourceIds,
|
||||
targetId: (targetItem as any).id,
|
||||
});
|
||||
}, [item, isDataRow, isPlaylistSong, playlistId, getValidDataItems, props.internalState]);
|
||||
|
||||
const handleMoveToTop = useCallback(() => {
|
||||
if (!item || !isDataRow || !isPlaylistSong || !playlistId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const validItems = getValidDataItems();
|
||||
const selectedItems = getDraggedItems(item as any, props.internalState);
|
||||
const sourceIds = selectedItems.map((draggedItem) => draggedItem.id);
|
||||
|
||||
if (sourceIds.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const firstItem = validItems[0];
|
||||
|
||||
const isAlreadyAtTop = selectedItems.some(
|
||||
(selectedItem) => (selectedItem as any).id === (firstItem as any).id,
|
||||
);
|
||||
|
||||
if (!firstItem || isAlreadyAtTop) {
|
||||
return;
|
||||
}
|
||||
|
||||
eventEmitter.emit('PLAYLIST_REORDER', {
|
||||
edge: 'top',
|
||||
playlistId,
|
||||
sourceIds,
|
||||
targetId: (firstItem as any).id,
|
||||
});
|
||||
}, [item, isDataRow, isPlaylistSong, playlistId, getValidDataItems, props.internalState]);
|
||||
|
||||
const handleMoveDown = useCallback(() => {
|
||||
if (!item || !isDataRow || !isPlaylistSong || !playlistId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const validItems = getValidDataItems();
|
||||
const selectedItems = getDraggedItems(item as any, props.internalState);
|
||||
const sourceIds = selectedItems.map((draggedItem) => draggedItem.id);
|
||||
|
||||
if (sourceIds.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
let bottommostIndex = -1;
|
||||
for (const selectedItem of selectedItems) {
|
||||
const index = validItems.findIndex((d) => (d as any).id === selectedItem.id);
|
||||
if (index !== -1 && index > bottommostIndex) {
|
||||
bottommostIndex = index;
|
||||
}
|
||||
}
|
||||
|
||||
if (bottommostIndex === -1 || bottommostIndex >= validItems.length - 1) {
|
||||
return;
|
||||
}
|
||||
|
||||
const targetItem = validItems[bottommostIndex + 1];
|
||||
|
||||
eventEmitter.emit('PLAYLIST_REORDER', {
|
||||
edge: 'bottom',
|
||||
playlistId,
|
||||
sourceIds,
|
||||
targetId: (targetItem as any).id,
|
||||
});
|
||||
}, [item, isDataRow, isPlaylistSong, playlistId, getValidDataItems, props.internalState]);
|
||||
|
||||
const handleMoveToBottom = useCallback(() => {
|
||||
if (!item || !isDataRow || !isPlaylistSong || !playlistId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const validItems = getValidDataItems();
|
||||
const selectedItems = getDraggedItems(item as any, props.internalState);
|
||||
const sourceIds = selectedItems.map((draggedItem) => draggedItem.id);
|
||||
|
||||
if (sourceIds.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const lastItem = validItems[validItems.length - 1];
|
||||
|
||||
const isAlreadyAtBottom = selectedItems.some(
|
||||
(selectedItem) => (selectedItem as any).id === (lastItem as any).id,
|
||||
);
|
||||
|
||||
if (!lastItem || isAlreadyAtBottom) {
|
||||
return;
|
||||
}
|
||||
|
||||
eventEmitter.emit('PLAYLIST_REORDER', {
|
||||
edge: 'bottom',
|
||||
playlistId,
|
||||
sourceIds,
|
||||
targetId: (lastItem as any).id,
|
||||
});
|
||||
}, [item, isDataRow, isPlaylistSong, playlistId, getValidDataItems, props.internalState]);
|
||||
|
||||
const upButtonHandlers = useLongPress<HTMLButtonElement>({
|
||||
onClick: handleMoveUp,
|
||||
onLongPress: handleMoveToTop,
|
||||
});
|
||||
|
||||
const downButtonHandlers = useLongPress<HTMLButtonElement>({
|
||||
onClick: handleMoveDown,
|
||||
onLongPress: handleMoveToBottom,
|
||||
});
|
||||
|
||||
return (
|
||||
<TableColumnContainer {...props} isDraggedOver={draggedOverEdge}>
|
||||
<ActionIconGroup w="100%">
|
||||
<ActionIcon
|
||||
{...upButtonHandlers}
|
||||
icon="arrowUp"
|
||||
iconProps={{ size: 'md' }}
|
||||
size="xs"
|
||||
tooltip={{
|
||||
label: (
|
||||
<>
|
||||
<Stack gap="xs" justify="center">
|
||||
<Text fw={500} ta="center">
|
||||
{t('action.moveUp', { postProcess: 'sentenceCase' })}
|
||||
</Text>
|
||||
<Text fw={500} isMuted size="xs" ta="center">
|
||||
{t('action.holdToMoveToTop', {
|
||||
postProcess: 'sentenceCase',
|
||||
})}
|
||||
</Text>
|
||||
</Stack>
|
||||
</>
|
||||
),
|
||||
}}
|
||||
variant="default"
|
||||
/>
|
||||
<ActionIcon
|
||||
{...downButtonHandlers}
|
||||
icon="arrowDown"
|
||||
iconProps={{ size: 'md' }}
|
||||
size="xs"
|
||||
tooltip={{
|
||||
label: (
|
||||
<>
|
||||
<Stack gap="xs" justify="center">
|
||||
<Text fw={500} ta="center">
|
||||
{t('action.moveDown', { postProcess: 'sentenceCase' })}
|
||||
</Text>
|
||||
<Text fw={500} isMuted size="xs" ta="center">
|
||||
{t('action.holdToMoveToBottom', {
|
||||
postProcess: 'sentenceCase',
|
||||
})}
|
||||
</Text>
|
||||
</Stack>
|
||||
</>
|
||||
),
|
||||
}}
|
||||
variant="default"
|
||||
/>
|
||||
<ActionIcon
|
||||
icon="dragVertical"
|
||||
iconProps={{ size: 'md' }}
|
||||
ref={dragRef}
|
||||
size="xs"
|
||||
style={{
|
||||
cursor: isPlaylistSong ? 'grab' : 'default',
|
||||
}}
|
||||
variant="default"
|
||||
/>
|
||||
</ActionIconGroup>
|
||||
</TableColumnContainer>
|
||||
);
|
||||
};
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
import { disableNativeDragPreview } from '@atlaskit/pragmatic-drag-and-drop/element/disable-native-drag-preview';
|
||||
import clsx from 'clsx';
|
||||
import React, { CSSProperties, ReactElement, ReactNode, useEffect, useRef, useState } from 'react';
|
||||
import { useParams } from 'react-router';
|
||||
import { CellComponentProps } from 'react-window-v2';
|
||||
|
||||
import styles from './item-table-list-column.module.css';
|
||||
@@ -38,6 +39,7 @@ import { GenreColumn } from '/@/renderer/components/item-list/item-table-list/co
|
||||
import { ImageColumn } from '/@/renderer/components/item-list/item-table-list/columns/image-column';
|
||||
import { NumericColumn } from '/@/renderer/components/item-list/item-table-list/columns/numeric-column';
|
||||
import { PathColumn } from '/@/renderer/components/item-list/item-table-list/columns/path-column';
|
||||
import { PlaylistReorderColumn } from '/@/renderer/components/item-list/item-table-list/columns/playlist-reorder-column';
|
||||
import { RatingColumn } from '/@/renderer/components/item-list/item-table-list/columns/rating-column';
|
||||
import { RowIndexColumn } from '/@/renderer/components/item-list/item-table-list/columns/row-index-column';
|
||||
import { SizeColumn } from '/@/renderer/components/item-list/item-table-list/columns/size-column';
|
||||
@@ -46,6 +48,7 @@ import { TitleColumn } from '/@/renderer/components/item-list/item-table-list/co
|
||||
import { TitleCombinedColumn } from '/@/renderer/components/item-list/item-table-list/columns/title-combined-column';
|
||||
import { TableItemProps } from '/@/renderer/components/item-list/item-table-list/item-table-list';
|
||||
import { ItemControls, ItemListItem } from '/@/renderer/components/item-list/types';
|
||||
import { eventEmitter } from '/@/renderer/events/event-emitter';
|
||||
import { useDragDrop } from '/@/renderer/hooks/use-drag-drop';
|
||||
import { Flex } from '/@/shared/components/flex/flex';
|
||||
import { Icon } from '/@/shared/components/icon/icon';
|
||||
@@ -74,6 +77,7 @@ export interface ItemTableListInnerColumn extends ItemTableListColumn {
|
||||
}
|
||||
|
||||
export const ItemTableListColumn = (props: ItemTableListColumn) => {
|
||||
const { playlistId } = useParams() as { playlistId?: string };
|
||||
const type = props.columns[props.columnIndex].id as TableColumn;
|
||||
|
||||
const isHeaderEnabled = !!props.enableHeader;
|
||||
@@ -171,7 +175,9 @@ export const ItemTableListColumn = (props: ItemTableListColumn) => {
|
||||
operation:
|
||||
props.itemType === LibraryItem.QUEUE_SONG
|
||||
? [DragOperation.REORDER, DragOperation.ADD]
|
||||
: [DragOperation.ADD],
|
||||
: props.itemType === LibraryItem.PLAYLIST_SONG
|
||||
? [DragOperation.REORDER]
|
||||
: [DragOperation.ADD],
|
||||
target: DragTargetMap[props.itemType] || DragTarget.GENERIC,
|
||||
},
|
||||
drop: {
|
||||
@@ -180,10 +186,21 @@ export const ItemTableListColumn = (props: ItemTableListColumn) => {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Allow drops for QUEUE_SONG (queue reordering)
|
||||
if (props.itemType === LibraryItem.QUEUE_SONG) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Allow drops for PLAYLIST_SONG (playlist reordering)
|
||||
// Only allow drops when drag is started from the reorder handle
|
||||
if (
|
||||
props.itemType === LibraryItem.PLAYLIST_SONG &&
|
||||
args.source.itemType === LibraryItem.PLAYLIST_SONG &&
|
||||
args.source.metadata?.fromReorderHandle === true
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
},
|
||||
getData: () => {
|
||||
@@ -331,6 +348,33 @@ export const ItemTableListColumn = (props: ItemTableListColumn) => {
|
||||
}
|
||||
}
|
||||
|
||||
// Handle PLAYLIST_SONG reordering
|
||||
// Only allow drops when drag is started from the reorder handle
|
||||
if (
|
||||
args.self.itemType === LibraryItem.PLAYLIST_SONG &&
|
||||
args.source.itemType === LibraryItem.PLAYLIST_SONG &&
|
||||
args.source.metadata?.fromReorderHandle === true &&
|
||||
playlistId
|
||||
) {
|
||||
const sourceItems = (args.source.item || []) as any[];
|
||||
const targetItem = item as any;
|
||||
|
||||
if (
|
||||
sourceItems.length > 0 &&
|
||||
args.edge &&
|
||||
(args.edge === 'top' || args.edge === 'bottom') &&
|
||||
targetItem
|
||||
) {
|
||||
// Emit event to reorder playlist songs
|
||||
eventEmitter.emit('PLAYLIST_REORDER', {
|
||||
edge: args.edge,
|
||||
playlistId,
|
||||
sourceIds: args.source.id,
|
||||
targetId: targetItem.id,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (props.internalState) {
|
||||
props.internalState.setDragging([]);
|
||||
}
|
||||
@@ -469,6 +513,9 @@ export const ItemTableListColumn = (props: ItemTableListColumn) => {
|
||||
case TableColumn.PATH:
|
||||
return <PathColumn {...props} {...dragProps} controls={controls} type={type} />;
|
||||
|
||||
case TableColumn.PLAYLIST_REORDER:
|
||||
return <PlaylistReorderColumn {...props} controls={controls} type={type} />;
|
||||
|
||||
case TableColumn.ROW_INDEX:
|
||||
return <RowIndexColumn {...props} {...dragProps} controls={controls} type={type} />;
|
||||
|
||||
@@ -1213,6 +1260,11 @@ const columnLabelMap: Record<TableColumn, ReactNode | string> = {
|
||||
[TableColumn.PLAY_COUNT]: i18n.t('table.column.playCount', {
|
||||
postProcess: 'upperCase',
|
||||
}) as string,
|
||||
[TableColumn.PLAYLIST_REORDER]: (
|
||||
<Flex className={styles.headerIconWrapper}>
|
||||
<Icon icon="dragVertical" />
|
||||
</Flex>
|
||||
),
|
||||
[TableColumn.RELEASE_DATE]: i18n.t('table.column.releaseDate', {
|
||||
postProcess: 'upperCase',
|
||||
}) as string,
|
||||
|
||||
@@ -1198,6 +1198,29 @@ const BaseItemTableList = ({
|
||||
};
|
||||
}, [enableDrag, initialize, osInstance, pinnedRightColumnCount]);
|
||||
|
||||
useEffect(() => {
|
||||
if (pinnedLeftColumnCount === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { current: root } = pinnedLeftColumnRef;
|
||||
|
||||
if (!root || !root.firstElementChild) {
|
||||
return;
|
||||
}
|
||||
|
||||
const viewport = root.firstElementChild as HTMLElement;
|
||||
|
||||
if (enableDrag) {
|
||||
autoScrollForElements({
|
||||
canScroll: () => true,
|
||||
element: viewport,
|
||||
getAllowedAxis: () => 'vertical',
|
||||
getConfiguration: () => ({ maxScrollSpeed: 'fast' }),
|
||||
});
|
||||
}
|
||||
}, [enableDrag, pinnedLeftColumnCount]);
|
||||
|
||||
// Initialize overlayscrollbars for right pinned columns
|
||||
useEffect(() => {
|
||||
if (pinnedRightColumnCount === 0) {
|
||||
|
||||
Reference in New Issue
Block a user