diff --git a/backend/events/events.go b/backend/events/events.go index 4bd3d0f..0536006 100644 --- a/backend/events/events.go +++ b/backend/events/events.go @@ -47,6 +47,8 @@ const ( RequestPlayTracksNext = "RequestPlayTracksNext" RequestPlayQueueIndex = "RequestPlayQueueIndex" RequestRemoveTracksFromQueue = "RequestRemoveTracksFromQueue" + RequestInsertTracksAtIndex = "RequestInsertTracksAtIndex" + RequestMoveQueueTracks = "RequestMoveQueueTracks" ) // Config events. diff --git a/backend/queue/queue.go b/backend/queue/queue.go index d813be2..6dee597 100644 --- a/backend/queue/queue.go +++ b/backend/queue/queue.go @@ -272,6 +272,28 @@ func (q *Queue) registerEventHandlers() { q.handleRemoveTracksFromQueue(data...) }, ) + + runtime.EventsOn( + q.ctx, + events.RequestInsertTracksAtIndex, + func(data ...any) { + q.logger.Info( + "Received RequestInsertTracksAtIndex", + ) + q.handleInsertTracksAtIndex(data...) + }, + ) + + runtime.EventsOn( + q.ctx, + events.RequestMoveQueueTracks, + func(data ...any) { + q.logger.Info( + "Received RequestMoveQueueTracks", + ) + q.handleMoveQueueTracks(data...) + }, + ) } // handleSetQueue processes the RequestSetQueue event payload. @@ -435,6 +457,92 @@ func (q *Queue) handleAddTracksToQueue(data ...any) { q.AddTracks(filePaths) } +// handleInsertTracksAtIndex processes the RequestInsertTracksAtIndex event +// payload. Expects data[0] = []interface{} of file path strings, +// data[1] = float64 target index. +func (q *Queue) handleInsertTracksAtIndex(data ...any) { + if len(data) < 2 { + q.logger.Error( + "RequestInsertTracksAtIndex: missing data", + ) + + return + } + + filePathsRaw, ok := data[0].([]interface{}) + if !ok { + q.logger.Error( + "RequestInsertTracksAtIndex: invalid filePaths type", + "got", data[0], + ) + + return + } + + filePaths := make([]string, 0, len(filePathsRaw)) + + for _, fp := range filePathsRaw { + if s, ok := fp.(string); ok { + filePaths = append(filePaths, s) + } + } + + idx, ok := data[1].(float64) + if !ok { + q.logger.Error( + "RequestInsertTracksAtIndex: invalid index type", + "got", data[1], + ) + + return + } + + q.InsertTracksAt(filePaths, int(idx)) +} + +// handleMoveQueueTracks processes the RequestMoveQueueTracks event payload. +// Expects data[0] = []interface{} of float64 source indices, +// data[1] = float64 target index. +func (q *Queue) handleMoveQueueTracks(data ...any) { + if len(data) < 2 { + q.logger.Error( + "RequestMoveQueueTracks: missing data", + ) + + return + } + + indicesRaw, ok := data[0].([]interface{}) + if !ok { + q.logger.Error( + "RequestMoveQueueTracks: invalid indices type", + "got", data[0], + ) + + return + } + + fromIndices := make([]int, 0, len(indicesRaw)) + + for _, v := range indicesRaw { + if f, ok := v.(float64); ok { + fromIndices = append(fromIndices, int(f)) + } + } + + toIdx, ok := data[1].(float64) + if !ok { + q.logger.Error( + "RequestMoveQueueTracks: invalid toIndex type", + "got", data[1], + ) + + return + } + + q.MoveQueueTracks(fromIndices, int(toIdx)) +} + // handlePlayQueueIndex processes the RequestPlayQueueIndex event payload. // Expects data[0] = float64 index. func (q *Queue) handlePlayQueueIndex(data ...any) { @@ -644,7 +752,7 @@ func (q *Queue) resolveRemainingTracks( } // AddTrack appends a track to the end of the queue. -// If the queue was empty, it starts playing the added track immediately. +// If the queue was empty, it loads the added track in a paused state. func (q *Queue) AddTrack(filePath string) { meta := q.lookupTrackMetaBatch([]string{filePath}) @@ -690,10 +798,10 @@ func (q *Queue) AddTrack(filePath string) { q.shuffleOrder = append(q.shuffleOrder, len(q.tracks)-1) } - // Auto-play if this is the first track added to an empty queue. + // Load (paused) if this is the first track added to an empty queue. if wasEmpty { q.currentIndex = 0 - q.playCurrentTrack() + q.loadCurrentTrack() } q.persistState() @@ -706,7 +814,7 @@ func (q *Queue) AddTrack(filePath string) { } // AddTracks appends multiple tracks to the end of the queue. -// If the queue was empty, it starts playing the first added track immediately. +// If the queue was empty, it loads the first added track in a paused state. func (q *Queue) AddTracks(filePaths []string) { allMeta := q.lookupTrackMetaBatch(filePaths) @@ -751,7 +859,7 @@ func (q *Queue) AddTracks(filePaths []string) { if wasEmpty && len(q.tracks) > 0 { q.currentIndex = 0 - q.playCurrentTrack() + q.loadCurrentTrack() } q.emitTracksModified( @@ -763,6 +871,7 @@ func (q *Queue) AddTracks(filePaths []string) { } // InsertNextTracks inserts multiple tracks as a contiguous block after the current track. +// If the queue was empty, it loads the first inserted track in a paused state. func (q *Queue) InsertNextTracks(filePaths []string) { allMeta := q.lookupTrackMetaBatch(filePaths) @@ -818,7 +927,7 @@ func (q *Queue) InsertNextTracks(filePaths []string) { if wasEmpty { q.currentIndex = 0 - q.playCurrentTrack() + q.loadCurrentTrack() } q.emitTracksModified( @@ -882,6 +991,246 @@ func (q *Queue) InsertNext(filePath string) { ) } +// InsertTracksAt inserts multiple tracks at the given index. +// If the queue was empty, it loads the first inserted track in a paused state. +func (q *Queue) InsertTracksAt(filePaths []string, index int) { + allMeta := q.lookupTrackMetaBatch(filePaths) + + q.mu.Lock() + defer q.mu.Unlock() + + wasEmpty := len(q.tracks) == 0 + + // Clamp index to valid range. + if index < 0 { + index = 0 + } + + if index > len(q.tracks) { + index = len(q.tracks) + } + + var newTracks []Track + + for _, fp := range filePaths { + m, ok := allMeta[fp] + if !ok { + q.logger.Warn( + "Could not find audio file", + "path", fp, + ) + + continue + } + + newTracks = append(newTracks, Track{ + AudioFileID: m.AudioFileID, + FilePath: m.FilePath, + Title: m.Title, + Artist: m.Artist, + }) + } + + if len(newTracks) == 0 { + return + } + + // Insert the block into the slice at index. + tail := make([]Track, len(q.tracks[index:])) + copy(tail, q.tracks[index:]) + q.tracks = append(q.tracks[:index], newTracks...) + q.tracks = append(q.tracks, tail...) + + // Shift currentIndex if insertion is at or before it. + if q.currentIndex >= 0 && index <= q.currentIndex { + q.currentIndex += len(newTracks) + } + + q.reindexPositions() + + if q.shuffleMode { + q.generateShuffleOrder() + } + + q.persistTracks() + q.persistState() + + if wasEmpty { + q.currentIndex = 0 + q.loadCurrentTrack() + } + + q.emitTracksModified( + "insert", + newTracks, + index, + nil, + ) +} + +// MoveQueueTracks moves tracks at the given indices to a new position +// as a contiguous block. The toIndex is the target position in the +// original (pre-move) array. +func (q *Queue) MoveQueueTracks( + fromIndices []int, + toIndex int, +) { + q.mu.Lock() + defer q.mu.Unlock() + + if len(fromIndices) == 0 || len(q.tracks) == 0 { + return + } + + // De-duplicate and sort source indices. + seen := make(map[int]bool, len(fromIndices)) + + var sorted []int + + for _, idx := range fromIndices { + if idx >= 0 && idx < len(q.tracks) && !seen[idx] { + seen[idx] = true + + sorted = append(sorted, idx) + } + } + + if len(sorted) == 0 { + return + } + + sortInts(sorted) + + // Clamp toIndex. + if toIndex < 0 { + toIndex = 0 + } + + if toIndex > len(q.tracks) { + toIndex = len(q.tracks) + } + + // Check if this is a no-op: all source indices are contiguous + // and already start at the target position. + isContiguous := true + + for i := 1; i < len(sorted); i++ { + if sorted[i] != sorted[i-1]+1 { + isContiguous = false + + break + } + } + + lastSorted := sorted[len(sorted)-1] + + if isContiguous && + (sorted[0] == toIndex || lastSorted+1 == toIndex) { + return + } + + // Find where currentIndex ends up after the move. + currentTrackIdx := q.currentIndex + + // Extract the tracks to move. + moving := make([]Track, len(sorted)) + for i, idx := range sorted { + moving[i] = q.tracks[idx] + } + + // Build a new slice without the moved tracks. + remaining := make([]Track, 0, len(q.tracks)-len(sorted)) + removeSet := make(map[int]bool, len(sorted)) + + for _, idx := range sorted { + removeSet[idx] = true + } + + for i, t := range q.tracks { + if !removeSet[i] { + remaining = append(remaining, t) + } + } + + // Calculate adjusted insertion index in the remaining slice. + adjustedIdx := toIndex + + for _, idx := range sorted { + if idx < toIndex { + adjustedIdx-- + } + } + + if adjustedIdx < 0 { + adjustedIdx = 0 + } + + if adjustedIdx > len(remaining) { + adjustedIdx = len(remaining) + } + + // Insert the moved block at the adjusted position. + tail := make([]Track, len(remaining[adjustedIdx:])) + copy(tail, remaining[adjustedIdx:]) + remaining = append(remaining[:adjustedIdx], moving...) + remaining = append(remaining, tail...) + q.tracks = remaining + + // Track currentIndex through the move. + if currentTrackIdx >= 0 { + if removeSet[currentTrackIdx] { + // The current track was moved — find its new position. + for ri, orig := range sorted { + if orig == currentTrackIdx { + q.currentIndex = adjustedIdx + ri + + break + } + } + } else { + // The current track was not moved. Find its position + // in 'remaining', then account for the insertion. + posInRemaining := currentTrackIdx + + for _, idx := range sorted { + if idx < currentTrackIdx { + posInRemaining-- + } + } + + if adjustedIdx <= posInRemaining { + q.currentIndex = posInRemaining + len(sorted) + } else { + q.currentIndex = posInRemaining + } + } + } + + q.reindexPositions() + + if q.shuffleMode { + q.generateShuffleOrder() + } + + q.persistTracks() + q.persistState() + q.emitTracksModified( + "move", + moving, + toIndex, + sorted, + ) +} + +// sortInts sorts a slice of ints in ascending order. +func sortInts(s []int) { + for i := 1; i < len(s); i++ { + for j := i; j > 0 && s[j-1] > s[j]; j-- { + s[j], s[j-1] = s[j-1], s[j] + } + } +} + // RemoveTrack removes a track at the given position from the queue. func (q *Queue) RemoveTrack(position int) { q.mu.Lock() diff --git a/frontend/src/components/cover-grid/album-dropdown.ts b/frontend/src/components/cover-grid/album-dropdown.ts index 2443c37..b49093f 100644 --- a/frontend/src/components/cover-grid/album-dropdown.ts +++ b/frontend/src/components/cover-grid/album-dropdown.ts @@ -327,7 +327,7 @@ export class AlbumDropdown extends LitElement { return html`
this.onTrackClick(e, track, index)} @dblclick=${(e: MouseEvent) => diff --git a/frontend/src/components/cover-grid/cover-grid.ts b/frontend/src/components/cover-grid/cover-grid.ts index 3a8fdc3..86cf36d 100644 --- a/frontend/src/components/cover-grid/cover-grid.ts +++ b/frontend/src/components/cover-grid/cover-grid.ts @@ -2601,7 +2601,7 @@ export class CoverGrid extends LitElement { role="button" data-index=${index} aria-label="${album.Name} by ${album.ArtistName}" - draggable=${selected ? 'true' : 'false'} + draggable="true" @dragstart=${this.onAlbumDragStart} @dragend=${this.onAlbumDragEnd} > diff --git a/frontend/src/components/playlist-view/playlist-view.ts b/frontend/src/components/playlist-view/playlist-view.ts index adda1bc..3b7c605 100644 --- a/frontend/src/components/playlist-view/playlist-view.ts +++ b/frontend/src/components/playlist-view/playlist-view.ts @@ -26,6 +26,8 @@ import { getDragPayload, setDragPayload, emitDragActive, + getActiveDragSource, + getActiveDragPlaylistId, } from '@utils/drag-controller'; import { createDragImage, @@ -801,6 +803,19 @@ export class PlaylistView ) => { if (!hasTrackPayload(e)) return; + // Don't allow dropping tracks back onto + // the same playlist. + const entry = this.entries[index]; + + if ( + entry && + getActiveDragSource() === 'playlist' && + getActiveDragPlaylistId() === + entry.summary.ID + ) { + return; + } + e.preventDefault(); if (e.dataTransfer) { @@ -852,6 +867,16 @@ export class PlaylistView if (!entry) return; + // Don't allow dropping tracks back onto + // the same playlist. + if ( + payload.source === 'playlist' && + payload.sourcePlaylistId === + entry.summary.ID + ) { + return; + } + try { await AddTracksToPlaylist( entry.summary.ID, @@ -1280,9 +1305,7 @@ export class PlaylistView return html`
diff --git a/frontend/src/components/queue-panel/queue-panel.ts b/frontend/src/components/queue-panel/queue-panel.ts index a676031..7ec0b05 100644 --- a/frontend/src/components/queue-panel/queue-panel.ts +++ b/frontend/src/components/queue-panel/queue-panel.ts @@ -22,6 +22,7 @@ import { getDragPayload, setDragPayload, emitDragActive, + getActiveDragSource, } from '@utils/drag-controller'; import { createDragImage, @@ -55,9 +56,14 @@ export class QueuePanel @state() private playlistSubmenuOpen = false; - @state() private dragOver = false; + private dropTargetIndex = -1; + private dropTargetRafId = 0; + + private autoScrollRafId = 0; + private autoScrollDelta = 0; + private dragImageEl: HTMLElement | null = null; @query('#add-to-playlist-popup') @@ -114,6 +120,14 @@ export class QueuePanel */ private lastScrolledIndex = -1; + /** + * Tracks the last currentIndex for which the virtualizer was + * told to re-render, so the active-track highlight stays in sync. + */ + private lastRenderedIndex = -1; + + + // ================================================================= // SelectionHost interface // ================================================================= @@ -221,6 +235,7 @@ export class QueuePanel } .track-item { + position: relative; display: flex; align-items: center; padding: 8px 16px; @@ -322,10 +337,37 @@ export class QueuePanel rgba(255, 212, 59, 0.2); } - .panel-content.drag-over .drop-indicator { + .panel-content.drag-over.empty-drag + .drop-indicator { display: block; } + .track-item.drop-before::before { + content: ''; + position: absolute; + top: -1px; + left: 8px; + right: 8px; + height: 2px; + background: #ffd43b; + border-radius: 1px; + z-index: 5; + pointer-events: none; + } + + .track-item.drop-after::after { + content: ''; + position: absolute; + bottom: -1px; + left: 8px; + right: 8px; + height: 2px; + background: #ffd43b; + border-radius: 1px; + z-index: 5; + pointer-events: none; + } + .empty-state { display: flex; flex-direction: column; @@ -409,6 +451,10 @@ export class QueuePanel 'click', this.clearSelectionHandler, ); + document.addEventListener( + 'dragend', + this.onDocumentDragEnd, + ); } override disconnectedCallback() { @@ -437,11 +483,22 @@ export class QueuePanel 'click', this.clearSelectionHandler, ); + document.removeEventListener( + 'dragend', + this.onDocumentDragEnd, + ); } override updated() { const currentIndex = this.queue.currentIndex; + // Force virtualizer to re-render visible items when the + // active track changes so the highlight stays in sync. + if (currentIndex !== this.lastRenderedIndex) { + this.lastRenderedIndex = currentIndex; + this.virtualizer?.requestUpdate(); + } + // Auto-scroll to the active track when it changes. if ( currentIndex >= 0 && @@ -645,37 +702,82 @@ export class QueuePanel // Drop target (tracks dropped into queue) // ================================================================= - private onPanelDragOver = (e: DragEvent) => { - if (!hasTrackPayload(e)) return; - - e.preventDefault(); - - if (e.dataTransfer) { - e.dataTransfer.dropEffect = 'copy'; - } - - if (!this.dragOver) { - this.dragOver = true; - } - }; - - private onPanelDragLeave = (e: DragEvent) => { - // Only reset when leaving the panel-content - // element itself (not a child). - const related = e.relatedTarget as Node | null; + /** + * Toggle the drag-over CSS classes directly on the + * DOM element. This avoids Lit re-renders which + * cause DOM mutations that break the browser's + * drag event stream. + */ + private updateDragOverClass() { const panel = this.shadowRoot?.querySelector( '.panel-content', ); - if (panel && !panel.contains(related)) { - this.dragOver = false; + if (!panel) return; + + const isEmpty = this.queue.tracks.length === 0; + + panel.classList.toggle( + 'drag-over', + this.dragOver, + ); + panel.classList.toggle( + 'empty-drag', + this.dragOver && isEmpty, + ); + } + + private onPanelDragEnter = (e: DragEvent) => { + if (!hasTrackPayload(e)) return; + + e.preventDefault(); + + if (e.dataTransfer) { + const isInternal = + getActiveDragSource() === 'queue'; + e.dataTransfer.dropEffect = isInternal + ? 'move' + : 'copy'; } + + if (!this.dragOver) { + this.dragOver = true; + this.updateDragOverClass(); + this.startAutoScroll(); + } + }; + + private onPanelDragOver = (e: DragEvent) => { + if (!hasTrackPayload(e)) return; + + e.preventDefault(); + + const isInternal = + getActiveDragSource() === 'queue'; + + if (e.dataTransfer) { + e.dataTransfer.dropEffect = isInternal + ? 'move' + : 'copy'; + } + + this.updateDropTargetIndex(e.clientY); + this.updateAutoScrollDelta(e.clientY); + }; + + private onPanelDragLeave = (_e: DragEvent) => { + // No-op: cleanup is handled by dragend / drop. + // Firing here would break due to child-boundary + // and virtualizer re-render events. }; private onPanelDrop = (e: DragEvent) => { e.preventDefault(); - this.dragOver = false; + + const targetIndex = this.dropTargetIndex; + + this.cleanupDragState(); const payload = getDragPayload(e); @@ -686,11 +788,203 @@ export class QueuePanel return; } - // Don't allow dropping queue items back - // onto the queue. - if (payload.source === 'queue') return; + if (payload.source === 'queue') { + // Internal reorder. + const fromIndices = this.selection + .getSelectedIndices(); - this.queue.addTracksToQueue(payload.filePaths); + if (fromIndices.length > 0) { + this.queue.moveTracksInQueue( + fromIndices, + targetIndex >= 0 + ? targetIndex + : this.queue.tracks.length, + ); + } + } else { + // External insert at position. + const idx = + targetIndex >= 0 + ? targetIndex + : this.queue.tracks.length; + this.queue.insertTracksAtIndex( + payload.filePaths, + idx, + ); + } + }; + + /** + * Calculate the drop target index from cursor Y + * position relative to the virtualizer's children. + */ + private updateDropTargetIndex(clientY: number) { + const newIdx = + this.computeDropTargetIndex(clientY); + + if (newIdx !== this.dropTargetIndex) { + this.dropTargetIndex = newIdx; + + // Debounce via RAF to avoid layout thrashing + // that interrupts the browser drag stream. + if (!this.dropTargetRafId) { + this.dropTargetRafId = + requestAnimationFrame(() => { + this.dropTargetRafId = 0; + this.virtualizer?.requestUpdate(); + }); + } + } + } + + private computeDropTargetIndex( + clientY: number, + ): number { + const tracks = this.queue.tracks; + + if (tracks.length === 0) return 0; + + const virt = this.virtualizer; + + if (!virt) return tracks.length; + + const items = + virt.querySelectorAll('.track-item'); + + if (items.length === 0) return tracks.length; + + // Check each visible item to find the drop + // position. + for (const item of items) { + const rect = item.getBoundingClientRect(); + const midY = rect.top + rect.height / 2; + + if (clientY < midY) { + const idx = Number( + (item as HTMLElement).dataset.index, + ); + + if (!Number.isNaN(idx)) return idx; + } + } + + // Cursor is below all visible items — append + // at end. + const lastItem = items[items.length - 1]; + + if (lastItem) { + const idx = Number( + (lastItem as HTMLElement).dataset + .index, + ); + + if (!Number.isNaN(idx)) return idx + 1; + } + + return tracks.length; + } + + // ================================================================= + // Auto-scroll during drag + // ================================================================= + + private static readonly SCROLL_ZONE = 60; + private static readonly SCROLL_SPEED = 12; + + /** + * Update the scroll delta based on cursor proximity + * to the top/bottom edges. The RAF loop (started in + * onPanelDragEnter) reads this value each frame. + * Setting delta to 0 means no scrolling; the loop + * stays running until the drag ends. + */ + private updateAutoScrollDelta(clientY: number) { + const virt = this.virtualizer; + + if (!virt) return; + + const rect = virt.getBoundingClientRect(); + const zone = QueuePanel.SCROLL_ZONE; + + const distTop = clientY - rect.top; + const distBottom = rect.bottom - clientY; + + if (distTop < zone && distTop >= 0) { + this.autoScrollDelta = + -QueuePanel.SCROLL_SPEED * + (1 - distTop / zone); + } else if ( + distBottom < zone && + distBottom >= 0 + ) { + this.autoScrollDelta = + QueuePanel.SCROLL_SPEED * + (1 - distBottom / zone); + } else { + this.autoScrollDelta = 0; + } + } + + private startAutoScroll() { + if (this.autoScrollRafId) return; + + const step = () => { + const virt = this.virtualizer; + + if (!virt) { + this.autoScrollRafId = 0; + + return; + } + + if (this.autoScrollDelta !== 0) { + virt.scrollTop += this.autoScrollDelta; + } + + this.autoScrollRafId = + requestAnimationFrame(step); + }; + + this.autoScrollRafId = + requestAnimationFrame(step); + } + + private stopAutoScroll() { + if (this.autoScrollRafId) { + cancelAnimationFrame(this.autoScrollRafId); + this.autoScrollRafId = 0; + } + + this.autoScrollDelta = 0; + } + + /** + * Reset all drag-related state. Called from drop, + * dragend, and the global dragend fallback. + */ + private cleanupDragState() { + if (!this.dragOver) return; + + this.dragOver = false; + this.dropTargetIndex = -1; + + if (this.dropTargetRafId) { + cancelAnimationFrame(this.dropTargetRafId); + this.dropTargetRafId = 0; + } + + this.updateDragOverClass(); + this.stopAutoScroll(); + this.virtualizer?.requestUpdate(); + } + + /** + * Global dragend handler catches external drags + * (from track-list / cover-grid) that end outside + * the queue panel without a drop event. + */ + private onDocumentDragEnd = () => { + this.cleanupDragState(); }; // ================================================================= @@ -706,10 +1000,17 @@ export class QueuePanel let filePaths: string[]; if (this.selection.isSelected(String(index))) { + // Drag the entire multi-selection. filePaths = this.selection .getSelectedIndices() .map((i) => tracks[i]!.filePath); } else { + // Dragging an unselected track — select + // only it so internal reorder works. + this.selection.handleContextMenu( + String(index), + ); + const track = tracks[index]; if (!track) return; @@ -742,6 +1043,7 @@ export class QueuePanel this.dragImageEl = null; } + this.cleanupDragState(); emitDragActive(false); }; @@ -806,10 +1108,19 @@ export class QueuePanel String(index), ); + const dropIdx = this.dropTargetIndex; + const trackCount = this.queue.tracks.length; + const showBefore = dropIdx === index; + const showAfter = + dropIdx === trackCount && + index === trackCount - 1; + const classes = [ 'track-item', active ? 'active' : '', selected ? 'selected' : '', + showBefore ? 'drop-before' : '', + showAfter ? 'drop-after' : '', ] .filter(Boolean) .join(' '); @@ -817,7 +1128,8 @@ export class QueuePanel return html`
this.handleTrackClick(e, track, index)} @dblclick=${() => @@ -856,9 +1168,8 @@ export class QueuePanel return html`
this.onTrackRowClick(e, track, index)} @dblclick=${() => this.onTrackRowDblClick(track)} diff --git a/frontend/src/events.ts b/frontend/src/events.ts index 718d7ae..ce4c9da 100644 --- a/frontend/src/events.ts +++ b/frontend/src/events.ts @@ -37,6 +37,8 @@ export const Events = { RequestPlayTracksNext: "RequestPlayTracksNext", RequestPlayQueueIndex: "RequestPlayQueueIndex", RequestRemoveTracksFromQueue: "RequestRemoveTracksFromQueue", + RequestInsertTracksAtIndex: "RequestInsertTracksAtIndex", + RequestMoveQueueTracks: "RequestMoveQueueTracks", // Library events LibraryScanStarted: "LibraryScanStarted", diff --git a/frontend/src/store/controllers/queue-controller.ts b/frontend/src/store/controllers/queue-controller.ts index 9cb5afd..873c806 100644 --- a/frontend/src/store/controllers/queue-controller.ts +++ b/frontend/src/store/controllers/queue-controller.ts @@ -118,4 +118,18 @@ export class QueueController implements ReactiveController { playAtIndex(index: number): void { queueStore.playAtIndex(index); } + + insertTracksAtIndex( + filePaths: string[], + index: number, + ): void { + queueStore.insertTracksAtIndex(filePaths, index); + } + + moveTracksInQueue( + fromIndices: number[], + toIndex: number, + ): void { + queueStore.moveTracksInQueue(fromIndices, toIndex); + } } diff --git a/frontend/src/store/queue-store.ts b/frontend/src/store/queue-store.ts index 7b98bae..b12689c 100644 --- a/frontend/src/store/queue-store.ts +++ b/frontend/src/store/queue-store.ts @@ -131,6 +131,38 @@ class QueueStore { ); } + break; + + case 'move': + if (delta.positions && delta.tracks) { + const removeSet = new Set(delta.positions); + const remaining = tracks.filter( + (_, i) => !removeSet.has(i), + ); + + // Adjust insertion index for removed elements. + let adjustedIdx = delta.index; + + for (const pos of delta.positions) { + if (pos < delta.index) { + adjustedIdx--; + } + } + + adjustedIdx = Math.max( + 0, + Math.min(adjustedIdx, remaining.length), + ); + + const before = remaining.slice(0, adjustedIdx); + const after = remaining.slice(adjustedIdx); + this.state.tracks = [ + ...before, + ...delta.tracks, + ...after, + ]; + } + break; } @@ -198,6 +230,25 @@ class QueueStore { EventsEmit(Events.RequestPlayQueueIndex, index); } + insertTracksAtIndex(filePaths: string[], index: number): void { + EventsEmit( + Events.RequestInsertTracksAtIndex, + filePaths, + index, + ); + } + + moveTracksInQueue( + fromIndices: number[], + toIndex: number, + ): void { + EventsEmit( + Events.RequestMoveQueueTracks, + fromIndices, + toIndex, + ); + } + // =================================================================== // SUBSCRIPTION SYSTEM // =================================================================== diff --git a/frontend/src/utils/drag-controller.ts b/frontend/src/utils/drag-controller.ts index f32ce33..21afa1a 100644 --- a/frontend/src/utils/drag-controller.ts +++ b/frontend/src/utils/drag-controller.ts @@ -25,6 +25,25 @@ export interface DragPayload { sourcePlaylistId?: number; } +// ===================================================================== +// Active drag source tracking +// ===================================================================== + +let activeDragSource: DragSource | null = null; +let activeDragPlaylistId: number | undefined; + +/** Return the source of the in-progress drag, or null. */ +export function getActiveDragSource(): DragSource | null { + return activeDragSource; +} + +/** Return the playlist ID of the in-progress drag, if any. */ +export function getActiveDragPlaylistId(): + | number + | undefined { + return activeDragPlaylistId; +} + // ===================================================================== // Global drag-active event // ===================================================================== @@ -39,6 +58,11 @@ export interface DragActiveDetail { * affordances (e.g. sidebar hover-to-navigate, queue button glow). */ export function emitDragActive(active: boolean): void { + if (!active) { + activeDragSource = null; + activeDragPlaylistId = undefined; + } + document.dispatchEvent( new CustomEvent( 'yj-drag-active', @@ -65,7 +89,10 @@ export function setDragPayload( ): boolean { if (!e.dataTransfer) return false; - e.dataTransfer.effectAllowed = 'copy'; + activeDragSource = payload.source; + activeDragPlaylistId = payload.sourcePlaylistId; + + e.dataTransfer.effectAllowed = 'copyMove'; e.dataTransfer.setData( DRAG_MIME, JSON.stringify(payload),