feat(queue): make queue tracks draggable via dnd-kit
Wire up @dnd-kit sortable context in QueuePanel so tracks can be reordered by dragging the grip handle, dispatching moveInQueue on drop.
This commit is contained in:
Generated
+56
@@ -8,6 +8,9 @@
|
||||
"name": "mcma-webui",
|
||||
"version": "1.0.0",
|
||||
"dependencies": {
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@olly/modern-sk": "^0.1.5",
|
||||
"@phosphor-icons/react": "^2.1.10",
|
||||
"@reduxjs/toolkit": "^2.12.0",
|
||||
@@ -552,6 +555,59 @@
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@dnd-kit/accessibility": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@dnd-kit/accessibility/-/accessibility-3.1.1.tgz",
|
||||
"integrity": "sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tslib": "^2.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=16.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@dnd-kit/core": {
|
||||
"version": "6.3.1",
|
||||
"resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz",
|
||||
"integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@dnd-kit/accessibility": "^3.1.1",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"tslib": "^2.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=16.8.0",
|
||||
"react-dom": ">=16.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@dnd-kit/sortable": {
|
||||
"version": "10.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@dnd-kit/sortable/-/sortable-10.0.0.tgz",
|
||||
"integrity": "sha512-+xqhmIIzvAYMGfBYYnbKuNicfSsk4RksY2XdmJhT+HAC01nix6fHCztU68jooFiMUB01Ky3F0FyOvhG/BZrWkg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"tslib": "^2.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@dnd-kit/core": "^6.3.0",
|
||||
"react": ">=16.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@dnd-kit/utilities": {
|
||||
"version": "3.2.2",
|
||||
"resolved": "https://registry.npmjs.org/@dnd-kit/utilities/-/utilities-3.2.2.tgz",
|
||||
"integrity": "sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tslib": "^2.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=16.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@emnapi/core": {
|
||||
"version": "1.10.0",
|
||||
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz",
|
||||
|
||||
@@ -13,6 +13,9 @@
|
||||
"test:watch": "rstest --watch"
|
||||
},
|
||||
"dependencies": {
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@olly/modern-sk": "^0.1.5",
|
||||
"@phosphor-icons/react": "^2.1.10",
|
||||
"@reduxjs/toolkit": "^2.12.0",
|
||||
|
||||
@@ -8,6 +8,22 @@ import {
|
||||
IconButton,
|
||||
} from '@olly/modern-sk';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
DndContext,
|
||||
closestCenter,
|
||||
PointerSensor,
|
||||
KeyboardSensor,
|
||||
useSensor,
|
||||
useSensors,
|
||||
type DragEndEvent,
|
||||
} from '@dnd-kit/core';
|
||||
import {
|
||||
SortableContext,
|
||||
useSortable,
|
||||
verticalListSortingStrategy,
|
||||
sortableKeyboardCoordinates,
|
||||
} from '@dnd-kit/sortable';
|
||||
import { CSS } from '@dnd-kit/utilities';
|
||||
import { Icon } from '../common/Icon';
|
||||
import { ArtTile } from '../common/ArtTile';
|
||||
import { PlayingIndicator } from '../common/PlayingIndicator';
|
||||
@@ -35,6 +51,19 @@ export function QueuePanel() {
|
||||
const isRadio = queue.source === 'radio';
|
||||
const sourceLabel = queue.sourceName ?? queue.source;
|
||||
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor, { activationConstraint: { distance: 4 } }),
|
||||
useSensor(KeyboardSensor, {
|
||||
coordinateGetter: sortableKeyboardCoordinates,
|
||||
}),
|
||||
);
|
||||
|
||||
const handleDragEnd = (event: DragEndEvent) => {
|
||||
const { active, over } = event;
|
||||
if (!over || active.id === over.id) return;
|
||||
dispatch(moveInQueue({ from: Number(active.id), to: Number(over.id) }));
|
||||
};
|
||||
|
||||
return (
|
||||
<aside className={`qd${isOpen ? '' : ' closed'}`} aria-hidden={!isOpen}>
|
||||
<div className="qd-inner">
|
||||
@@ -114,21 +143,36 @@ export function QueuePanel() {
|
||||
>
|
||||
{t('queue.nextUp')}
|
||||
</span>
|
||||
{queue.entries.map((entry, index) => (
|
||||
<QueueRow
|
||||
key={`${entry.trackId}-${index}`}
|
||||
entry={entry}
|
||||
isCurrent={index === queue.currentIndex}
|
||||
isPlaying={isPlaying}
|
||||
onPlay={() => dispatch(goToIndex(index))}
|
||||
onMoveNext={() =>
|
||||
dispatch(
|
||||
moveInQueue({ from: index, to: queue.currentIndex + 1 }),
|
||||
)
|
||||
}
|
||||
onRemove={() => dispatch(removeFromQueue(index))}
|
||||
/>
|
||||
))}
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
collisionDetection={closestCenter}
|
||||
onDragEnd={handleDragEnd}
|
||||
>
|
||||
<SortableContext
|
||||
items={queue.entries.map((_, index) => String(index))}
|
||||
strategy={verticalListSortingStrategy}
|
||||
>
|
||||
{queue.entries.map((entry, index) => (
|
||||
<QueueRow
|
||||
key={`${entry.trackId}-${index}`}
|
||||
id={String(index)}
|
||||
entry={entry}
|
||||
isCurrent={index === queue.currentIndex}
|
||||
isPlaying={isPlaying}
|
||||
onPlay={() => dispatch(goToIndex(index))}
|
||||
onMoveNext={() =>
|
||||
dispatch(
|
||||
moveInQueue({
|
||||
from: index,
|
||||
to: queue.currentIndex + 1,
|
||||
}),
|
||||
)
|
||||
}
|
||||
onRemove={() => dispatch(removeFromQueue(index))}
|
||||
/>
|
||||
))}
|
||||
</SortableContext>
|
||||
</DndContext>
|
||||
|
||||
{isRadio && (
|
||||
<div className="qd-loadmore">{t('queue.loadingMore')}</div>
|
||||
@@ -147,6 +191,7 @@ export function QueuePanel() {
|
||||
* enrichment updates show. The currently-playing entry is outlined and shows
|
||||
* a playing-bars indicator in place of the drag grip. */
|
||||
function QueueRow({
|
||||
id,
|
||||
entry,
|
||||
isCurrent,
|
||||
isPlaying,
|
||||
@@ -154,6 +199,7 @@ function QueueRow({
|
||||
onMoveNext,
|
||||
onRemove,
|
||||
}: {
|
||||
id: string;
|
||||
entry: QueueEntry;
|
||||
isCurrent: boolean;
|
||||
isPlaying: boolean;
|
||||
@@ -172,16 +218,33 @@ function QueueRow({
|
||||
? getTrackCoverUrl(resolved.trackId, token, true)
|
||||
: undefined);
|
||||
|
||||
const {
|
||||
attributes,
|
||||
listeners,
|
||||
setNodeRef,
|
||||
transform,
|
||||
transition,
|
||||
isDragging,
|
||||
} = useSortable({ id });
|
||||
|
||||
const style = {
|
||||
transform: CSS.Transform.toString(transform),
|
||||
transition,
|
||||
opacity: isDragging ? 0.5 : 1,
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`qrow${isCurrent ? ' current' : ''}`}
|
||||
ref={setNodeRef}
|
||||
style={style}
|
||||
className={`qrow${isCurrent ? ' current' : ''}${isDragging ? ' dragging' : ''}`}
|
||||
onDoubleClick={onPlay}
|
||||
title={t('queue.doubleClickPlay')}
|
||||
>
|
||||
{isCurrent ? (
|
||||
<PlayingIndicator animate={isPlaying} />
|
||||
) : (
|
||||
<span className="grip">
|
||||
<span className="grip" {...attributes} {...listeners}>
|
||||
<Icon name="dots-six-vertical" />
|
||||
</span>
|
||||
)}
|
||||
|
||||
@@ -668,6 +668,12 @@
|
||||
);
|
||||
box-shadow: 0 0 0 1px rgba(190, 242, 100, 0.35) inset;
|
||||
}
|
||||
.qrow.dragging {
|
||||
z-index: 1;
|
||||
cursor: grabbing;
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
.qrow.current .qt .t {
|
||||
color: var(--lime);
|
||||
font-weight: 600;
|
||||
|
||||
Reference in New Issue
Block a user