From 63294c59379dca0ecafec197a56fa4816b1faca8 Mon Sep 17 00:00:00 2001 From: Ishan Audichya Date: Fri, 22 Aug 2025 11:19:42 +0530 Subject: [PATCH] Implement drag-and-drop in Queue --- apps/client/package.json | 3 + apps/client/src/app/globals.css | 37 + apps/client/src/components/Queue.tsx | 630 +++++++++++------- apps/server/src/managers/RoomManager.ts | 32 + .../handlers/handleReorderAudioSources.ts | 25 + apps/server/src/websocket/registry.ts | 6 + bun.lock | 11 + packages/shared/types/WSRequest.ts | 8 + 8 files changed, 511 insertions(+), 241 deletions(-) create mode 100644 apps/server/src/websocket/handlers/handleReorderAudioSources.ts diff --git a/apps/client/package.json b/apps/client/package.json index 94cbb1b5..35716bd8 100644 --- a/apps/client/package.json +++ b/apps/client/package.json @@ -9,6 +9,9 @@ "lint": "next lint" }, "dependencies": { + "@dnd-kit/core": "^6.3.1", + "@dnd-kit/sortable": "^10.0.0", + "@dnd-kit/utilities": "^3.2.2", "@radix-ui/react-accordion": "^1.2.4", "@radix-ui/react-avatar": "^1.1.3", "@radix-ui/react-dialog": "^1.1.14", diff --git a/apps/client/src/app/globals.css b/apps/client/src/app/globals.css index 9eb59a57..fd5a6b1e 100644 --- a/apps/client/src/app/globals.css +++ b/apps/client/src/app/globals.css @@ -200,6 +200,43 @@ height: 30%; } } + + /* Drag and drop animations */ + @keyframes drag-lift { + 0% { + transform: scale(1) rotate(0deg); + } + 100% { + transform: scale(1.02) rotate(2deg); + } + } + + @keyframes drag-drop { + 0% { + transform: scale(1.02) rotate(2deg); + } + 50% { + transform: scale(0.98) rotate(-1deg); + } + 100% { + transform: scale(1) rotate(0deg); + } + } + + @keyframes drag-placeholder { + 0% { + opacity: 0.3; + transform: scale(0.95); + } + 50% { + opacity: 0.6; + transform: scale(1.02); + } + 100% { + opacity: 0.3; + transform: scale(0.95); + } + } } @theme inline { diff --git a/apps/client/src/components/Queue.tsx b/apps/client/src/components/Queue.tsx index 69c19b45..0fd363a9 100644 --- a/apps/client/src/components/Queue.tsx +++ b/apps/client/src/components/Queue.tsx @@ -9,10 +9,283 @@ import { // MoreHorizontal, // Keeping for potential future use Pause, Play, + GripVertical, } from "lucide-react"; import { AnimatePresence, motion } from "motion/react"; import { usePostHog } from "posthog-js/react"; import LoadDefaultTracksButton from "./LoadDefaultTracksButton"; +import { + DndContext, + closestCenter, + KeyboardSensor, + PointerSensor, + useSensor, + useSensors, + DragEndEvent, + DragStartEvent, + DragOverlay, +} from "@dnd-kit/core"; +import { + arrayMove, + SortableContext, + sortableKeyboardCoordinates, + verticalListSortingStrategy, +} from "@dnd-kit/sortable"; +import { + useSortable, +} from "@dnd-kit/sortable"; +import { CSS } from "@dnd-kit/utilities"; +import { useState } from "react"; + +// Sortable Queue Item Component +const SortableQueueItem = ({ + sourceState, + index, + isSelected, + isPlaying, + isLoading, + isError, + canMutate, + onItemClick, + onDelete +}: { + sourceState: AudioSourceState; + index: number; + isSelected: boolean; + isPlaying: boolean; + isLoading: boolean; + isError: boolean; + canMutate: boolean; + onItemClick: (sourceState: AudioSourceState) => void; + onDelete: (url: string) => void; +}) => { + const { + attributes, + listeners, + setNodeRef, + transform, + transition, + isDragging, + } = useSortable({ id: sourceState.source.url }); + + const style = { + transform: CSS.Transform.toString(transform), + transition, + }; + + + + return ( + onItemClick(sourceState)} + > + {/* Drag Handle */} + {canMutate && ( +
e.stopPropagation()} + > + +
+ )} + + {/* Track number / Play icon */} +
+ + {isLoading ? ( + + + + ) : isError ? ( + + + + ) : ( + + {/* Play/Pause button (shown on hover) */} + + + {/* Playing indicator or track number (hidden on hover) */} +
+ {isSelected && isPlaying ? ( +
+
+
+
+
+ ) : ( + + {index + 1} + + )} +
+
+ )} +
+
+ + {/* Track name */} +
+
+ {extractFileNameFromUrl(sourceState.source.url)} + {isError && sourceState.error && ( + + ({sourceState.error}) + + )} +
+
+ + {/* Duration & Delete Button */} +
+
+ {!isLoading && + formatTime( + useGlobalStore.getState().getAudioDuration({ url: sourceState.source.url }) + )} +
+ + {/* Direct delete button */} + {canMutate && ( + + )} +
+
+ ); +}; + +// Drag Overlay Component +const DragOverlayItem = ({ sourceState, index }: { sourceState: AudioSourceState; index: number }) => { + return ( + +
+ +
+
+ {index + 1} +
+
+
+ {extractFileNameFromUrl(sourceState.source.url)} +
+
+
+ ); +}; export const Queue = ({ className, ...rest }: React.ComponentProps<"div">) => { const posthog = usePostHog(); @@ -25,9 +298,21 @@ export const Queue = ({ className, ...rest }: React.ComponentProps<"div">) => { const broadcastPlay = useGlobalStore((state) => state.broadcastPlay); const broadcastPause = useGlobalStore((state) => state.broadcastPause); const isPlaying = useGlobalStore((state) => state.isPlaying); - const getAudioDuration = useGlobalStore((state) => state.getAudioDuration); const canMutate = useCanMutate(); - // socket handled by child button component when needed + + // Drag and drop state + const [activeId, setActiveId] = useState(null); + + const sensors = useSensors( + useSensor(PointerSensor, { + activationConstraint: { + distance: 8, + }, + }), + useSensor(KeyboardSensor, { + coordinateGetter: sortableKeyboardCoordinates, + }) + ); const handleItemClick = (sourceState: AudioSourceState) => { if (!canMutate) return; @@ -57,253 +342,116 @@ export const Queue = ({ className, ...rest }: React.ComponentProps<"div">) => { } }; - return ( -
- {/*

Beatsync

*/} -
- {audioSources.length > 0 ? ( - - {/* Ensure keys are stable and unique even if duplicates attempted */} - {audioSources.map((sourceState, index) => { - const isSelected = sourceState.source.url === selectedAudioId; - const isPlayingThis = isSelected && isPlaying; - const isLoading = sourceState.status === "loading"; - const isError = sourceState.status === "error"; + const handleDelete = (url: string) => { + const socket = useGlobalStore.getState().socket; + if (!socket) return; + sendWSRequest({ + ws: socket, + request: { + type: ClientActionEnum.enum.DELETE_AUDIO_SOURCES, + urls: [url], + }, + }); + }; - return ( - handleItemClick(sourceState)} - > - {/* Track number / Play icon */} -
- - {isLoading ? ( - - - - ) : isError ? ( - - - - ) : ( - - {/* Play/Pause button (shown on hover) */} - + const handleDragStart = (event: DragStartEvent) => { + setActiveId(event.active.id as string); + }; - {/* Playing indicator or track number (hidden on hover) */} -
- {isPlayingThis ? ( -
-
-
-
-
- ) : ( - - {index + 1} - - )} -
-
- )} -
-
+ const handleDragEnd = (event: DragEndEvent) => { + const { active, over } = event; + setActiveId(null); - {/* Track name */} -
-
- {extractFileNameFromUrl(sourceState.source.url)} - {isError && sourceState.error && ( - - ({sourceState.error}) - - )} -
-
+ if (active.id !== over?.id) { + const oldIndex = audioSources.findIndex(source => source.source.url === active.id); + const newIndex = audioSources.findIndex(source => source.source.url === over?.id); - {/* Duration & Delete Button */} -
-
- {!isLoading && - formatTime( - getAudioDuration({ url: sourceState.source.url }) - )} -
+ if (oldIndex !== -1 && newIndex !== -1) { + const newOrder = arrayMove(audioSources, oldIndex, newIndex); + const newUrls = newOrder.map(source => source.source.url); - {/* Direct delete button */} - {canMutate && ( - - )} + // Send reorder request to server + const socket = useGlobalStore.getState().socket; + if (socket) { + sendWSRequest({ + ws: socket, + request: { + type: ClientActionEnum.enum.REORDER_AUDIO_SOURCES, + urls: newUrls, + }, + }); + } + } + } + }; - {/* Dropdown for re-uploading - Commented out for potential future use */} - {/* - e.stopPropagation()} - > - - - e.stopPropagation()} - > - {canMutate && ( - { - const socket = useGlobalStore.getState().socket; - if (!socket) return; - sendWSRequest({ - ws: socket, - request: { - type: ClientActionEnum.enum - .DELETE_AUDIO_SOURCES, - urls: [sourceState.source.url], - }, - }); - }} - > - Remove from queue - - )} - - */} -
-
- ); - })} -
- ) : ( - - {isInitingSystem ? ( - "Loading tracks..." - ) : canMutate ? ( - <> -
No tracks yet
- - + const activeItem = activeId ? audioSources.find(source => source.source.url === activeId) : null; + const activeIndex = activeId ? audioSources.findIndex(source => source.source.url === activeId) : -1; + + return ( +
+ + source.source.url)} + strategy={verticalListSortingStrategy} + > +
+ {audioSources.length > 0 ? ( + + {audioSources.map((sourceState, index) => { + const isSelected = sourceState.source.url === selectedAudioId; + const isLoading = sourceState.status === "loading"; + const isError = sourceState.status === "error"; + + return ( + + ); + })} + ) : ( - "No tracks available" + + {isInitingSystem ? ( + "Loading tracks..." + ) : canMutate ? ( + <> +
No tracks yet
+ + + ) : ( + "No tracks available" + )} +
)} - - )} -
+
+ + + + {activeItem && activeIndex !== -1 ? ( + + ) : null} + +
); }; diff --git a/apps/server/src/managers/RoomManager.ts b/apps/server/src/managers/RoomManager.ts index d6a9d9df..01f5def1 100644 --- a/apps/server/src/managers/RoomManager.ts +++ b/apps/server/src/managers/RoomManager.ts @@ -282,6 +282,38 @@ export class RoomManager { }; } + reorderAudioSources(urls: string[]): AudioSourceType[] { + // Create a map of URL to audio source for quick lookup + const sourceMap = new Map( + this.audioSources.map((source) => [source.url, source]) + ); + + // Create new array with sources in the specified order + const reorderedSources: AudioSourceType[] = []; + const processedUrls = new Set(); + + // Add sources in the specified order + for (const url of urls) { + const source = sourceMap.get(url); + if (source) { + reorderedSources.push(source); + processedUrls.add(url); + } + } + + // Add any remaining sources that weren't in the urls array + for (const source of this.audioSources) { + if (!processedUrls.has(source.url)) { + reorderedSources.push(source); + } + } + + this.audioSources = reorderedSources; + console.log(`Reordered ${reorderedSources.length} audio sources in room ${this.roomId}`); + + return this.audioSources; + } + /** * Get all clients in the room */ diff --git a/apps/server/src/websocket/handlers/handleReorderAudioSources.ts b/apps/server/src/websocket/handlers/handleReorderAudioSources.ts new file mode 100644 index 00000000..19cb390b --- /dev/null +++ b/apps/server/src/websocket/handlers/handleReorderAudioSources.ts @@ -0,0 +1,25 @@ +import { ExtractWSRequestFrom } from "@beatsync/shared"; +import { sendBroadcast } from "../../utils/responses"; +import { requireCanMutate } from "../middlewares"; +import { HandlerFunction } from "../types"; + +export const handleReorderAudioSources: HandlerFunction< + ExtractWSRequestFrom["REORDER_AUDIO_SOURCES"] +> = async ({ ws, message, server }) => { + const { room } = requireCanMutate(ws); + + // Handle audio source reordering + console.log(`Reordering audio sources in room ${ws.data.roomId}`); + + const reorderedSources = room.reorderAudioSources(message.urls); + + // Broadcast the updated audio sources to all clients + sendBroadcast({ + server, + roomId: ws.data.roomId, + message: { + type: "ROOM_EVENT", + event: { type: "SET_AUDIO_SOURCES", sources: reorderedSources }, + }, + }); +}; diff --git a/apps/server/src/websocket/registry.ts b/apps/server/src/websocket/registry.ts index c74ae384..979ab6c4 100644 --- a/apps/server/src/websocket/registry.ts +++ b/apps/server/src/websocket/registry.ts @@ -1,6 +1,7 @@ import { ClientActionEnum } from "@beatsync/shared"; import { handleDeleteAudioSources } from "./handlers/handleDeleteAudioSources"; import { handleLoadDefaultTracks } from "./handlers/handleLoadDefaultTracks"; +import { handleReorderAudioSources } from "./handlers/handleReorderAudioSources"; import { handleSearchMusic } from "./handlers/handleSearchMusic"; import { handleSendChatMessage } from "./handlers/handleSendChatMessage"; import { handleSendIp } from "./handlers/handleSendIp"; @@ -74,6 +75,11 @@ export const WS_REGISTRY: WebsocketRegistry = { description: "Delete audio sources with room prefix (non-default only)", }, + [ClientActionEnum.enum.REORDER_AUDIO_SOURCES]: { + handle: handleReorderAudioSources, + description: "Reorder audio sources in the room queue", + }, + [ClientActionEnum.enum.SET_ADMIN]: { handle: handleSetAdmin, description: "Set admin status for a client", diff --git a/bun.lock b/bun.lock index 10fc4e2f..fb767f2c 100644 --- a/bun.lock +++ b/bun.lock @@ -18,6 +18,9 @@ "name": "client", "version": "0.1.0", "dependencies": { + "@dnd-kit/core": "^6.3.1", + "@dnd-kit/sortable": "^10.0.0", + "@dnd-kit/utilities": "^3.2.2", "@radix-ui/react-accordion": "^1.2.4", "@radix-ui/react-avatar": "^1.1.3", "@radix-ui/react-dialog": "^1.1.14", @@ -191,6 +194,14 @@ "@beatsync/shared": ["@beatsync/shared@workspace:packages/shared"], + "@dnd-kit/accessibility": ["@dnd-kit/accessibility@3.1.1", "", { "dependencies": { "tslib": "^2.0.0" }, "peerDependencies": { "react": ">=16.8.0" } }, "sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw=="], + + "@dnd-kit/core": ["@dnd-kit/core@6.3.1", "", { "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" } }, "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ=="], + + "@dnd-kit/sortable": ["@dnd-kit/sortable@10.0.0", "", { "dependencies": { "@dnd-kit/utilities": "^3.2.2", "tslib": "^2.0.0" }, "peerDependencies": { "@dnd-kit/core": "^6.3.0", "react": ">=16.8.0" } }, "sha512-+xqhmIIzvAYMGfBYYnbKuNicfSsk4RksY2XdmJhT+HAC01nix6fHCztU68jooFiMUB01Ky3F0FyOvhG/BZrWkg=="], + + "@dnd-kit/utilities": ["@dnd-kit/utilities@3.2.2", "", { "dependencies": { "tslib": "^2.0.0" }, "peerDependencies": { "react": ">=16.8.0" } }, "sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg=="], + "@emnapi/runtime": ["@emnapi/runtime@1.3.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-kEBmG8KyqtxJZv+ygbEim+KCGtIq1fC22Ms3S4ziXmYKm8uyoLX0MHONVKwp+9opg390VaKRNt4a7A9NwmpNhw=="], "@eslint-community/eslint-utils": ["@eslint-community/eslint-utils@4.4.1", "", { "dependencies": { "eslint-visitor-keys": "^3.4.3" }, "peerDependencies": { "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" } }, "sha512-s3O3waFUrMV8P/XaF/+ZTp1X9XBZW1a4B97ZnjQF2KYWaFD2A8KyFBsrsfSjEmjn3RGWAIuvlneuZm3CUK3jbA=="], diff --git a/packages/shared/types/WSRequest.ts b/packages/shared/types/WSRequest.ts index 0ca68d6a..45fc537e 100644 --- a/packages/shared/types/WSRequest.ts +++ b/packages/shared/types/WSRequest.ts @@ -27,6 +27,7 @@ export const ClientActionEnum = z.enum([ "SEND_IP", // Send IP to server "LOAD_DEFAULT_TRACKS", // Load default tracks into empty queue "DELETE_AUDIO_SOURCES", // Delete audio sources from the room queue (non-default only) + "REORDER_AUDIO_SOURCES", // Reorder audio sources in the room queue "SEARCH_MUSIC", // Search for music "STREAM_MUSIC", // Stream music "SET_GLOBAL_VOLUME", // Set global volume for all clients @@ -92,6 +93,11 @@ const DeleteAudioSourcesSchema = z.object({ urls: z.array(z.string()).min(1), }); +const ReorderAudioSourcesSchema = z.object({ + type: z.literal(ClientActionEnum.enum.REORDER_AUDIO_SOURCES), + urls: z.array(z.string()).min(1), // Array of URLs in the new order +}); + const SetAdminSchema = z.object({ type: z.literal(ClientActionEnum.enum.SET_ADMIN), clientId: z.string(), // The client to set admin status for @@ -153,6 +159,7 @@ export const WSRequestSchema = z.discriminatedUnion("type", [ SendLocationSchema, LoadDefaultTracksSchema, DeleteAudioSourcesSchema, + ReorderAudioSourcesSchema, SearchMusicSchema, StreamMusicSchema, SetGlobalVolumeSchema, @@ -163,6 +170,7 @@ export type PlayActionType = z.infer; export type PauseActionType = z.infer; export type ReorderClientType = z.infer; export type SetListeningSourceType = z.infer; +export type ReorderAudioSourcesType = z.infer; // Mapped type to access request types by their type field export type ExtractWSRequestFrom = {