fixed drag and drop behavior

This commit is contained in:
2026-02-19 22:37:19 -05:00
parent 193f65bd98
commit 56cf92a44c
11 changed files with 825 additions and 44 deletions
+2
View File
@@ -47,6 +47,8 @@ const (
RequestPlayTracksNext = "RequestPlayTracksNext"
RequestPlayQueueIndex = "RequestPlayQueueIndex"
RequestRemoveTracksFromQueue = "RequestRemoveTracksFromQueue"
RequestInsertTracksAtIndex = "RequestInsertTracksAtIndex"
RequestMoveQueueTracks = "RequestMoveQueueTracks"
)
// Config events.
+355 -6
View File
@@ -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()
@@ -327,7 +327,7 @@ export class AlbumDropdown extends LitElement {
return html`
<div
class=${classes}
draggable=${selected ? 'true' : 'false'}
draggable="true"
@click=${(e: MouseEvent) =>
this.onTrackClick(e, track, index)}
@dblclick=${(e: MouseEvent) =>
@@ -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}
>
@@ -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`
<div
class=${classes}
draggable=${selected
? 'true'
: 'false'}
draggable="true"
@click=${(
e: MouseEvent,
) =>
@@ -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`
<div
class=${classes}
draggable=${selected ? 'true' : 'false'}
data-index=${index}
draggable="true"
@click=${(e: MouseEvent) =>
this.handleTrackClick(e, track, index)}
@dblclick=${() =>
@@ -856,9 +1168,8 @@ export class QueuePanel
return html`
<div
class="panel-content ${this.dragOver
? 'drag-over'
: ''}"
class="panel-content"
@dragenter=${this.onPanelDragEnter}
@dragover=${this.onPanelDragOver}
@dragleave=${this.onPanelDragLeave}
@drop=${this.onPanelDrop}
@@ -297,7 +297,9 @@ export class TrackList extends LitElement implements SelectionHost {
lit-virtualizer {
flex: 1;
overflow-x: hidden;
overflow-y: auto;
user-select: none;
}
.track-row {
@@ -730,7 +732,7 @@ export class TrackList extends LitElement implements SelectionHost {
return html`
<div
class=${classes}
draggable=${selected ? 'true' : 'false'}
draggable="true"
@click=${(e: MouseEvent) =>
this.onTrackRowClick(e, track, index)}
@dblclick=${() => this.onTrackRowDblClick(track)}
+2
View File
@@ -37,6 +37,8 @@ export const Events = {
RequestPlayTracksNext: "RequestPlayTracksNext",
RequestPlayQueueIndex: "RequestPlayQueueIndex",
RequestRemoveTracksFromQueue: "RequestRemoveTracksFromQueue",
RequestInsertTracksAtIndex: "RequestInsertTracksAtIndex",
RequestMoveQueueTracks: "RequestMoveQueueTracks",
// Library events
LibraryScanStarted: "LibraryScanStarted",
@@ -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);
}
}
+51
View File
@@ -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
// ===================================================================
+28 -1
View File
@@ -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<DragActiveDetail>(
'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),