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),