fixed drag and drop behavior
This commit is contained in:
@@ -47,6 +47,8 @@ const (
|
|||||||
RequestPlayTracksNext = "RequestPlayTracksNext"
|
RequestPlayTracksNext = "RequestPlayTracksNext"
|
||||||
RequestPlayQueueIndex = "RequestPlayQueueIndex"
|
RequestPlayQueueIndex = "RequestPlayQueueIndex"
|
||||||
RequestRemoveTracksFromQueue = "RequestRemoveTracksFromQueue"
|
RequestRemoveTracksFromQueue = "RequestRemoveTracksFromQueue"
|
||||||
|
RequestInsertTracksAtIndex = "RequestInsertTracksAtIndex"
|
||||||
|
RequestMoveQueueTracks = "RequestMoveQueueTracks"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Config events.
|
// Config events.
|
||||||
|
|||||||
+355
-6
@@ -272,6 +272,28 @@ func (q *Queue) registerEventHandlers() {
|
|||||||
q.handleRemoveTracksFromQueue(data...)
|
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.
|
// handleSetQueue processes the RequestSetQueue event payload.
|
||||||
@@ -435,6 +457,92 @@ func (q *Queue) handleAddTracksToQueue(data ...any) {
|
|||||||
q.AddTracks(filePaths)
|
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.
|
// handlePlayQueueIndex processes the RequestPlayQueueIndex event payload.
|
||||||
// Expects data[0] = float64 index.
|
// Expects data[0] = float64 index.
|
||||||
func (q *Queue) handlePlayQueueIndex(data ...any) {
|
func (q *Queue) handlePlayQueueIndex(data ...any) {
|
||||||
@@ -644,7 +752,7 @@ func (q *Queue) resolveRemainingTracks(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// AddTrack appends a track to the end of the queue.
|
// 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) {
|
func (q *Queue) AddTrack(filePath string) {
|
||||||
meta := q.lookupTrackMetaBatch([]string{filePath})
|
meta := q.lookupTrackMetaBatch([]string{filePath})
|
||||||
|
|
||||||
@@ -690,10 +798,10 @@ func (q *Queue) AddTrack(filePath string) {
|
|||||||
q.shuffleOrder = append(q.shuffleOrder, len(q.tracks)-1)
|
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 {
|
if wasEmpty {
|
||||||
q.currentIndex = 0
|
q.currentIndex = 0
|
||||||
q.playCurrentTrack()
|
q.loadCurrentTrack()
|
||||||
}
|
}
|
||||||
|
|
||||||
q.persistState()
|
q.persistState()
|
||||||
@@ -706,7 +814,7 @@ func (q *Queue) AddTrack(filePath string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// AddTracks appends multiple tracks to the end of the queue.
|
// 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) {
|
func (q *Queue) AddTracks(filePaths []string) {
|
||||||
allMeta := q.lookupTrackMetaBatch(filePaths)
|
allMeta := q.lookupTrackMetaBatch(filePaths)
|
||||||
|
|
||||||
@@ -751,7 +859,7 @@ func (q *Queue) AddTracks(filePaths []string) {
|
|||||||
|
|
||||||
if wasEmpty && len(q.tracks) > 0 {
|
if wasEmpty && len(q.tracks) > 0 {
|
||||||
q.currentIndex = 0
|
q.currentIndex = 0
|
||||||
q.playCurrentTrack()
|
q.loadCurrentTrack()
|
||||||
}
|
}
|
||||||
|
|
||||||
q.emitTracksModified(
|
q.emitTracksModified(
|
||||||
@@ -763,6 +871,7 @@ func (q *Queue) AddTracks(filePaths []string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// InsertNextTracks inserts multiple tracks as a contiguous block after the current track.
|
// 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) {
|
func (q *Queue) InsertNextTracks(filePaths []string) {
|
||||||
allMeta := q.lookupTrackMetaBatch(filePaths)
|
allMeta := q.lookupTrackMetaBatch(filePaths)
|
||||||
|
|
||||||
@@ -818,7 +927,7 @@ func (q *Queue) InsertNextTracks(filePaths []string) {
|
|||||||
|
|
||||||
if wasEmpty {
|
if wasEmpty {
|
||||||
q.currentIndex = 0
|
q.currentIndex = 0
|
||||||
q.playCurrentTrack()
|
q.loadCurrentTrack()
|
||||||
}
|
}
|
||||||
|
|
||||||
q.emitTracksModified(
|
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.
|
// RemoveTrack removes a track at the given position from the queue.
|
||||||
func (q *Queue) RemoveTrack(position int) {
|
func (q *Queue) RemoveTrack(position int) {
|
||||||
q.mu.Lock()
|
q.mu.Lock()
|
||||||
|
|||||||
@@ -327,7 +327,7 @@ export class AlbumDropdown extends LitElement {
|
|||||||
return html`
|
return html`
|
||||||
<div
|
<div
|
||||||
class=${classes}
|
class=${classes}
|
||||||
draggable=${selected ? 'true' : 'false'}
|
draggable="true"
|
||||||
@click=${(e: MouseEvent) =>
|
@click=${(e: MouseEvent) =>
|
||||||
this.onTrackClick(e, track, index)}
|
this.onTrackClick(e, track, index)}
|
||||||
@dblclick=${(e: MouseEvent) =>
|
@dblclick=${(e: MouseEvent) =>
|
||||||
|
|||||||
@@ -2601,7 +2601,7 @@ export class CoverGrid extends LitElement {
|
|||||||
role="button"
|
role="button"
|
||||||
data-index=${index}
|
data-index=${index}
|
||||||
aria-label="${album.Name} by ${album.ArtistName}"
|
aria-label="${album.Name} by ${album.ArtistName}"
|
||||||
draggable=${selected ? 'true' : 'false'}
|
draggable="true"
|
||||||
@dragstart=${this.onAlbumDragStart}
|
@dragstart=${this.onAlbumDragStart}
|
||||||
@dragend=${this.onAlbumDragEnd}
|
@dragend=${this.onAlbumDragEnd}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -26,6 +26,8 @@ import {
|
|||||||
getDragPayload,
|
getDragPayload,
|
||||||
setDragPayload,
|
setDragPayload,
|
||||||
emitDragActive,
|
emitDragActive,
|
||||||
|
getActiveDragSource,
|
||||||
|
getActiveDragPlaylistId,
|
||||||
} from '@utils/drag-controller';
|
} from '@utils/drag-controller';
|
||||||
import {
|
import {
|
||||||
createDragImage,
|
createDragImage,
|
||||||
@@ -801,6 +803,19 @@ export class PlaylistView
|
|||||||
) => {
|
) => {
|
||||||
if (!hasTrackPayload(e)) return;
|
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();
|
e.preventDefault();
|
||||||
|
|
||||||
if (e.dataTransfer) {
|
if (e.dataTransfer) {
|
||||||
@@ -852,6 +867,16 @@ export class PlaylistView
|
|||||||
|
|
||||||
if (!entry) return;
|
if (!entry) return;
|
||||||
|
|
||||||
|
// Don't allow dropping tracks back onto
|
||||||
|
// the same playlist.
|
||||||
|
if (
|
||||||
|
payload.source === 'playlist' &&
|
||||||
|
payload.sourcePlaylistId ===
|
||||||
|
entry.summary.ID
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await AddTracksToPlaylist(
|
await AddTracksToPlaylist(
|
||||||
entry.summary.ID,
|
entry.summary.ID,
|
||||||
@@ -1280,9 +1305,7 @@ export class PlaylistView
|
|||||||
return html`
|
return html`
|
||||||
<div
|
<div
|
||||||
class=${classes}
|
class=${classes}
|
||||||
draggable=${selected
|
draggable="true"
|
||||||
? 'true'
|
|
||||||
: 'false'}
|
|
||||||
@click=${(
|
@click=${(
|
||||||
e: MouseEvent,
|
e: MouseEvent,
|
||||||
) =>
|
) =>
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import {
|
|||||||
getDragPayload,
|
getDragPayload,
|
||||||
setDragPayload,
|
setDragPayload,
|
||||||
emitDragActive,
|
emitDragActive,
|
||||||
|
getActiveDragSource,
|
||||||
} from '@utils/drag-controller';
|
} from '@utils/drag-controller';
|
||||||
import {
|
import {
|
||||||
createDragImage,
|
createDragImage,
|
||||||
@@ -55,9 +56,14 @@ export class QueuePanel
|
|||||||
@state()
|
@state()
|
||||||
private playlistSubmenuOpen = false;
|
private playlistSubmenuOpen = false;
|
||||||
|
|
||||||
@state()
|
|
||||||
private dragOver = false;
|
private dragOver = false;
|
||||||
|
|
||||||
|
private dropTargetIndex = -1;
|
||||||
|
private dropTargetRafId = 0;
|
||||||
|
|
||||||
|
private autoScrollRafId = 0;
|
||||||
|
private autoScrollDelta = 0;
|
||||||
|
|
||||||
private dragImageEl: HTMLElement | null = null;
|
private dragImageEl: HTMLElement | null = null;
|
||||||
|
|
||||||
@query('#add-to-playlist-popup')
|
@query('#add-to-playlist-popup')
|
||||||
@@ -114,6 +120,14 @@ export class QueuePanel
|
|||||||
*/
|
*/
|
||||||
private lastScrolledIndex = -1;
|
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
|
// SelectionHost interface
|
||||||
// =================================================================
|
// =================================================================
|
||||||
@@ -221,6 +235,7 @@ export class QueuePanel
|
|||||||
}
|
}
|
||||||
|
|
||||||
.track-item {
|
.track-item {
|
||||||
|
position: relative;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
padding: 8px 16px;
|
padding: 8px 16px;
|
||||||
@@ -322,10 +337,37 @@ export class QueuePanel
|
|||||||
rgba(255, 212, 59, 0.2);
|
rgba(255, 212, 59, 0.2);
|
||||||
}
|
}
|
||||||
|
|
||||||
.panel-content.drag-over .drop-indicator {
|
.panel-content.drag-over.empty-drag
|
||||||
|
.drop-indicator {
|
||||||
display: block;
|
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 {
|
.empty-state {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -409,6 +451,10 @@ export class QueuePanel
|
|||||||
'click',
|
'click',
|
||||||
this.clearSelectionHandler,
|
this.clearSelectionHandler,
|
||||||
);
|
);
|
||||||
|
document.addEventListener(
|
||||||
|
'dragend',
|
||||||
|
this.onDocumentDragEnd,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
override disconnectedCallback() {
|
override disconnectedCallback() {
|
||||||
@@ -437,11 +483,22 @@ export class QueuePanel
|
|||||||
'click',
|
'click',
|
||||||
this.clearSelectionHandler,
|
this.clearSelectionHandler,
|
||||||
);
|
);
|
||||||
|
document.removeEventListener(
|
||||||
|
'dragend',
|
||||||
|
this.onDocumentDragEnd,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
override updated() {
|
override updated() {
|
||||||
const currentIndex = this.queue.currentIndex;
|
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.
|
// Auto-scroll to the active track when it changes.
|
||||||
if (
|
if (
|
||||||
currentIndex >= 0 &&
|
currentIndex >= 0 &&
|
||||||
@@ -645,37 +702,82 @@ export class QueuePanel
|
|||||||
// Drop target (tracks dropped into queue)
|
// Drop target (tracks dropped into queue)
|
||||||
// =================================================================
|
// =================================================================
|
||||||
|
|
||||||
private onPanelDragOver = (e: DragEvent) => {
|
/**
|
||||||
if (!hasTrackPayload(e)) return;
|
* Toggle the drag-over CSS classes directly on the
|
||||||
|
* DOM element. This avoids Lit re-renders which
|
||||||
e.preventDefault();
|
* cause DOM mutations that break the browser's
|
||||||
|
* drag event stream.
|
||||||
if (e.dataTransfer) {
|
*/
|
||||||
e.dataTransfer.dropEffect = 'copy';
|
private updateDragOverClass() {
|
||||||
}
|
|
||||||
|
|
||||||
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;
|
|
||||||
const panel =
|
const panel =
|
||||||
this.shadowRoot?.querySelector(
|
this.shadowRoot?.querySelector(
|
||||||
'.panel-content',
|
'.panel-content',
|
||||||
);
|
);
|
||||||
|
|
||||||
if (panel && !panel.contains(related)) {
|
if (!panel) return;
|
||||||
this.dragOver = false;
|
|
||||||
|
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) => {
|
private onPanelDrop = (e: DragEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
this.dragOver = false;
|
|
||||||
|
const targetIndex = this.dropTargetIndex;
|
||||||
|
|
||||||
|
this.cleanupDragState();
|
||||||
|
|
||||||
const payload = getDragPayload(e);
|
const payload = getDragPayload(e);
|
||||||
|
|
||||||
@@ -686,11 +788,203 @@ export class QueuePanel
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Don't allow dropping queue items back
|
if (payload.source === 'queue') {
|
||||||
// onto the queue.
|
// Internal reorder.
|
||||||
if (payload.source === 'queue') return;
|
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[];
|
let filePaths: string[];
|
||||||
|
|
||||||
if (this.selection.isSelected(String(index))) {
|
if (this.selection.isSelected(String(index))) {
|
||||||
|
// Drag the entire multi-selection.
|
||||||
filePaths = this.selection
|
filePaths = this.selection
|
||||||
.getSelectedIndices()
|
.getSelectedIndices()
|
||||||
.map((i) => tracks[i]!.filePath);
|
.map((i) => tracks[i]!.filePath);
|
||||||
} else {
|
} else {
|
||||||
|
// Dragging an unselected track — select
|
||||||
|
// only it so internal reorder works.
|
||||||
|
this.selection.handleContextMenu(
|
||||||
|
String(index),
|
||||||
|
);
|
||||||
|
|
||||||
const track = tracks[index];
|
const track = tracks[index];
|
||||||
|
|
||||||
if (!track) return;
|
if (!track) return;
|
||||||
@@ -742,6 +1043,7 @@ export class QueuePanel
|
|||||||
this.dragImageEl = null;
|
this.dragImageEl = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
this.cleanupDragState();
|
||||||
emitDragActive(false);
|
emitDragActive(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -806,10 +1108,19 @@ export class QueuePanel
|
|||||||
String(index),
|
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 = [
|
const classes = [
|
||||||
'track-item',
|
'track-item',
|
||||||
active ? 'active' : '',
|
active ? 'active' : '',
|
||||||
selected ? 'selected' : '',
|
selected ? 'selected' : '',
|
||||||
|
showBefore ? 'drop-before' : '',
|
||||||
|
showAfter ? 'drop-after' : '',
|
||||||
]
|
]
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
.join(' ');
|
.join(' ');
|
||||||
@@ -817,7 +1128,8 @@ export class QueuePanel
|
|||||||
return html`
|
return html`
|
||||||
<div
|
<div
|
||||||
class=${classes}
|
class=${classes}
|
||||||
draggable=${selected ? 'true' : 'false'}
|
data-index=${index}
|
||||||
|
draggable="true"
|
||||||
@click=${(e: MouseEvent) =>
|
@click=${(e: MouseEvent) =>
|
||||||
this.handleTrackClick(e, track, index)}
|
this.handleTrackClick(e, track, index)}
|
||||||
@dblclick=${() =>
|
@dblclick=${() =>
|
||||||
@@ -856,9 +1168,8 @@ export class QueuePanel
|
|||||||
|
|
||||||
return html`
|
return html`
|
||||||
<div
|
<div
|
||||||
class="panel-content ${this.dragOver
|
class="panel-content"
|
||||||
? 'drag-over'
|
@dragenter=${this.onPanelDragEnter}
|
||||||
: ''}"
|
|
||||||
@dragover=${this.onPanelDragOver}
|
@dragover=${this.onPanelDragOver}
|
||||||
@dragleave=${this.onPanelDragLeave}
|
@dragleave=${this.onPanelDragLeave}
|
||||||
@drop=${this.onPanelDrop}
|
@drop=${this.onPanelDrop}
|
||||||
|
|||||||
@@ -297,7 +297,9 @@ export class TrackList extends LitElement implements SelectionHost {
|
|||||||
|
|
||||||
lit-virtualizer {
|
lit-virtualizer {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
|
overflow-x: hidden;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
|
user-select: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.track-row {
|
.track-row {
|
||||||
@@ -730,7 +732,7 @@ export class TrackList extends LitElement implements SelectionHost {
|
|||||||
return html`
|
return html`
|
||||||
<div
|
<div
|
||||||
class=${classes}
|
class=${classes}
|
||||||
draggable=${selected ? 'true' : 'false'}
|
draggable="true"
|
||||||
@click=${(e: MouseEvent) =>
|
@click=${(e: MouseEvent) =>
|
||||||
this.onTrackRowClick(e, track, index)}
|
this.onTrackRowClick(e, track, index)}
|
||||||
@dblclick=${() => this.onTrackRowDblClick(track)}
|
@dblclick=${() => this.onTrackRowDblClick(track)}
|
||||||
|
|||||||
@@ -37,6 +37,8 @@ export const Events = {
|
|||||||
RequestPlayTracksNext: "RequestPlayTracksNext",
|
RequestPlayTracksNext: "RequestPlayTracksNext",
|
||||||
RequestPlayQueueIndex: "RequestPlayQueueIndex",
|
RequestPlayQueueIndex: "RequestPlayQueueIndex",
|
||||||
RequestRemoveTracksFromQueue: "RequestRemoveTracksFromQueue",
|
RequestRemoveTracksFromQueue: "RequestRemoveTracksFromQueue",
|
||||||
|
RequestInsertTracksAtIndex: "RequestInsertTracksAtIndex",
|
||||||
|
RequestMoveQueueTracks: "RequestMoveQueueTracks",
|
||||||
|
|
||||||
// Library events
|
// Library events
|
||||||
LibraryScanStarted: "LibraryScanStarted",
|
LibraryScanStarted: "LibraryScanStarted",
|
||||||
|
|||||||
@@ -118,4 +118,18 @@ export class QueueController implements ReactiveController {
|
|||||||
playAtIndex(index: number): void {
|
playAtIndex(index: number): void {
|
||||||
queueStore.playAtIndex(index);
|
queueStore.playAtIndex(index);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
insertTracksAtIndex(
|
||||||
|
filePaths: string[],
|
||||||
|
index: number,
|
||||||
|
): void {
|
||||||
|
queueStore.insertTracksAtIndex(filePaths, index);
|
||||||
|
}
|
||||||
|
|
||||||
|
moveTracksInQueue(
|
||||||
|
fromIndices: number[],
|
||||||
|
toIndex: number,
|
||||||
|
): void {
|
||||||
|
queueStore.moveTracksInQueue(fromIndices, toIndex);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -198,6 +230,25 @@ class QueueStore {
|
|||||||
EventsEmit(Events.RequestPlayQueueIndex, index);
|
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
|
// SUBSCRIPTION SYSTEM
|
||||||
// ===================================================================
|
// ===================================================================
|
||||||
|
|||||||
@@ -25,6 +25,25 @@ export interface DragPayload {
|
|||||||
sourcePlaylistId?: number;
|
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
|
// Global drag-active event
|
||||||
// =====================================================================
|
// =====================================================================
|
||||||
@@ -39,6 +58,11 @@ export interface DragActiveDetail {
|
|||||||
* affordances (e.g. sidebar hover-to-navigate, queue button glow).
|
* affordances (e.g. sidebar hover-to-navigate, queue button glow).
|
||||||
*/
|
*/
|
||||||
export function emitDragActive(active: boolean): void {
|
export function emitDragActive(active: boolean): void {
|
||||||
|
if (!active) {
|
||||||
|
activeDragSource = null;
|
||||||
|
activeDragPlaylistId = undefined;
|
||||||
|
}
|
||||||
|
|
||||||
document.dispatchEvent(
|
document.dispatchEvent(
|
||||||
new CustomEvent<DragActiveDetail>(
|
new CustomEvent<DragActiveDetail>(
|
||||||
'yj-drag-active',
|
'yj-drag-active',
|
||||||
@@ -65,7 +89,10 @@ export function setDragPayload(
|
|||||||
): boolean {
|
): boolean {
|
||||||
if (!e.dataTransfer) return false;
|
if (!e.dataTransfer) return false;
|
||||||
|
|
||||||
e.dataTransfer.effectAllowed = 'copy';
|
activeDragSource = payload.source;
|
||||||
|
activeDragPlaylistId = payload.sourcePlaylistId;
|
||||||
|
|
||||||
|
e.dataTransfer.effectAllowed = 'copyMove';
|
||||||
e.dataTransfer.setData(
|
e.dataTransfer.setData(
|
||||||
DRAG_MIME,
|
DRAG_MIME,
|
||||||
JSON.stringify(payload),
|
JSON.stringify(payload),
|
||||||
|
|||||||
Reference in New Issue
Block a user