virtualization in queue list, queue db queries optimized for large queue requests

This commit is contained in:
2026-02-17 19:54:24 -05:00
parent 1a1ac7d9a1
commit be6f4e9764
3 changed files with 549 additions and 177 deletions
+15
View File
@@ -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
View File
@@ -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>
`; `;