virtualization in queue list, queue db queries optimized for large queue requests
This commit is contained in:
@@ -93,3 +93,18 @@ func NewDB(logger *slog.Logger) (*DB, error) {
|
|||||||
logger: logger,
|
logger: logger,
|
||||||
}, err
|
}, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// BeginTx starts a new database transaction.
|
||||||
|
func (d *DB) BeginTx() (*sql.Tx, error) {
|
||||||
|
return d.db.BeginTx(d.Ctx, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ExecContext executes a query without returning any rows.
|
||||||
|
func (d *DB) ExecContext(query string, args ...any) (sql.Result, error) {
|
||||||
|
return d.db.ExecContext(d.Ctx, query, args...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// QueryContext executes a query that returns rows.
|
||||||
|
func (d *DB) QueryContext(query string, args ...any) (*sql.Rows, error) {
|
||||||
|
return d.db.QueryContext(d.Ctx, query, args...)
|
||||||
|
}
|
||||||
|
|||||||
+466
-142
@@ -6,9 +6,12 @@ import (
|
|||||||
"database/sql"
|
"database/sql"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
|
"fmt"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"math/rand/v2"
|
"math/rand/v2"
|
||||||
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
|
"sync/atomic"
|
||||||
|
|
||||||
"github.com/wailsapp/wails/v2/pkg/runtime"
|
"github.com/wailsapp/wails/v2/pkg/runtime"
|
||||||
|
|
||||||
@@ -31,6 +34,18 @@ const (
|
|||||||
// "Previous" restarts the current track instead of going to the prior one.
|
// "Previous" restarts the current track instead of going to the prior one.
|
||||||
const PreviousRestartThreshold = 3
|
const PreviousRestartThreshold = 3
|
||||||
|
|
||||||
|
// maxSQLiteVars is the maximum number of bind variables SQLite supports
|
||||||
|
// per statement. We use a conservative limit for batching.
|
||||||
|
const maxSQLiteVars = 900
|
||||||
|
|
||||||
|
// trackMeta holds the result of a batch metadata lookup.
|
||||||
|
type trackMeta struct {
|
||||||
|
AudioFileID int64
|
||||||
|
FilePath string
|
||||||
|
Title string
|
||||||
|
Artist string
|
||||||
|
}
|
||||||
|
|
||||||
// TrackLoader is the interface the queue uses to tell the player to load a file.
|
// TrackLoader is the interface the queue uses to tell the player to load a file.
|
||||||
type TrackLoader interface {
|
type TrackLoader interface {
|
||||||
LoadFile(filePath string) error
|
LoadFile(filePath string) error
|
||||||
@@ -73,6 +88,10 @@ type Queue struct {
|
|||||||
repeatMode RepeatMode
|
repeatMode RepeatMode
|
||||||
shuffleOrder []int
|
shuffleOrder []int
|
||||||
sourcePlaylistID int64
|
sourcePlaylistID int64
|
||||||
|
|
||||||
|
// setQueueGen is incremented each time SetQueue is called. Background
|
||||||
|
// goroutines check this to detect if they have been superseded.
|
||||||
|
setQueueGen atomic.Int64
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewQueue creates a new queue manager.
|
// NewQueue creates a new queue manager.
|
||||||
@@ -164,35 +183,59 @@ func (q *Queue) registerEventHandlers() {
|
|||||||
q.handlePlayNext(data...)
|
q.handlePlayNext(data...)
|
||||||
})
|
})
|
||||||
|
|
||||||
runtime.EventsOn(q.ctx, events.RequestRemoveFromQueue, func(data ...any) {
|
runtime.EventsOn(
|
||||||
q.logger.Info("Received RequestRemoveFromQueue")
|
q.ctx,
|
||||||
q.handleRemoveFromQueue(data...)
|
events.RequestRemoveFromQueue,
|
||||||
})
|
func(data ...any) {
|
||||||
|
q.logger.Info("Received RequestRemoveFromQueue")
|
||||||
|
q.handleRemoveFromQueue(data...)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
runtime.EventsOn(q.ctx, events.RequestToggleShuffle, func(_ ...any) {
|
runtime.EventsOn(
|
||||||
q.logger.Info("Received RequestToggleShuffle")
|
q.ctx,
|
||||||
q.ToggleShuffle()
|
events.RequestToggleShuffle,
|
||||||
})
|
func(_ ...any) {
|
||||||
|
q.logger.Info("Received RequestToggleShuffle")
|
||||||
|
q.ToggleShuffle()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
runtime.EventsOn(q.ctx, events.RequestCycleRepeat, func(_ ...any) {
|
runtime.EventsOn(
|
||||||
q.logger.Info("Received RequestCycleRepeat")
|
q.ctx,
|
||||||
q.CycleRepeat()
|
events.RequestCycleRepeat,
|
||||||
})
|
func(_ ...any) {
|
||||||
|
q.logger.Info("Received RequestCycleRepeat")
|
||||||
|
q.CycleRepeat()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
runtime.EventsOn(q.ctx, events.RequestAddTracksToQueue, func(data ...any) {
|
runtime.EventsOn(
|
||||||
q.logger.Info("Received RequestAddTracksToQueue")
|
q.ctx,
|
||||||
q.handleAddTracksToQueue(data...)
|
events.RequestAddTracksToQueue,
|
||||||
})
|
func(data ...any) {
|
||||||
|
q.logger.Info("Received RequestAddTracksToQueue")
|
||||||
|
q.handleAddTracksToQueue(data...)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
runtime.EventsOn(q.ctx, events.RequestPlayTracksNext, func(data ...any) {
|
runtime.EventsOn(
|
||||||
q.logger.Info("Received RequestPlayTracksNext")
|
q.ctx,
|
||||||
q.handlePlayTracksNext(data...)
|
events.RequestPlayTracksNext,
|
||||||
})
|
func(data ...any) {
|
||||||
|
q.logger.Info("Received RequestPlayTracksNext")
|
||||||
|
q.handlePlayTracksNext(data...)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
runtime.EventsOn(q.ctx, events.RequestPlayQueueIndex, func(data ...any) {
|
runtime.EventsOn(
|
||||||
q.logger.Info("Received RequestPlayQueueIndex")
|
q.ctx,
|
||||||
q.handlePlayQueueIndex(data...)
|
events.RequestPlayQueueIndex,
|
||||||
})
|
func(data ...any) {
|
||||||
|
q.logger.Info("Received RequestPlayQueueIndex")
|
||||||
|
q.handlePlayQueueIndex(data...)
|
||||||
|
},
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// handleSetQueue processes the RequestSetQueue event payload.
|
// handleSetQueue processes the RequestSetQueue event payload.
|
||||||
@@ -239,7 +282,10 @@ func (q *Queue) handleAddToQueue(data ...any) {
|
|||||||
|
|
||||||
filePath, ok := data[0].(string)
|
filePath, ok := data[0].(string)
|
||||||
if !ok {
|
if !ok {
|
||||||
q.logger.Error("RequestAddToQueue: invalid filePath type", "got", data[0])
|
q.logger.Error(
|
||||||
|
"RequestAddToQueue: invalid filePath type",
|
||||||
|
"got", data[0],
|
||||||
|
)
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -258,7 +304,10 @@ func (q *Queue) handlePlayNext(data ...any) {
|
|||||||
|
|
||||||
filePath, ok := data[0].(string)
|
filePath, ok := data[0].(string)
|
||||||
if !ok {
|
if !ok {
|
||||||
q.logger.Error("RequestPlayNext: invalid filePath type", "got", data[0])
|
q.logger.Error(
|
||||||
|
"RequestPlayNext: invalid filePath type",
|
||||||
|
"got", data[0],
|
||||||
|
)
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -277,7 +326,10 @@ func (q *Queue) handleRemoveFromQueue(data ...any) {
|
|||||||
|
|
||||||
position, ok := data[0].(float64)
|
position, ok := data[0].(float64)
|
||||||
if !ok {
|
if !ok {
|
||||||
q.logger.Error("RequestRemoveFromQueue: invalid position type", "got", data[0])
|
q.logger.Error(
|
||||||
|
"RequestRemoveFromQueue: invalid position type",
|
||||||
|
"got", data[0],
|
||||||
|
)
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -296,7 +348,10 @@ func (q *Queue) handleAddTracksToQueue(data ...any) {
|
|||||||
|
|
||||||
filePathsRaw, ok := data[0].([]interface{})
|
filePathsRaw, ok := data[0].([]interface{})
|
||||||
if !ok {
|
if !ok {
|
||||||
q.logger.Error("RequestAddTracksToQueue: invalid filePaths type", "got", data[0])
|
q.logger.Error(
|
||||||
|
"RequestAddTracksToQueue: invalid filePaths type",
|
||||||
|
"got", data[0],
|
||||||
|
)
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -323,7 +378,10 @@ func (q *Queue) handlePlayQueueIndex(data ...any) {
|
|||||||
|
|
||||||
index, ok := data[0].(float64)
|
index, ok := data[0].(float64)
|
||||||
if !ok {
|
if !ok {
|
||||||
q.logger.Error("RequestPlayQueueIndex: invalid index type", "got", data[0])
|
q.logger.Error(
|
||||||
|
"RequestPlayQueueIndex: invalid index type",
|
||||||
|
"got", data[0],
|
||||||
|
)
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -342,7 +400,10 @@ func (q *Queue) handlePlayTracksNext(data ...any) {
|
|||||||
|
|
||||||
filePathsRaw, ok := data[0].([]interface{})
|
filePathsRaw, ok := data[0].([]interface{})
|
||||||
if !ok {
|
if !ok {
|
||||||
q.logger.Error("RequestPlayTracksNext: invalid filePaths type", "got", data[0])
|
q.logger.Error(
|
||||||
|
"RequestPlayTracksNext: invalid filePaths type",
|
||||||
|
"got", data[0],
|
||||||
|
)
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -359,69 +420,141 @@ func (q *Queue) handlePlayTracksNext(data ...any) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// SetQueue replaces the entire queue with new tracks and starts playing.
|
// SetQueue replaces the entire queue with new tracks and starts playing.
|
||||||
|
// It uses a two-phase approach: the start track is resolved immediately so
|
||||||
|
// playback begins without delay, then the remaining tracks are resolved in
|
||||||
|
// the background. A generation counter ensures stale background work is
|
||||||
|
// discarded if SetQueue is called again before it finishes.
|
||||||
func (q *Queue) SetQueue(filePaths []string, startIndex int) {
|
func (q *Queue) SetQueue(filePaths []string, startIndex int) {
|
||||||
|
gen := q.setQueueGen.Add(1)
|
||||||
|
|
||||||
|
if startIndex < 0 || startIndex >= len(filePaths) {
|
||||||
|
startIndex = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// Phase 1: resolve only the start track so playback begins immediately.
|
||||||
|
startMeta := q.lookupTrackMetaBatch([]string{filePaths[startIndex]})
|
||||||
|
|
||||||
|
q.mu.Lock()
|
||||||
|
|
||||||
|
startTrackMeta, ok := startMeta[filePaths[startIndex]]
|
||||||
|
if !ok {
|
||||||
|
q.logger.Warn(
|
||||||
|
"Could not find start track in database",
|
||||||
|
"path", filePaths[startIndex],
|
||||||
|
)
|
||||||
|
q.mu.Unlock()
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build a placeholder slice with only the start track populated.
|
||||||
|
// Other slots will be filled by the background phase.
|
||||||
|
q.tracks = []Track{
|
||||||
|
{
|
||||||
|
AudioFileID: startTrackMeta.AudioFileID,
|
||||||
|
FilePath: startTrackMeta.FilePath,
|
||||||
|
Position: 0,
|
||||||
|
Title: startTrackMeta.Title,
|
||||||
|
Artist: startTrackMeta.Artist,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
q.currentIndex = 0
|
||||||
|
q.sourcePlaylistID = 0
|
||||||
|
q.shuffleOrder = nil
|
||||||
|
|
||||||
|
// Start playing immediately.
|
||||||
|
q.playCurrentTrack()
|
||||||
|
q.emitQueueChanged()
|
||||||
|
q.mu.Unlock()
|
||||||
|
|
||||||
|
// Phase 2: resolve remaining tracks in a background goroutine.
|
||||||
|
go q.resolveRemainingTracks(gen, filePaths, startIndex)
|
||||||
|
}
|
||||||
|
|
||||||
|
// resolveRemainingTracks runs in a goroutine to batch-resolve all tracks
|
||||||
|
// for a SetQueue call. It checks the generation counter before applying
|
||||||
|
// results to avoid overwriting a newer SetQueue call.
|
||||||
|
func (q *Queue) resolveRemainingTracks(
|
||||||
|
gen int64,
|
||||||
|
filePaths []string,
|
||||||
|
startIndex int,
|
||||||
|
) {
|
||||||
|
allMeta := q.lookupTrackMetaBatch(filePaths)
|
||||||
|
|
||||||
|
// Check if we have been superseded before acquiring the mutex.
|
||||||
|
if q.setQueueGen.Load() != gen {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
q.mu.Lock()
|
q.mu.Lock()
|
||||||
defer q.mu.Unlock()
|
defer q.mu.Unlock()
|
||||||
|
|
||||||
// Look up audio file IDs and metadata for all paths.
|
// Double-check under the lock.
|
||||||
|
if q.setQueueGen.Load() != gen {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
tracks := make([]Track, 0, len(filePaths))
|
tracks := make([]Track, 0, len(filePaths))
|
||||||
|
|
||||||
for i, fp := range filePaths {
|
for i, fp := range filePaths {
|
||||||
af, err := q.db.Queries.GetAudioFileByPath(q.db.Ctx, fp)
|
meta, found := allMeta[fp]
|
||||||
if err != nil {
|
if !found {
|
||||||
q.logger.Warn("Could not find audio file in database", "path", fp, "err", err)
|
q.logger.Warn(
|
||||||
|
"Could not find audio file in database",
|
||||||
|
"path", fp,
|
||||||
|
)
|
||||||
|
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
track := Track{
|
tracks = append(tracks, Track{
|
||||||
AudioFileID: af.ID,
|
AudioFileID: meta.AudioFileID,
|
||||||
FilePath: fp,
|
FilePath: meta.FilePath,
|
||||||
Position: int64(i),
|
Position: int64(i),
|
||||||
}
|
Title: meta.Title,
|
||||||
|
Artist: meta.Artist,
|
||||||
// Try to get metadata.
|
})
|
||||||
meta, metaErr := q.db.Queries.GetTrackMetadataByPath(q.db.Ctx, fp)
|
|
||||||
if metaErr == nil {
|
|
||||||
track.Title = meta.Title
|
|
||||||
track.Artist = meta.Artist
|
|
||||||
}
|
|
||||||
|
|
||||||
tracks = append(tracks, track)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
q.tracks = tracks
|
q.tracks = tracks
|
||||||
q.sourcePlaylistID = 0
|
|
||||||
|
|
||||||
if startIndex >= 0 && startIndex < len(q.tracks) {
|
// Recalculate startIndex: the original index might be shifted if
|
||||||
q.currentIndex = startIndex
|
// earlier tracks were missing from the database. Find the track that
|
||||||
} else {
|
// matches the originally requested start path.
|
||||||
q.currentIndex = 0
|
startPath := filePaths[startIndex]
|
||||||
|
q.currentIndex = 0
|
||||||
|
|
||||||
|
for i, t := range q.tracks {
|
||||||
|
if t.FilePath == startPath {
|
||||||
|
q.currentIndex = i
|
||||||
|
|
||||||
|
break
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Regenerate shuffle order if shuffle is on.
|
|
||||||
if q.shuffleMode {
|
if q.shuffleMode {
|
||||||
q.generateShuffleOrder()
|
q.generateShuffleOrder()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Persist to DB.
|
|
||||||
q.persistTracks()
|
q.persistTracks()
|
||||||
q.persistState()
|
q.persistState()
|
||||||
|
|
||||||
// Start playing the selected track.
|
|
||||||
q.playCurrentTrack()
|
|
||||||
q.emitQueueChanged()
|
q.emitQueueChanged()
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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 starts playing the added track immediately.
|
||||||
func (q *Queue) AddTrack(filePath string) {
|
func (q *Queue) AddTrack(filePath string) {
|
||||||
|
meta := q.lookupTrackMetaBatch([]string{filePath})
|
||||||
|
|
||||||
q.mu.Lock()
|
q.mu.Lock()
|
||||||
defer q.mu.Unlock()
|
defer q.mu.Unlock()
|
||||||
|
|
||||||
af, err := q.db.Queries.GetAudioFileByPath(q.db.Ctx, filePath)
|
m, ok := meta[filePath]
|
||||||
if err != nil {
|
if !ok {
|
||||||
q.logger.Error("Could not find audio file", "path", filePath, "err", err)
|
q.logger.Error(
|
||||||
|
"Could not find audio file",
|
||||||
|
"path", filePath,
|
||||||
|
)
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -429,25 +562,23 @@ func (q *Queue) AddTrack(filePath string) {
|
|||||||
wasEmpty := len(q.tracks) == 0
|
wasEmpty := len(q.tracks) == 0
|
||||||
|
|
||||||
track := Track{
|
track := Track{
|
||||||
AudioFileID: af.ID,
|
AudioFileID: m.AudioFileID,
|
||||||
FilePath: filePath,
|
FilePath: m.FilePath,
|
||||||
Position: int64(len(q.tracks)),
|
Position: int64(len(q.tracks)),
|
||||||
}
|
Title: m.Title,
|
||||||
|
Artist: m.Artist,
|
||||||
// Try to get metadata.
|
|
||||||
meta, metaErr := q.db.Queries.GetTrackMetadataByPath(q.db.Ctx, filePath)
|
|
||||||
if metaErr == nil {
|
|
||||||
track.Title = meta.Title
|
|
||||||
track.Artist = meta.Artist
|
|
||||||
}
|
}
|
||||||
|
|
||||||
q.tracks = append(q.tracks, track)
|
q.tracks = append(q.tracks, track)
|
||||||
|
|
||||||
// Persist.
|
// Persist.
|
||||||
_, insertErr := q.db.Queries.InsertQueueTrack(q.db.Ctx, sqlcgen.InsertQueueTrackParams{
|
_, insertErr := q.db.Queries.InsertQueueTrack(
|
||||||
AudioFileID: af.ID,
|
q.db.Ctx,
|
||||||
Position: track.Position,
|
sqlcgen.InsertQueueTrackParams{
|
||||||
})
|
AudioFileID: m.AudioFileID,
|
||||||
|
Position: track.Position,
|
||||||
|
},
|
||||||
|
)
|
||||||
if insertErr != nil {
|
if insertErr != nil {
|
||||||
q.logger.Error("Failed to persist queue track", "err", insertErr)
|
q.logger.Error("Failed to persist queue track", "err", insertErr)
|
||||||
}
|
}
|
||||||
@@ -470,29 +601,30 @@ 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 starts playing the first added track immediately.
|
||||||
func (q *Queue) AddTracks(filePaths []string) {
|
func (q *Queue) AddTracks(filePaths []string) {
|
||||||
|
allMeta := q.lookupTrackMetaBatch(filePaths)
|
||||||
|
|
||||||
q.mu.Lock()
|
q.mu.Lock()
|
||||||
defer q.mu.Unlock()
|
defer q.mu.Unlock()
|
||||||
|
|
||||||
wasEmpty := len(q.tracks) == 0
|
wasEmpty := len(q.tracks) == 0
|
||||||
|
|
||||||
for _, fp := range filePaths {
|
for _, fp := range filePaths {
|
||||||
af, err := q.db.Queries.GetAudioFileByPath(q.db.Ctx, fp)
|
m, ok := allMeta[fp]
|
||||||
if err != nil {
|
if !ok {
|
||||||
q.logger.Warn("Could not find audio file", "path", fp, "err", err)
|
q.logger.Warn(
|
||||||
|
"Could not find audio file",
|
||||||
|
"path", fp,
|
||||||
|
)
|
||||||
|
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
track := Track{
|
track := Track{
|
||||||
AudioFileID: af.ID,
|
AudioFileID: m.AudioFileID,
|
||||||
FilePath: fp,
|
FilePath: m.FilePath,
|
||||||
Position: int64(len(q.tracks)),
|
Position: int64(len(q.tracks)),
|
||||||
}
|
Title: m.Title,
|
||||||
|
Artist: m.Artist,
|
||||||
meta, metaErr := q.db.Queries.GetTrackMetadataByPath(q.db.Ctx, fp)
|
|
||||||
if metaErr == nil {
|
|
||||||
track.Title = meta.Title
|
|
||||||
track.Artist = meta.Artist
|
|
||||||
}
|
}
|
||||||
|
|
||||||
q.tracks = append(q.tracks, track)
|
q.tracks = append(q.tracks, track)
|
||||||
@@ -515,6 +647,8 @@ 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.
|
||||||
func (q *Queue) InsertNextTracks(filePaths []string) {
|
func (q *Queue) InsertNextTracks(filePaths []string) {
|
||||||
|
allMeta := q.lookupTrackMetaBatch(filePaths)
|
||||||
|
|
||||||
q.mu.Lock()
|
q.mu.Lock()
|
||||||
defer q.mu.Unlock()
|
defer q.mu.Unlock()
|
||||||
|
|
||||||
@@ -528,25 +662,22 @@ func (q *Queue) InsertNextTracks(filePaths []string) {
|
|||||||
var newTracks []Track
|
var newTracks []Track
|
||||||
|
|
||||||
for _, fp := range filePaths {
|
for _, fp := range filePaths {
|
||||||
af, err := q.db.Queries.GetAudioFileByPath(q.db.Ctx, fp)
|
m, ok := allMeta[fp]
|
||||||
if err != nil {
|
if !ok {
|
||||||
q.logger.Warn("Could not find audio file", "path", fp, "err", err)
|
q.logger.Warn(
|
||||||
|
"Could not find audio file",
|
||||||
|
"path", fp,
|
||||||
|
)
|
||||||
|
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
track := Track{
|
newTracks = append(newTracks, Track{
|
||||||
AudioFileID: af.ID,
|
AudioFileID: m.AudioFileID,
|
||||||
FilePath: fp,
|
FilePath: m.FilePath,
|
||||||
}
|
Title: m.Title,
|
||||||
|
Artist: m.Artist,
|
||||||
meta, metaErr := q.db.Queries.GetTrackMetadataByPath(q.db.Ctx, fp)
|
})
|
||||||
if metaErr == nil {
|
|
||||||
track.Title = meta.Title
|
|
||||||
track.Artist = meta.Artist
|
|
||||||
}
|
|
||||||
|
|
||||||
newTracks = append(newTracks, track)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(newTracks) == 0 {
|
if len(newTracks) == 0 {
|
||||||
@@ -578,12 +709,17 @@ func (q *Queue) InsertNextTracks(filePaths []string) {
|
|||||||
|
|
||||||
// InsertNext inserts a track right after the currently playing track.
|
// InsertNext inserts a track right after the currently playing track.
|
||||||
func (q *Queue) InsertNext(filePath string) {
|
func (q *Queue) InsertNext(filePath string) {
|
||||||
|
meta := q.lookupTrackMetaBatch([]string{filePath})
|
||||||
|
|
||||||
q.mu.Lock()
|
q.mu.Lock()
|
||||||
defer q.mu.Unlock()
|
defer q.mu.Unlock()
|
||||||
|
|
||||||
af, err := q.db.Queries.GetAudioFileByPath(q.db.Ctx, filePath)
|
m, ok := meta[filePath]
|
||||||
if err != nil {
|
if !ok {
|
||||||
q.logger.Error("Could not find audio file", "path", filePath, "err", err)
|
q.logger.Error(
|
||||||
|
"Could not find audio file",
|
||||||
|
"path", filePath,
|
||||||
|
)
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -594,16 +730,11 @@ func (q *Queue) InsertNext(filePath string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
track := Track{
|
track := Track{
|
||||||
AudioFileID: af.ID,
|
AudioFileID: m.AudioFileID,
|
||||||
FilePath: filePath,
|
FilePath: m.FilePath,
|
||||||
Position: int64(insertPos),
|
Position: int64(insertPos),
|
||||||
}
|
Title: m.Title,
|
||||||
|
Artist: m.Artist,
|
||||||
// Try to get metadata.
|
|
||||||
meta, metaErr := q.db.Queries.GetTrackMetadataByPath(q.db.Ctx, filePath)
|
|
||||||
if metaErr == nil {
|
|
||||||
track.Title = meta.Title
|
|
||||||
track.Artist = meta.Artist
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Insert into slice.
|
// Insert into slice.
|
||||||
@@ -630,7 +761,10 @@ func (q *Queue) RemoveTrack(position int) {
|
|||||||
defer q.mu.Unlock()
|
defer q.mu.Unlock()
|
||||||
|
|
||||||
if position < 0 || position >= len(q.tracks) {
|
if position < 0 || position >= len(q.tracks) {
|
||||||
q.logger.Warn("RemoveTrack: position out of range", "position", position)
|
q.logger.Warn(
|
||||||
|
"RemoveTrack: position out of range",
|
||||||
|
"position", position,
|
||||||
|
)
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -641,7 +775,8 @@ func (q *Queue) RemoveTrack(position int) {
|
|||||||
// is loaded, so only shift when a valid track is selected.
|
// is loaded, so only shift when a valid track is selected.
|
||||||
if q.currentIndex >= 0 && position < q.currentIndex {
|
if q.currentIndex >= 0 && position < q.currentIndex {
|
||||||
q.currentIndex--
|
q.currentIndex--
|
||||||
} else if position == q.currentIndex && q.currentIndex >= len(q.tracks) && len(q.tracks) > 0 {
|
} else if position == q.currentIndex &&
|
||||||
|
q.currentIndex >= len(q.tracks) && len(q.tracks) > 0 {
|
||||||
q.currentIndex = len(q.tracks) - 1
|
q.currentIndex = len(q.tracks) - 1
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -772,7 +907,10 @@ func (q *Queue) PlayIndex(index int) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if index < 0 || index >= len(q.tracks) {
|
if index < 0 || index >= len(q.tracks) {
|
||||||
q.logger.Warn("PlayIndex: index out of range", "index", index, "trackCount", len(q.tracks))
|
q.logger.Warn(
|
||||||
|
"PlayIndex: index out of range",
|
||||||
|
"index", index, "trackCount", len(q.tracks),
|
||||||
|
)
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -799,7 +937,7 @@ func (q *Queue) ToggleShuffle() {
|
|||||||
q.emitQueueChanged()
|
q.emitQueueChanged()
|
||||||
}
|
}
|
||||||
|
|
||||||
// CycleRepeat cycles through repeat modes: off → all → one → off.
|
// CycleRepeat cycles through repeat modes: off -> all -> one -> off.
|
||||||
func (q *Queue) CycleRepeat() {
|
func (q *Queue) CycleRepeat() {
|
||||||
q.mu.Lock()
|
q.mu.Lock()
|
||||||
defer q.mu.Unlock()
|
defer q.mu.Unlock()
|
||||||
@@ -883,7 +1021,9 @@ func (q *Queue) RestoreState() {
|
|||||||
if state.ShuffleOrder.Valid && state.ShuffleOrder.String != "" {
|
if state.ShuffleOrder.Valid && state.ShuffleOrder.String != "" {
|
||||||
var order []int
|
var order []int
|
||||||
|
|
||||||
if err := json.Unmarshal([]byte(state.ShuffleOrder.String), &order); err != nil {
|
if err := json.Unmarshal(
|
||||||
|
[]byte(state.ShuffleOrder.String), &order,
|
||||||
|
); err != nil {
|
||||||
q.logger.Warn("Failed to parse shuffle order", "err", err)
|
q.logger.Warn("Failed to parse shuffle order", "err", err)
|
||||||
} else {
|
} else {
|
||||||
q.shuffleOrder = order
|
q.shuffleOrder = order
|
||||||
@@ -1079,7 +1219,8 @@ func (q *Queue) loadCurrentTrack() bool {
|
|||||||
if q.currentIndex < 0 || q.currentIndex >= len(q.tracks) {
|
if q.currentIndex < 0 || q.currentIndex >= len(q.tracks) {
|
||||||
q.logger.Warn(
|
q.logger.Warn(
|
||||||
"Current index out of range",
|
"Current index out of range",
|
||||||
"index", q.currentIndex, "trackCount", len(q.tracks),
|
"index", q.currentIndex,
|
||||||
|
"trackCount", len(q.tracks),
|
||||||
)
|
)
|
||||||
|
|
||||||
return false
|
return false
|
||||||
@@ -1088,7 +1229,8 @@ func (q *Queue) loadCurrentTrack() bool {
|
|||||||
track := q.tracks[q.currentIndex]
|
track := q.tracks[q.currentIndex]
|
||||||
q.logger.Info(
|
q.logger.Info(
|
||||||
"Loading track from queue",
|
"Loading track from queue",
|
||||||
"filePath", track.FilePath, "position", q.currentIndex,
|
"filePath", track.FilePath,
|
||||||
|
"position", q.currentIndex,
|
||||||
)
|
)
|
||||||
|
|
||||||
err := q.player.LoadFile(track.FilePath)
|
err := q.player.LoadFile(track.FilePath)
|
||||||
@@ -1145,27 +1287,197 @@ func (q *Queue) reindexPositions() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// persistTracks writes the current queue tracks to the database.
|
// lookupTrackMetaBatch fetches audio file IDs and metadata for a batch of
|
||||||
// TODO: wrap in a transaction so the DELETE-all + INSERT-all is atomic.
|
// file paths using a single query per chunk (instead of 2 queries per track).
|
||||||
// Without a transaction a crash mid-write could leave queue_tracks empty or
|
// Returns a map keyed by file path. This is safe to call without holding q.mu.
|
||||||
// partially populated. Low practical risk but worth addressing for robustness.
|
func (q *Queue) lookupTrackMetaBatch(
|
||||||
func (q *Queue) persistTracks() {
|
filePaths []string,
|
||||||
err := q.db.Queries.ClearQueueTracks(q.db.Ctx)
|
) map[string]trackMeta {
|
||||||
|
result := make(map[string]trackMeta, len(filePaths))
|
||||||
|
|
||||||
|
// Deduplicate paths to avoid redundant work.
|
||||||
|
unique := make([]string, 0, len(filePaths))
|
||||||
|
seen := make(map[string]bool, len(filePaths))
|
||||||
|
|
||||||
|
for _, fp := range filePaths {
|
||||||
|
if !seen[fp] {
|
||||||
|
seen[fp] = true
|
||||||
|
|
||||||
|
unique = append(unique, fp)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Process in chunks to stay under the SQLite bind variable limit.
|
||||||
|
for i := 0; i < len(unique); i += maxSQLiteVars {
|
||||||
|
end := i + maxSQLiteVars
|
||||||
|
if end > len(unique) {
|
||||||
|
end = len(unique)
|
||||||
|
}
|
||||||
|
|
||||||
|
chunk := unique[i:end]
|
||||||
|
q.lookupChunk(chunk, result)
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
// lookupChunk executes a single batch query for a chunk of file paths.
|
||||||
|
func (q *Queue) lookupChunk(
|
||||||
|
paths []string,
|
||||||
|
result map[string]trackMeta,
|
||||||
|
) {
|
||||||
|
if len(paths) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
placeholders := make([]string, len(paths))
|
||||||
|
args := make([]any, len(paths))
|
||||||
|
|
||||||
|
for i, fp := range paths {
|
||||||
|
placeholders[i] = "?"
|
||||||
|
args[i] = fp
|
||||||
|
}
|
||||||
|
|
||||||
|
query := fmt.Sprintf(
|
||||||
|
`SELECT af.id, af.file_path,
|
||||||
|
COALESCE(r.name, '') AS title,
|
||||||
|
COALESCE(ac.text, '') AS artist
|
||||||
|
FROM audio_files af
|
||||||
|
LEFT JOIN recordings r ON af.recording_id = r.id
|
||||||
|
LEFT JOIN artist_credit ac ON r.artist_credit_id = ac.id
|
||||||
|
WHERE af.file_path IN (%s)`,
|
||||||
|
strings.Join(placeholders, ","),
|
||||||
|
)
|
||||||
|
|
||||||
|
rows, err := q.db.QueryContext(query, args...)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
q.logger.Error("Failed to clear queue tracks", "err", err)
|
q.logger.Error("Batch metadata lookup failed", "err", err)
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, track := range q.tracks {
|
defer func() {
|
||||||
_, err := q.db.Queries.InsertQueueTrack(q.db.Ctx, sqlcgen.InsertQueueTrackParams{
|
if closeErr := rows.Close(); closeErr != nil {
|
||||||
AudioFileID: track.AudioFileID,
|
q.logger.Error(
|
||||||
Position: track.Position,
|
"Failed to close rows",
|
||||||
})
|
"err", closeErr,
|
||||||
if err != nil {
|
)
|
||||||
q.logger.Error("Failed to insert queue track", "err", err)
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
for rows.Next() {
|
||||||
|
var m trackMeta
|
||||||
|
|
||||||
|
if scanErr := rows.Scan(
|
||||||
|
&m.AudioFileID, &m.FilePath, &m.Title, &m.Artist,
|
||||||
|
); scanErr != nil {
|
||||||
|
q.logger.Error(
|
||||||
|
"Failed to scan batch metadata row",
|
||||||
|
"err", scanErr,
|
||||||
|
)
|
||||||
|
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
result[m.FilePath] = m
|
||||||
|
}
|
||||||
|
|
||||||
|
if rowsErr := rows.Err(); rowsErr != nil {
|
||||||
|
q.logger.Error(
|
||||||
|
"Error iterating batch metadata rows",
|
||||||
|
"err", rowsErr,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// persistTracks writes the current queue tracks to the database atomically
|
||||||
|
// using a transaction with batched multi-row inserts.
|
||||||
|
func (q *Queue) persistTracks() {
|
||||||
|
tx, err := q.db.BeginTx()
|
||||||
|
if err != nil {
|
||||||
|
q.logger.Error("Failed to begin transaction", "err", err)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
committed := false
|
||||||
|
|
||||||
|
defer func() {
|
||||||
|
if !committed {
|
||||||
|
if rbErr := tx.Rollback(); rbErr != nil {
|
||||||
|
q.logger.Error(
|
||||||
|
"Failed to rollback transaction",
|
||||||
|
"err", rbErr,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
// Clear existing tracks.
|
||||||
|
txQueries := q.db.Queries.WithTx(tx)
|
||||||
|
|
||||||
|
if clearErr := txQueries.ClearQueueTracks(q.db.Ctx); clearErr != nil {
|
||||||
|
q.logger.Error("Failed to clear queue tracks", "err", clearErr)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Batch insert tracks. Each row needs 2 bind vars (audio_file_id, position).
|
||||||
|
const varsPerRow = 2
|
||||||
|
|
||||||
|
batchSize := maxSQLiteVars / varsPerRow
|
||||||
|
|
||||||
|
for i := 0; i < len(q.tracks); i += batchSize {
|
||||||
|
end := i + batchSize
|
||||||
|
if end > len(q.tracks) {
|
||||||
|
end = len(q.tracks)
|
||||||
|
}
|
||||||
|
|
||||||
|
batch := q.tracks[i:end]
|
||||||
|
|
||||||
|
if insertErr := q.insertTrackBatch(tx, batch); insertErr != nil {
|
||||||
|
q.logger.Error(
|
||||||
|
"Failed to batch insert queue tracks",
|
||||||
|
"err", insertErr,
|
||||||
|
)
|
||||||
|
|
||||||
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if commitErr := tx.Commit(); commitErr != nil {
|
||||||
|
q.logger.Error("Failed to commit transaction", "err", commitErr)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
committed = true
|
||||||
|
}
|
||||||
|
|
||||||
|
// insertTrackBatch inserts a batch of tracks in a single multi-row INSERT.
|
||||||
|
func (q *Queue) insertTrackBatch(tx *sql.Tx, batch []Track) error {
|
||||||
|
if len(batch) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
valuePlaceholders := make([]string, len(batch))
|
||||||
|
args := make([]any, 0, len(batch)*2)
|
||||||
|
|
||||||
|
for i, track := range batch {
|
||||||
|
valuePlaceholders[i] = "(?, ?)"
|
||||||
|
|
||||||
|
args = append(args, track.AudioFileID, track.Position)
|
||||||
|
}
|
||||||
|
|
||||||
|
query := "INSERT INTO queue_tracks (audio_file_id, position) VALUES " +
|
||||||
|
strings.Join(valuePlaceholders, ",")
|
||||||
|
|
||||||
|
_, err := tx.ExecContext(q.db.Ctx, query, args...)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("batch insert failed: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// persistState writes the queue metadata to the database.
|
// persistState writes the queue metadata to the database.
|
||||||
@@ -1175,24 +1487,36 @@ func (q *Queue) persistState() {
|
|||||||
if len(q.shuffleOrder) > 0 {
|
if len(q.shuffleOrder) > 0 {
|
||||||
data, err := json.Marshal(q.shuffleOrder)
|
data, err := json.Marshal(q.shuffleOrder)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
q.logger.Error("Failed to marshal shuffle order", "err", err)
|
q.logger.Error(
|
||||||
|
"Failed to marshal shuffle order",
|
||||||
|
"err", err,
|
||||||
|
)
|
||||||
} else {
|
} else {
|
||||||
shuffleOrderJSON = sql.NullString{String: string(data), Valid: true}
|
shuffleOrderJSON = sql.NullString{
|
||||||
|
String: string(data),
|
||||||
|
Valid: true,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
sourcePlaylistID := sql.NullInt64{}
|
sourcePlaylistID := sql.NullInt64{}
|
||||||
if q.sourcePlaylistID > 0 {
|
if q.sourcePlaylistID > 0 {
|
||||||
sourcePlaylistID = sql.NullInt64{Int64: q.sourcePlaylistID, Valid: true}
|
sourcePlaylistID = sql.NullInt64{
|
||||||
|
Int64: q.sourcePlaylistID,
|
||||||
|
Valid: true,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
err := q.db.Queries.UpdateQueueState(q.db.Ctx, sqlcgen.UpdateQueueStateParams{
|
err := q.db.Queries.UpdateQueueState(
|
||||||
SourcePlaylistID: sourcePlaylistID,
|
q.db.Ctx,
|
||||||
CurrentPosition: int64(q.currentIndex),
|
sqlcgen.UpdateQueueStateParams{
|
||||||
ShuffleMode: q.shuffleMode,
|
SourcePlaylistID: sourcePlaylistID,
|
||||||
RepeatMode: string(q.repeatMode),
|
CurrentPosition: int64(q.currentIndex),
|
||||||
ShuffleOrder: shuffleOrderJSON,
|
ShuffleMode: q.shuffleMode,
|
||||||
})
|
RepeatMode: string(q.repeatMode),
|
||||||
|
ShuffleOrder: shuffleOrderJSON,
|
||||||
|
},
|
||||||
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
q.logger.Error("Failed to persist queue state", "err", err)
|
q.logger.Error("Failed to persist queue state", "err", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,10 @@ import '@awesome.me/webawesome/dist/components/popup/popup.js';
|
|||||||
import { QueueController } from '@store/controllers/queue-controller';
|
import { QueueController } from '@store/controllers/queue-controller';
|
||||||
import '@components/playlist-picker/playlist-picker.js';
|
import '@components/playlist-picker/playlist-picker.js';
|
||||||
import type { PlaylistPicker } from '@components/playlist-picker/playlist-picker.js';
|
import type { PlaylistPicker } from '@components/playlist-picker/playlist-picker.js';
|
||||||
|
import '@lit-labs/virtualizer';
|
||||||
|
import type { LitVirtualizer } from '@lit-labs/virtualizer';
|
||||||
|
import { flow } from '@lit-labs/virtualizer/layouts/flow.js';
|
||||||
|
import type { QueueTrack } from '@store/queue-store';
|
||||||
|
|
||||||
const MIN_WIDTH = 200;
|
const MIN_WIDTH = 200;
|
||||||
const MAX_WIDTH = 500;
|
const MAX_WIDTH = 500;
|
||||||
@@ -26,6 +30,9 @@ export class QueuePanel extends LitElement {
|
|||||||
@query('#add-to-playlist-popup')
|
@query('#add-to-playlist-popup')
|
||||||
private addToPlaylistPopup!: HTMLElement;
|
private addToPlaylistPopup!: HTMLElement;
|
||||||
|
|
||||||
|
@query('lit-virtualizer')
|
||||||
|
private virtualizer!: LitVirtualizer;
|
||||||
|
|
||||||
private closePickerHandler = (e: MouseEvent) => {
|
private closePickerHandler = (e: MouseEvent) => {
|
||||||
const path = e.composedPath();
|
const path = e.composedPath();
|
||||||
const popup = this.addToPlaylistPopup;
|
const popup = this.addToPlaylistPopup;
|
||||||
@@ -37,6 +44,10 @@ export class QueuePanel extends LitElement {
|
|||||||
};
|
};
|
||||||
|
|
||||||
private panelWidth = DEFAULT_WIDTH;
|
private panelWidth = DEFAULT_WIDTH;
|
||||||
|
private flowLayout = flow();
|
||||||
|
|
||||||
|
/** Track the last currentIndex so we only auto-scroll on actual track changes. */
|
||||||
|
private lastScrolledIndex = -1;
|
||||||
|
|
||||||
static override styles = css`
|
static override styles = css`
|
||||||
:host {
|
:host {
|
||||||
@@ -116,12 +127,9 @@ export class QueuePanel extends LitElement {
|
|||||||
z-index: 210;
|
z-index: 210;
|
||||||
}
|
}
|
||||||
|
|
||||||
.track-list {
|
lit-virtualizer {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
padding: 0;
|
|
||||||
margin: 0;
|
|
||||||
list-style: none;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.track-item {
|
.track-item {
|
||||||
@@ -131,6 +139,8 @@ export class QueuePanel extends LitElement {
|
|||||||
gap: 12px;
|
gap: 12px;
|
||||||
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
|
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
width: 100%;
|
||||||
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
|
|
||||||
.track-item:hover {
|
.track-item:hover {
|
||||||
@@ -230,6 +240,22 @@ export class QueuePanel extends LitElement {
|
|||||||
document.removeEventListener('click', this.closePickerHandler);
|
document.removeEventListener('click', this.closePickerHandler);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override updated() {
|
||||||
|
const currentIndex = this.queue.currentIndex;
|
||||||
|
|
||||||
|
// Auto-scroll to the active track when it changes.
|
||||||
|
if (
|
||||||
|
currentIndex >= 0 &&
|
||||||
|
currentIndex !== this.lastScrolledIndex &&
|
||||||
|
this.virtualizer
|
||||||
|
) {
|
||||||
|
this.lastScrolledIndex = currentIndex;
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
this.virtualizer?.scrollToIndex(currentIndex, 'center');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private async handleAddToPlaylist() {
|
private async handleAddToPlaylist() {
|
||||||
if (this.queue.tracks.length === 0) return;
|
if (this.queue.tracks.length === 0) return;
|
||||||
|
|
||||||
@@ -316,9 +342,40 @@ export class QueuePanel extends LitElement {
|
|||||||
this.isDragging = false;
|
this.isDragging = false;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
private renderTrackItem = (
|
||||||
|
track: QueueTrack,
|
||||||
|
index: number,
|
||||||
|
) => {
|
||||||
|
const currentIndex = this.queue.currentIndex;
|
||||||
|
|
||||||
|
return html`
|
||||||
|
<div
|
||||||
|
class="track-item ${index === currentIndex ? 'active' : ''}"
|
||||||
|
@click=${() => this.handleTrackClick(index)}
|
||||||
|
>
|
||||||
|
<span class="track-position">${index + 1}</span>
|
||||||
|
<div class="track-details">
|
||||||
|
<span class="track-title">
|
||||||
|
${this.getDisplayTitle(track)}
|
||||||
|
</span>
|
||||||
|
<span class="track-artist">
|
||||||
|
${track.artist || 'Unknown Artist'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
class="remove-button"
|
||||||
|
@click=${(e: Event) =>
|
||||||
|
this.handleRemoveTrack(e, index)}
|
||||||
|
title="Remove from queue"
|
||||||
|
>
|
||||||
|
<wa-icon name="xmark"></wa-icon>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
};
|
||||||
|
|
||||||
override render() {
|
override render() {
|
||||||
const tracks = this.queue.tracks;
|
const tracks = this.queue.tracks;
|
||||||
const currentIndex = this.queue.currentIndex;
|
|
||||||
|
|
||||||
return html`
|
return html`
|
||||||
<div class="panel-content">
|
<div class="panel-content">
|
||||||
@@ -365,36 +422,12 @@ export class QueuePanel extends LitElement {
|
|||||||
</div>
|
</div>
|
||||||
`
|
`
|
||||||
: html`
|
: html`
|
||||||
<ul class="track-list">
|
<lit-virtualizer
|
||||||
${tracks.map(
|
scroller
|
||||||
(track, index) => html`
|
.items=${tracks}
|
||||||
<li
|
.renderItem=${this.renderTrackItem}
|
||||||
class="track-item ${index === currentIndex
|
.layout=${this.flowLayout}
|
||||||
? 'active'
|
></lit-virtualizer>
|
||||||
: ''}"
|
|
||||||
@click=${() => this.handleTrackClick(index)}
|
|
||||||
>
|
|
||||||
<span class="track-position">${index + 1}</span>
|
|
||||||
<div class="track-details">
|
|
||||||
<span class="track-title">
|
|
||||||
${this.getDisplayTitle(track)}
|
|
||||||
</span>
|
|
||||||
<span class="track-artist">
|
|
||||||
${track.artist || 'Unknown Artist'}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
class="remove-button"
|
|
||||||
@click=${(e: Event) =>
|
|
||||||
this.handleRemoveTrack(e, index)}
|
|
||||||
title="Remove from queue"
|
|
||||||
>
|
|
||||||
<wa-icon name="xmark"></wa-icon>
|
|
||||||
</button>
|
|
||||||
</li>
|
|
||||||
`,
|
|
||||||
)}
|
|
||||||
</ul>
|
|
||||||
`}
|
`}
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
|
|||||||
Reference in New Issue
Block a user