feat(queue): make queue tracks draggable via dnd-kit
Docker Build & Publish / build (push) Has been cancelled
Docker Build & Publish / push (push) Has been cancelled
Docker Build & Publish / Prune old image versions (push) Has been cancelled

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:
Senko-san
2026-06-13 17:40:58 +03:00
parent 9c70b8a11f
commit b37fabd936
4 changed files with 145 additions and 17 deletions
+56
View File
@@ -8,6 +8,9 @@
"name": "mcma-webui", "name": "mcma-webui",
"version": "1.0.0", "version": "1.0.0",
"dependencies": { "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", "@olly/modern-sk": "^0.1.5",
"@phosphor-icons/react": "^2.1.10", "@phosphor-icons/react": "^2.1.10",
"@reduxjs/toolkit": "^2.12.0", "@reduxjs/toolkit": "^2.12.0",
@@ -552,6 +555,59 @@
"node": ">=6.9.0" "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": { "node_modules/@emnapi/core": {
"version": "1.10.0", "version": "1.10.0",
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz",
+3
View File
@@ -13,6 +13,9 @@
"test:watch": "rstest --watch" "test:watch": "rstest --watch"
}, },
"dependencies": { "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", "@olly/modern-sk": "^0.1.5",
"@phosphor-icons/react": "^2.1.10", "@phosphor-icons/react": "^2.1.10",
"@reduxjs/toolkit": "^2.12.0", "@reduxjs/toolkit": "^2.12.0",
+80 -17
View File
@@ -8,6 +8,22 @@ import {
IconButton, IconButton,
} from '@olly/modern-sk'; } from '@olly/modern-sk';
import { useTranslation } from 'react-i18next'; 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 { Icon } from '../common/Icon';
import { ArtTile } from '../common/ArtTile'; import { ArtTile } from '../common/ArtTile';
import { PlayingIndicator } from '../common/PlayingIndicator'; import { PlayingIndicator } from '../common/PlayingIndicator';
@@ -35,6 +51,19 @@ export function QueuePanel() {
const isRadio = queue.source === 'radio'; const isRadio = queue.source === 'radio';
const sourceLabel = queue.sourceName ?? queue.source; 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 ( return (
<aside className={`qd${isOpen ? '' : ' closed'}`} aria-hidden={!isOpen}> <aside className={`qd${isOpen ? '' : ' closed'}`} aria-hidden={!isOpen}>
<div className="qd-inner"> <div className="qd-inner">
@@ -114,21 +143,36 @@ export function QueuePanel() {
> >
{t('queue.nextUp')} {t('queue.nextUp')}
</span> </span>
{queue.entries.map((entry, index) => ( <DndContext
<QueueRow sensors={sensors}
key={`${entry.trackId}-${index}`} collisionDetection={closestCenter}
entry={entry} onDragEnd={handleDragEnd}
isCurrent={index === queue.currentIndex} >
isPlaying={isPlaying} <SortableContext
onPlay={() => dispatch(goToIndex(index))} items={queue.entries.map((_, index) => String(index))}
onMoveNext={() => strategy={verticalListSortingStrategy}
dispatch( >
moveInQueue({ from: index, to: queue.currentIndex + 1 }), {queue.entries.map((entry, index) => (
) <QueueRow
} key={`${entry.trackId}-${index}`}
onRemove={() => dispatch(removeFromQueue(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 && ( {isRadio && (
<div className="qd-loadmore">{t('queue.loadingMore')}</div> <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 * enrichment updates show. The currently-playing entry is outlined and shows
* a playing-bars indicator in place of the drag grip. */ * a playing-bars indicator in place of the drag grip. */
function QueueRow({ function QueueRow({
id,
entry, entry,
isCurrent, isCurrent,
isPlaying, isPlaying,
@@ -154,6 +199,7 @@ function QueueRow({
onMoveNext, onMoveNext,
onRemove, onRemove,
}: { }: {
id: string;
entry: QueueEntry; entry: QueueEntry;
isCurrent: boolean; isCurrent: boolean;
isPlaying: boolean; isPlaying: boolean;
@@ -172,16 +218,33 @@ function QueueRow({
? getTrackCoverUrl(resolved.trackId, token, true) ? getTrackCoverUrl(resolved.trackId, token, true)
: undefined); : undefined);
const {
attributes,
listeners,
setNodeRef,
transform,
transition,
isDragging,
} = useSortable({ id });
const style = {
transform: CSS.Transform.toString(transform),
transition,
opacity: isDragging ? 0.5 : 1,
};
return ( return (
<div <div
className={`qrow${isCurrent ? ' current' : ''}`} ref={setNodeRef}
style={style}
className={`qrow${isCurrent ? ' current' : ''}${isDragging ? ' dragging' : ''}`}
onDoubleClick={onPlay} onDoubleClick={onPlay}
title={t('queue.doubleClickPlay')} title={t('queue.doubleClickPlay')}
> >
{isCurrent ? ( {isCurrent ? (
<PlayingIndicator animate={isPlaying} /> <PlayingIndicator animate={isPlaying} />
) : ( ) : (
<span className="grip"> <span className="grip" {...attributes} {...listeners}>
<Icon name="dots-six-vertical" /> <Icon name="dots-six-vertical" />
</span> </span>
)} )}
+6
View File
@@ -668,6 +668,12 @@
); );
box-shadow: 0 0 0 1px rgba(190, 242, 100, 0.35) inset; 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 { .qrow.current .qt .t {
color: var(--lime); color: var(--lime);
font-weight: 600; font-weight: 600;