mirror of
https://github.com/jeffvli/feishin.git
synced 2026-05-07 12:30:12 +02:00
377 lines
14 KiB
TypeScript
377 lines
14 KiB
TypeScript
import clsx from 'clsx';
|
|
import { forwardRef, useEffect, useMemo, useRef, useState } from 'react';
|
|
|
|
import styles from './play-queue.module.css';
|
|
|
|
import { useItemListColumnReorder } from '/@/renderer/components/item-list/helpers/use-item-list-column-reorder';
|
|
import { useItemListColumnResize } from '/@/renderer/components/item-list/helpers/use-item-list-column-resize';
|
|
import {
|
|
ItemTableList,
|
|
TableGroupHeader,
|
|
} from '/@/renderer/components/item-list/item-table-list/item-table-list';
|
|
import { ItemTableListColumn } from '/@/renderer/components/item-list/item-table-list/item-table-list-column';
|
|
import { ItemListHandle } from '/@/renderer/components/item-list/types';
|
|
import { eventEmitter } from '/@/renderer/events/event-emitter';
|
|
import { useIsPlayerFetching, usePlayer } from '/@/renderer/features/player/context/player-context';
|
|
import { searchLibraryItems } from '/@/renderer/features/shared/utils';
|
|
import { useDragDrop } from '/@/renderer/hooks/use-drag-drop';
|
|
import {
|
|
isShuffleEnabled,
|
|
mapShuffledToQueueIndex,
|
|
subscribeCurrentTrack,
|
|
subscribePlayerQueue,
|
|
useFollowCurrentSong,
|
|
useListSettings,
|
|
usePlayerActions,
|
|
usePlayerSong,
|
|
usePlayerStore,
|
|
} from '/@/renderer/store';
|
|
import { Flex } from '/@/shared/components/flex/flex';
|
|
import { LoadingOverlay } from '/@/shared/components/loading-overlay/loading-overlay';
|
|
import { useDebouncedValue } from '/@/shared/hooks/use-debounced-value';
|
|
import { useFocusWithin } from '/@/shared/hooks/use-focus-within';
|
|
import { useHotkeys } from '/@/shared/hooks/use-hotkeys';
|
|
import { useMergedRef } from '/@/shared/hooks/use-merged-ref';
|
|
import { Folder, LibraryItem, QueueSong, Song } from '/@/shared/types/domain-types';
|
|
import { DragTarget } from '/@/shared/types/drag-and-drop';
|
|
import { ItemListKey, Play } from '/@/shared/types/types';
|
|
|
|
type QueueProps = {
|
|
enableScrollShadow?: boolean;
|
|
listKey: ItemListKey;
|
|
searchTerm: string | undefined;
|
|
};
|
|
|
|
export const PlayQueue = forwardRef<ItemListHandle, QueueProps>(
|
|
({ enableScrollShadow = true, listKey, searchTerm }, ref) => {
|
|
const { table } = useListSettings(listKey) || {};
|
|
|
|
const isFetching = useIsPlayerFetching();
|
|
const tableRef = useRef<ItemListHandle>(null);
|
|
const mergedRef = useMergedRef(ref, tableRef);
|
|
const { getQueue } = usePlayerActions();
|
|
const followCurrentSong = useFollowCurrentSong();
|
|
|
|
const [debouncedSearchTerm] = useDebouncedValue(searchTerm, 200);
|
|
|
|
const [data, setData] = useState<QueueSong[]>([]);
|
|
const [groups, setGroups] = useState<TableGroupHeader[]>([]);
|
|
|
|
useEffect(() => {
|
|
const setQueue = () => {
|
|
const queue = getQueue() || { groups: [], items: [] };
|
|
|
|
setData(queue.items);
|
|
|
|
setGroups([]);
|
|
};
|
|
|
|
const unsub = subscribePlayerQueue(() => {
|
|
setQueue();
|
|
});
|
|
|
|
const unsubCurrentTrack = subscribeCurrentTrack((e) => {
|
|
if (followCurrentSong && e.index !== -1) {
|
|
tableRef.current?.scrollToIndex(e.index, {
|
|
align: 'center',
|
|
behavior: 'auto',
|
|
});
|
|
}
|
|
});
|
|
|
|
const handleAutoDJQueueAdded = () => {
|
|
if (followCurrentSong) {
|
|
const state = usePlayerStore.getState();
|
|
let index = state.player.index;
|
|
|
|
if (isShuffleEnabled(state)) {
|
|
index = mapShuffledToQueueIndex(index, state.queue.shuffled);
|
|
}
|
|
|
|
if (index !== -1) {
|
|
// Use setTimeout to ensure the DOM has updated with the new queue items
|
|
setTimeout(() => {
|
|
tableRef.current?.scrollToIndex(index, {
|
|
align: 'center',
|
|
behavior: 'auto',
|
|
});
|
|
}, 0);
|
|
}
|
|
}
|
|
};
|
|
|
|
eventEmitter.on('AUTODJ_QUEUE_ADDED', handleAutoDJQueueAdded);
|
|
|
|
setQueue();
|
|
|
|
if (followCurrentSong) {
|
|
const state = usePlayerStore.getState();
|
|
let index = state.player.index;
|
|
|
|
if (isShuffleEnabled(state)) {
|
|
index = mapShuffledToQueueIndex(index, state.queue.shuffled);
|
|
}
|
|
|
|
if (index !== -1) {
|
|
setTimeout(() => {
|
|
tableRef.current?.scrollToIndex(index, {
|
|
align: 'center',
|
|
behavior: 'auto',
|
|
});
|
|
}, 0);
|
|
}
|
|
}
|
|
|
|
return () => {
|
|
unsub();
|
|
unsubCurrentTrack();
|
|
eventEmitter.off('AUTODJ_QUEUE_ADDED', handleAutoDJQueueAdded);
|
|
};
|
|
}, [getQueue, tableRef, followCurrentSong]);
|
|
|
|
const filteredData: QueueSong[] = useMemo(() => {
|
|
if (debouncedSearchTerm) {
|
|
const searched = searchLibraryItems(data, debouncedSearchTerm, LibraryItem.SONG);
|
|
return searched;
|
|
}
|
|
|
|
return data;
|
|
}, [data, debouncedSearchTerm]);
|
|
|
|
const isEmpty = filteredData.length === 0;
|
|
|
|
const { handleColumnReordered } = useItemListColumnReorder({
|
|
itemListKey: listKey,
|
|
});
|
|
|
|
const { handleColumnResized } = useItemListColumnResize({
|
|
itemListKey: listKey,
|
|
});
|
|
|
|
const currentSong = usePlayerSong();
|
|
|
|
const currentSongUniqueId = currentSong?._uniqueId;
|
|
|
|
const { focused, ref: containerFocusRef } = useFocusWithin();
|
|
const player = usePlayer();
|
|
|
|
useHotkeys([
|
|
[
|
|
'delete',
|
|
() => {
|
|
if (focused) {
|
|
const selectedItems =
|
|
tableRef.current?.internalState.getSelected() as QueueSong[];
|
|
|
|
if (!selectedItems || selectedItems.length === 0) {
|
|
return;
|
|
}
|
|
|
|
player.clearSelected(selectedItems);
|
|
}
|
|
},
|
|
],
|
|
]);
|
|
|
|
return (
|
|
<div className={styles.container} ref={containerFocusRef}>
|
|
<LoadingOverlay pos="absolute" visible={isFetching} />
|
|
<ItemTableList
|
|
activeRowId={currentSongUniqueId}
|
|
autoFitColumns={table.autoFitColumns}
|
|
CellComponent={ItemTableListColumn}
|
|
columns={table.columns}
|
|
data={filteredData}
|
|
enableAlternateRowColors={table.enableAlternateRowColors}
|
|
enableDrag
|
|
enableExpansion={false}
|
|
enableHeader={table.enableHeader}
|
|
enableHorizontalBorders={table.enableHorizontalBorders}
|
|
enableRowHoverHighlight={table.enableRowHoverHighlight}
|
|
enableScrollShadow={enableScrollShadow}
|
|
enableSelection
|
|
enableSelectionDialog={false}
|
|
enableVerticalBorders={table.enableVerticalBorders}
|
|
getRowId="_uniqueId"
|
|
groups={groups.length > 0 ? groups : undefined}
|
|
initialTop={{
|
|
to: 0,
|
|
type: 'offset',
|
|
}}
|
|
itemType={LibraryItem.QUEUE_SONG}
|
|
onColumnReordered={handleColumnReordered}
|
|
onColumnResized={handleColumnResized}
|
|
ref={mergedRef}
|
|
size={table.size}
|
|
/>
|
|
{isEmpty && <EmptyQueueDropZone />}
|
|
</div>
|
|
);
|
|
},
|
|
);
|
|
|
|
const EmptyQueueDropZone = () => {
|
|
const playerContext = usePlayer();
|
|
|
|
const { isDraggedOver, ref } = useDragDrop<HTMLDivElement>({
|
|
drop: {
|
|
canDrop: () => {
|
|
return true;
|
|
},
|
|
getData: () => {
|
|
return {
|
|
id: [],
|
|
item: [],
|
|
itemType: LibraryItem.QUEUE_SONG,
|
|
type: DragTarget.QUEUE_SONG,
|
|
};
|
|
},
|
|
onDrag: () => {
|
|
return;
|
|
},
|
|
onDragLeave: () => {
|
|
return;
|
|
},
|
|
onDrop: (args) => {
|
|
if (args.self.type === DragTarget.QUEUE_SONG) {
|
|
const sourceServerId = (
|
|
args.source.item?.[0] as unknown as { _serverId: string }
|
|
)?._serverId;
|
|
const sourceItemType = args.source.itemType as LibraryItem;
|
|
|
|
switch (args.source.type) {
|
|
case DragTarget.ALBUM: {
|
|
if (sourceServerId) {
|
|
playerContext.addToQueueByFetch(
|
|
sourceServerId,
|
|
args.source.id,
|
|
sourceItemType,
|
|
Play.NOW,
|
|
);
|
|
}
|
|
break;
|
|
}
|
|
case DragTarget.ALBUM_ARTIST: {
|
|
if (sourceServerId) {
|
|
playerContext.addToQueueByFetch(
|
|
sourceServerId,
|
|
args.source.id,
|
|
sourceItemType,
|
|
Play.NOW,
|
|
);
|
|
}
|
|
break;
|
|
}
|
|
case DragTarget.ARTIST: {
|
|
if (sourceServerId) {
|
|
playerContext.addToQueueByFetch(
|
|
sourceServerId,
|
|
args.source.id,
|
|
sourceItemType,
|
|
Play.NOW,
|
|
);
|
|
}
|
|
break;
|
|
}
|
|
case DragTarget.FOLDER: {
|
|
const items = args.source.item;
|
|
|
|
const { folders, songs } = (items || []).reduce<{
|
|
folders: Folder[];
|
|
songs: Song[];
|
|
}>(
|
|
(acc, item) => {
|
|
if ((item as unknown as Song)._itemType === LibraryItem.SONG) {
|
|
acc.songs.push(item as unknown as Song);
|
|
} else if (
|
|
(item as unknown as Folder)._itemType === LibraryItem.FOLDER
|
|
) {
|
|
acc.folders.push(item as unknown as Folder);
|
|
}
|
|
return acc;
|
|
},
|
|
{ folders: [], songs: [] },
|
|
);
|
|
|
|
const folderIds = folders.map((folder) => folder.id);
|
|
|
|
// Handle folders: fetch and add to queue
|
|
if (folderIds.length > 0 && sourceServerId) {
|
|
playerContext.addToQueueByFetch(
|
|
sourceServerId,
|
|
folderIds,
|
|
LibraryItem.FOLDER,
|
|
Play.NOW,
|
|
);
|
|
}
|
|
|
|
// Handle songs: add directly to queue
|
|
if (songs.length > 0) {
|
|
playerContext.addToQueueByData(songs, Play.NOW);
|
|
}
|
|
|
|
break;
|
|
}
|
|
case DragTarget.GENRE: {
|
|
if (sourceServerId) {
|
|
playerContext.addToQueueByFetch(
|
|
sourceServerId,
|
|
args.source.id,
|
|
sourceItemType,
|
|
Play.NOW,
|
|
);
|
|
}
|
|
break;
|
|
}
|
|
case DragTarget.PLAYLIST: {
|
|
if (sourceServerId) {
|
|
playerContext.addToQueueByFetch(
|
|
sourceServerId,
|
|
args.source.id,
|
|
sourceItemType,
|
|
Play.NOW,
|
|
);
|
|
}
|
|
break;
|
|
}
|
|
case DragTarget.QUEUE_SONG: {
|
|
const sourceItems = (args.source.item || []) as QueueSong[];
|
|
if (sourceItems.length > 0) {
|
|
playerContext.addToQueueByData(sourceItems, Play.NOW);
|
|
}
|
|
break;
|
|
}
|
|
case DragTarget.SONG: {
|
|
const sourceItems = (args.source.item || []) as Song[];
|
|
if (sourceItems.length > 0) {
|
|
playerContext.addToQueueByData(sourceItems, Play.NOW);
|
|
}
|
|
break;
|
|
}
|
|
default: {
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
return;
|
|
},
|
|
},
|
|
isEnabled: true,
|
|
});
|
|
|
|
return (
|
|
<Flex
|
|
align="center"
|
|
className={clsx(styles.dropZone, {
|
|
[styles.draggedOver]: isDraggedOver,
|
|
})}
|
|
direction="column"
|
|
gap="md"
|
|
justify="center"
|
|
ref={ref}
|
|
w="100%"
|
|
/>
|
|
);
|
|
};
|