1552 lines
33 KiB
Go
1552 lines
33 KiB
Go
// Package queue manages the playback queue and auto-advance logic.
|
|
package queue
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"log/slog"
|
|
"math/rand/v2"
|
|
"strings"
|
|
"sync"
|
|
"sync/atomic"
|
|
|
|
"github.com/wailsapp/wails/v2/pkg/runtime"
|
|
|
|
"yellowjacket/backend/database"
|
|
"yellowjacket/backend/database/sql/sqlcgen"
|
|
"yellowjacket/backend/events"
|
|
)
|
|
|
|
// RepeatMode represents the queue repeat behavior.
|
|
type RepeatMode string
|
|
|
|
// Repeat mode values.
|
|
const (
|
|
RepeatOff RepeatMode = "off"
|
|
RepeatAll RepeatMode = "all"
|
|
RepeatOne RepeatMode = "one"
|
|
)
|
|
|
|
// PreviousRestartThreshold is the number of seconds into a track before
|
|
// "Previous" restarts the current track instead of going to the prior one.
|
|
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.
|
|
type TrackLoader interface {
|
|
LoadFile(filePath string) error
|
|
Play() error
|
|
IsPlaying() bool
|
|
CurrentPositionSeconds() (int, error)
|
|
UnloadTrack()
|
|
}
|
|
|
|
// Track represents a track in the queue with its metadata.
|
|
type Track struct {
|
|
ID int64 `json:"id"`
|
|
AudioFileID int64 `json:"audioFileId"`
|
|
FilePath string `json:"filePath"`
|
|
Position int64 `json:"position"`
|
|
Title string `json:"title"`
|
|
Artist string `json:"artist"`
|
|
}
|
|
|
|
// State is the full state emitted to the frontend.
|
|
type State struct {
|
|
Tracks []Track `json:"tracks"`
|
|
CurrentIndex int `json:"currentIndex"`
|
|
ShuffleMode bool `json:"shuffleMode"`
|
|
RepeatMode RepeatMode `json:"repeatMode"`
|
|
SourcePlaylistID int64 `json:"sourcePlaylistId"`
|
|
}
|
|
|
|
// Queue manages an ordered list of tracks for playback.
|
|
type Queue struct {
|
|
ctx context.Context
|
|
logger *slog.Logger
|
|
db *database.DB
|
|
player TrackLoader
|
|
|
|
mu sync.Mutex
|
|
tracks []Track
|
|
currentIndex int
|
|
shuffleMode bool
|
|
repeatMode RepeatMode
|
|
shuffleOrder []int
|
|
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.
|
|
func NewQueue(logger *slog.Logger, db *database.DB) *Queue {
|
|
return &Queue{
|
|
logger: logger.WithGroup("queue"),
|
|
db: db,
|
|
repeatMode: RepeatOff,
|
|
}
|
|
}
|
|
|
|
// SetContext sets the Wails runtime context and registers event handlers.
|
|
func (q *Queue) SetContext(ctx context.Context) {
|
|
q.ctx = ctx
|
|
q.registerEventHandlers()
|
|
}
|
|
|
|
// SetPlayer provides the queue with a reference to the player for auto-advance.
|
|
func (q *Queue) SetPlayer(player TrackLoader) {
|
|
q.player = player
|
|
}
|
|
|
|
// OnPlaybackFinished is called when a track finishes playing naturally.
|
|
// This drives the auto-advance behavior.
|
|
func (q *Queue) OnPlaybackFinished() {
|
|
q.mu.Lock()
|
|
defer q.mu.Unlock()
|
|
|
|
if len(q.tracks) == 0 {
|
|
return
|
|
}
|
|
|
|
// Repeat One: replay the current track.
|
|
if q.repeatMode == RepeatOne {
|
|
q.playCurrentTrack()
|
|
q.emitQueueChanged()
|
|
|
|
return
|
|
}
|
|
|
|
nextIdx := q.nextIndex()
|
|
if nextIdx == -1 {
|
|
// Queue exhausted — this is the extension point for a future fallback playlist.
|
|
q.onQueueExhausted()
|
|
|
|
return
|
|
}
|
|
|
|
q.currentIndex = nextIdx
|
|
q.playCurrentTrack()
|
|
q.emitQueueChanged()
|
|
}
|
|
|
|
// registerEventHandlers sets up Wails event listeners for queue commands.
|
|
func (q *Queue) registerEventHandlers() {
|
|
if q.ctx == nil {
|
|
q.logger.Error("Context is nil, cannot register event handlers")
|
|
|
|
return
|
|
}
|
|
|
|
runtime.EventsOn(q.ctx, events.RequestPlay, func(_ ...any) {
|
|
q.logger.Info("Received RequestPlay")
|
|
q.PlayFromStart()
|
|
})
|
|
|
|
runtime.EventsOn(q.ctx, events.RequestNext, func(_ ...any) {
|
|
q.logger.Info("Received RequestNext")
|
|
q.Next()
|
|
})
|
|
|
|
runtime.EventsOn(q.ctx, events.RequestPrevious, func(_ ...any) {
|
|
q.logger.Info("Received RequestPrevious")
|
|
q.Previous()
|
|
})
|
|
|
|
runtime.EventsOn(q.ctx, events.RequestSetQueue, func(data ...any) {
|
|
q.logger.Info("Received RequestSetQueue")
|
|
q.handleSetQueue(data...)
|
|
})
|
|
|
|
runtime.EventsOn(q.ctx, events.RequestAddToQueue, func(data ...any) {
|
|
q.logger.Info("Received RequestAddToQueue")
|
|
q.handleAddToQueue(data...)
|
|
})
|
|
|
|
runtime.EventsOn(q.ctx, events.RequestPlayNext, func(data ...any) {
|
|
q.logger.Info("Received RequestPlayNext")
|
|
q.handlePlayNext(data...)
|
|
})
|
|
|
|
runtime.EventsOn(
|
|
q.ctx,
|
|
events.RequestRemoveFromQueue,
|
|
func(data ...any) {
|
|
q.logger.Info("Received RequestRemoveFromQueue")
|
|
q.handleRemoveFromQueue(data...)
|
|
},
|
|
)
|
|
|
|
runtime.EventsOn(
|
|
q.ctx,
|
|
events.RequestToggleShuffle,
|
|
func(_ ...any) {
|
|
q.logger.Info("Received RequestToggleShuffle")
|
|
q.ToggleShuffle()
|
|
},
|
|
)
|
|
|
|
runtime.EventsOn(
|
|
q.ctx,
|
|
events.RequestCycleRepeat,
|
|
func(_ ...any) {
|
|
q.logger.Info("Received RequestCycleRepeat")
|
|
q.CycleRepeat()
|
|
},
|
|
)
|
|
|
|
runtime.EventsOn(
|
|
q.ctx,
|
|
events.RequestAddTracksToQueue,
|
|
func(data ...any) {
|
|
q.logger.Info("Received RequestAddTracksToQueue")
|
|
q.handleAddTracksToQueue(data...)
|
|
},
|
|
)
|
|
|
|
runtime.EventsOn(
|
|
q.ctx,
|
|
events.RequestPlayTracksNext,
|
|
func(data ...any) {
|
|
q.logger.Info("Received RequestPlayTracksNext")
|
|
q.handlePlayTracksNext(data...)
|
|
},
|
|
)
|
|
|
|
runtime.EventsOn(
|
|
q.ctx,
|
|
events.RequestPlayQueueIndex,
|
|
func(data ...any) {
|
|
q.logger.Info("Received RequestPlayQueueIndex")
|
|
q.handlePlayQueueIndex(data...)
|
|
},
|
|
)
|
|
}
|
|
|
|
// handleSetQueue processes the RequestSetQueue event payload.
|
|
// Expects data[0] = []interface{} of file path strings, data[1] = float64 start index.
|
|
func (q *Queue) handleSetQueue(data ...any) {
|
|
if len(data) < 2 {
|
|
q.logger.Error("RequestSetQueue: missing data")
|
|
|
|
return
|
|
}
|
|
|
|
filePathsRaw, ok := data[0].([]interface{})
|
|
if !ok {
|
|
q.logger.Error("RequestSetQueue: invalid filePaths type")
|
|
|
|
return
|
|
}
|
|
|
|
filePaths := make([]string, 0, len(filePathsRaw))
|
|
|
|
for _, fp := range filePathsRaw {
|
|
if s, ok := fp.(string); ok {
|
|
filePaths = append(filePaths, s)
|
|
}
|
|
}
|
|
|
|
startIndex := 0
|
|
|
|
if si, ok := data[1].(float64); ok {
|
|
startIndex = int(si)
|
|
}
|
|
|
|
q.SetQueue(filePaths, startIndex)
|
|
}
|
|
|
|
// handleAddToQueue processes the RequestAddToQueue event payload.
|
|
// Expects data[0] = string file path.
|
|
func (q *Queue) handleAddToQueue(data ...any) {
|
|
if len(data) < 1 {
|
|
q.logger.Error("RequestAddToQueue: missing data")
|
|
|
|
return
|
|
}
|
|
|
|
filePath, ok := data[0].(string)
|
|
if !ok {
|
|
q.logger.Error(
|
|
"RequestAddToQueue: invalid filePath type",
|
|
"got", data[0],
|
|
)
|
|
|
|
return
|
|
}
|
|
|
|
q.AddTrack(filePath)
|
|
}
|
|
|
|
// handlePlayNext processes the RequestPlayNext event payload.
|
|
// Expects data[0] = string file path.
|
|
func (q *Queue) handlePlayNext(data ...any) {
|
|
if len(data) < 1 {
|
|
q.logger.Error("RequestPlayNext: missing data")
|
|
|
|
return
|
|
}
|
|
|
|
filePath, ok := data[0].(string)
|
|
if !ok {
|
|
q.logger.Error(
|
|
"RequestPlayNext: invalid filePath type",
|
|
"got", data[0],
|
|
)
|
|
|
|
return
|
|
}
|
|
|
|
q.InsertNext(filePath)
|
|
}
|
|
|
|
// handleRemoveFromQueue processes the RequestRemoveFromQueue event payload.
|
|
// Expects data[0] = float64 position.
|
|
func (q *Queue) handleRemoveFromQueue(data ...any) {
|
|
if len(data) < 1 {
|
|
q.logger.Error("RequestRemoveFromQueue: missing data")
|
|
|
|
return
|
|
}
|
|
|
|
position, ok := data[0].(float64)
|
|
if !ok {
|
|
q.logger.Error(
|
|
"RequestRemoveFromQueue: invalid position type",
|
|
"got", data[0],
|
|
)
|
|
|
|
return
|
|
}
|
|
|
|
q.RemoveTrack(int(position))
|
|
}
|
|
|
|
// handleAddTracksToQueue processes the RequestAddTracksToQueue event payload.
|
|
// Expects data[0] = []interface{} of file path strings.
|
|
func (q *Queue) handleAddTracksToQueue(data ...any) {
|
|
if len(data) < 1 {
|
|
q.logger.Error("RequestAddTracksToQueue: missing data")
|
|
|
|
return
|
|
}
|
|
|
|
filePathsRaw, ok := data[0].([]interface{})
|
|
if !ok {
|
|
q.logger.Error(
|
|
"RequestAddTracksToQueue: 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)
|
|
}
|
|
}
|
|
|
|
q.AddTracks(filePaths)
|
|
}
|
|
|
|
// handlePlayQueueIndex processes the RequestPlayQueueIndex event payload.
|
|
// Expects data[0] = float64 index.
|
|
func (q *Queue) handlePlayQueueIndex(data ...any) {
|
|
if len(data) < 1 {
|
|
q.logger.Error("RequestPlayQueueIndex: missing data")
|
|
|
|
return
|
|
}
|
|
|
|
index, ok := data[0].(float64)
|
|
if !ok {
|
|
q.logger.Error(
|
|
"RequestPlayQueueIndex: invalid index type",
|
|
"got", data[0],
|
|
)
|
|
|
|
return
|
|
}
|
|
|
|
q.PlayIndex(int(index))
|
|
}
|
|
|
|
// handlePlayTracksNext processes the RequestPlayTracksNext event payload.
|
|
// Expects data[0] = []interface{} of file path strings.
|
|
func (q *Queue) handlePlayTracksNext(data ...any) {
|
|
if len(data) < 1 {
|
|
q.logger.Error("RequestPlayTracksNext: missing data")
|
|
|
|
return
|
|
}
|
|
|
|
filePathsRaw, ok := data[0].([]interface{})
|
|
if !ok {
|
|
q.logger.Error(
|
|
"RequestPlayTracksNext: 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)
|
|
}
|
|
}
|
|
|
|
q.InsertNextTracks(filePaths)
|
|
}
|
|
|
|
// 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) {
|
|
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()
|
|
defer q.mu.Unlock()
|
|
|
|
// Double-check under the lock.
|
|
if q.setQueueGen.Load() != gen {
|
|
return
|
|
}
|
|
|
|
tracks := make([]Track, 0, len(filePaths))
|
|
|
|
for i, fp := range filePaths {
|
|
meta, found := allMeta[fp]
|
|
if !found {
|
|
q.logger.Warn(
|
|
"Could not find audio file in database",
|
|
"path", fp,
|
|
)
|
|
|
|
continue
|
|
}
|
|
|
|
tracks = append(tracks, Track{
|
|
AudioFileID: meta.AudioFileID,
|
|
FilePath: meta.FilePath,
|
|
Position: int64(i),
|
|
Title: meta.Title,
|
|
Artist: meta.Artist,
|
|
})
|
|
}
|
|
|
|
q.tracks = tracks
|
|
|
|
// Recalculate startIndex: the original index might be shifted if
|
|
// earlier tracks were missing from the database. Find the track that
|
|
// matches the originally requested start path.
|
|
startPath := filePaths[startIndex]
|
|
q.currentIndex = 0
|
|
|
|
for i, t := range q.tracks {
|
|
if t.FilePath == startPath {
|
|
q.currentIndex = i
|
|
|
|
break
|
|
}
|
|
}
|
|
|
|
if q.shuffleMode {
|
|
q.generateShuffleOrder()
|
|
}
|
|
|
|
q.persistTracks()
|
|
q.persistState()
|
|
q.emitQueueChanged()
|
|
}
|
|
|
|
// AddTrack appends a track to the end of the queue.
|
|
// If the queue was empty, it starts playing the added track immediately.
|
|
func (q *Queue) AddTrack(filePath string) {
|
|
meta := q.lookupTrackMetaBatch([]string{filePath})
|
|
|
|
q.mu.Lock()
|
|
defer q.mu.Unlock()
|
|
|
|
m, ok := meta[filePath]
|
|
if !ok {
|
|
q.logger.Error(
|
|
"Could not find audio file",
|
|
"path", filePath,
|
|
)
|
|
|
|
return
|
|
}
|
|
|
|
wasEmpty := len(q.tracks) == 0
|
|
|
|
track := Track{
|
|
AudioFileID: m.AudioFileID,
|
|
FilePath: m.FilePath,
|
|
Position: int64(len(q.tracks)),
|
|
Title: m.Title,
|
|
Artist: m.Artist,
|
|
}
|
|
|
|
q.tracks = append(q.tracks, track)
|
|
|
|
// Persist.
|
|
_, insertErr := q.db.Queries.InsertQueueTrack(
|
|
q.db.Ctx,
|
|
sqlcgen.InsertQueueTrackParams{
|
|
AudioFileID: m.AudioFileID,
|
|
Position: track.Position,
|
|
},
|
|
)
|
|
if insertErr != nil {
|
|
q.logger.Error("Failed to persist queue track", "err", insertErr)
|
|
}
|
|
|
|
// Update shuffle order if shuffle is on.
|
|
if q.shuffleMode {
|
|
q.shuffleOrder = append(q.shuffleOrder, len(q.tracks)-1)
|
|
}
|
|
|
|
// Auto-play if this is the first track added to an empty queue.
|
|
if wasEmpty {
|
|
q.currentIndex = 0
|
|
q.playCurrentTrack()
|
|
}
|
|
|
|
q.persistState()
|
|
q.emitQueueChanged()
|
|
}
|
|
|
|
// AddTracks appends multiple tracks to the end of the queue.
|
|
// If the queue was empty, it starts playing the first added track immediately.
|
|
func (q *Queue) AddTracks(filePaths []string) {
|
|
allMeta := q.lookupTrackMetaBatch(filePaths)
|
|
|
|
q.mu.Lock()
|
|
defer q.mu.Unlock()
|
|
|
|
wasEmpty := len(q.tracks) == 0
|
|
|
|
for _, fp := range filePaths {
|
|
m, ok := allMeta[fp]
|
|
if !ok {
|
|
q.logger.Warn(
|
|
"Could not find audio file",
|
|
"path", fp,
|
|
)
|
|
|
|
continue
|
|
}
|
|
|
|
track := Track{
|
|
AudioFileID: m.AudioFileID,
|
|
FilePath: m.FilePath,
|
|
Position: int64(len(q.tracks)),
|
|
Title: m.Title,
|
|
Artist: m.Artist,
|
|
}
|
|
|
|
q.tracks = append(q.tracks, track)
|
|
}
|
|
|
|
if q.shuffleMode {
|
|
q.generateShuffleOrder()
|
|
}
|
|
|
|
q.persistTracks()
|
|
q.persistState()
|
|
|
|
if wasEmpty && len(q.tracks) > 0 {
|
|
q.currentIndex = 0
|
|
q.playCurrentTrack()
|
|
}
|
|
|
|
q.emitQueueChanged()
|
|
}
|
|
|
|
// InsertNextTracks inserts multiple tracks as a contiguous block after the current track.
|
|
func (q *Queue) InsertNextTracks(filePaths []string) {
|
|
allMeta := q.lookupTrackMetaBatch(filePaths)
|
|
|
|
q.mu.Lock()
|
|
defer q.mu.Unlock()
|
|
|
|
insertPos := q.currentIndex + 1
|
|
if insertPos > len(q.tracks) {
|
|
insertPos = len(q.tracks)
|
|
}
|
|
|
|
wasEmpty := len(q.tracks) == 0
|
|
|
|
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 insertPos.
|
|
tail := make([]Track, len(q.tracks[insertPos:]))
|
|
copy(tail, q.tracks[insertPos:])
|
|
q.tracks = append(q.tracks[:insertPos], newTracks...)
|
|
q.tracks = append(q.tracks, tail...)
|
|
|
|
q.reindexPositions()
|
|
|
|
if q.shuffleMode {
|
|
q.generateShuffleOrder()
|
|
}
|
|
|
|
q.persistTracks()
|
|
q.persistState()
|
|
|
|
if wasEmpty {
|
|
q.currentIndex = 0
|
|
q.playCurrentTrack()
|
|
}
|
|
|
|
q.emitQueueChanged()
|
|
}
|
|
|
|
// InsertNext inserts a track right after the currently playing track.
|
|
func (q *Queue) InsertNext(filePath string) {
|
|
meta := q.lookupTrackMetaBatch([]string{filePath})
|
|
|
|
q.mu.Lock()
|
|
defer q.mu.Unlock()
|
|
|
|
m, ok := meta[filePath]
|
|
if !ok {
|
|
q.logger.Error(
|
|
"Could not find audio file",
|
|
"path", filePath,
|
|
)
|
|
|
|
return
|
|
}
|
|
|
|
insertPos := q.currentIndex + 1
|
|
if insertPos > len(q.tracks) {
|
|
insertPos = len(q.tracks)
|
|
}
|
|
|
|
track := Track{
|
|
AudioFileID: m.AudioFileID,
|
|
FilePath: m.FilePath,
|
|
Position: int64(insertPos),
|
|
Title: m.Title,
|
|
Artist: m.Artist,
|
|
}
|
|
|
|
// Insert into slice.
|
|
q.tracks = append(q.tracks, Track{})
|
|
copy(q.tracks[insertPos+1:], q.tracks[insertPos:])
|
|
q.tracks[insertPos] = track
|
|
|
|
// Reindex positions.
|
|
q.reindexPositions()
|
|
|
|
// Regenerate shuffle order if needed.
|
|
if q.shuffleMode {
|
|
q.generateShuffleOrder()
|
|
}
|
|
|
|
q.persistTracks()
|
|
q.persistState()
|
|
q.emitQueueChanged()
|
|
}
|
|
|
|
// RemoveTrack removes a track at the given position from the queue.
|
|
func (q *Queue) RemoveTrack(position int) {
|
|
q.mu.Lock()
|
|
defer q.mu.Unlock()
|
|
|
|
if position < 0 || position >= len(q.tracks) {
|
|
q.logger.Warn(
|
|
"RemoveTrack: position out of range",
|
|
"position", position,
|
|
)
|
|
|
|
return
|
|
}
|
|
|
|
q.tracks = append(q.tracks[:position], q.tracks[position+1:]...)
|
|
|
|
// Adjust current index if needed. A currentIndex of -1 means no track
|
|
// is loaded, so only shift when a valid track is selected.
|
|
if q.currentIndex >= 0 && position < q.currentIndex {
|
|
q.currentIndex--
|
|
} else if position == q.currentIndex &&
|
|
q.currentIndex >= len(q.tracks) && len(q.tracks) > 0 {
|
|
q.currentIndex = len(q.tracks) - 1
|
|
}
|
|
|
|
q.reindexPositions()
|
|
|
|
if q.shuffleMode {
|
|
q.generateShuffleOrder()
|
|
}
|
|
|
|
q.persistTracks()
|
|
q.persistState()
|
|
q.emitQueueChanged()
|
|
}
|
|
|
|
// Next advances to the next track. If the player was paused, the next
|
|
// track is loaded but not played. In RepeatOne mode, the current track
|
|
// is replayed instead of advancing.
|
|
func (q *Queue) Next() {
|
|
q.mu.Lock()
|
|
defer q.mu.Unlock()
|
|
|
|
if len(q.tracks) == 0 {
|
|
return
|
|
}
|
|
|
|
wasPlaying := q.player != nil && q.player.IsPlaying()
|
|
|
|
// Repeat One: replay the current track.
|
|
if q.repeatMode == RepeatOne {
|
|
q.playOrLoadCurrentTrack(wasPlaying)
|
|
q.emitQueueChanged()
|
|
|
|
return
|
|
}
|
|
|
|
nextIdx := q.nextIndex()
|
|
if nextIdx == -1 {
|
|
q.onQueueExhausted()
|
|
|
|
return
|
|
}
|
|
|
|
q.currentIndex = nextIdx
|
|
q.playOrLoadCurrentTrack(wasPlaying)
|
|
q.emitQueueChanged()
|
|
}
|
|
|
|
// Previous goes to the previous track (or restarts current if >3s in).
|
|
// If the player was paused, the track is loaded but not played.
|
|
// In RepeatOne mode, the current track is replayed instead of navigating.
|
|
func (q *Queue) Previous() {
|
|
q.mu.Lock()
|
|
defer q.mu.Unlock()
|
|
|
|
if len(q.tracks) == 0 || q.currentIndex < 0 {
|
|
return
|
|
}
|
|
|
|
wasPlaying := q.player != nil && q.player.IsPlaying()
|
|
|
|
// Repeat One: replay the current track.
|
|
if q.repeatMode == RepeatOne {
|
|
q.playOrLoadCurrentTrack(wasPlaying)
|
|
q.emitQueueChanged()
|
|
|
|
return
|
|
}
|
|
|
|
// If more than 3 seconds into the track, restart it.
|
|
if q.player != nil {
|
|
posSecs, err := q.player.CurrentPositionSeconds()
|
|
if err == nil && posSecs > PreviousRestartThreshold {
|
|
q.playOrLoadCurrentTrack(wasPlaying)
|
|
q.emitQueueChanged()
|
|
|
|
return
|
|
}
|
|
}
|
|
|
|
prevIdx := q.previousIndex()
|
|
if prevIdx == -1 {
|
|
// At the beginning — just restart the current track.
|
|
q.playOrLoadCurrentTrack(wasPlaying)
|
|
q.emitQueueChanged()
|
|
|
|
return
|
|
}
|
|
|
|
q.currentIndex = prevIdx
|
|
q.playOrLoadCurrentTrack(wasPlaying)
|
|
q.emitQueueChanged()
|
|
}
|
|
|
|
// PlayFromStart restarts playback from the beginning of the queue.
|
|
// If shuffle is enabled, a new shuffle order is generated and playback
|
|
// starts from a random track. This is a no-op when a track is already
|
|
// active (currentIndex != -1) or the queue is empty.
|
|
func (q *Queue) PlayFromStart() {
|
|
q.mu.Lock()
|
|
defer q.mu.Unlock()
|
|
|
|
if q.currentIndex != -1 {
|
|
return
|
|
}
|
|
|
|
if len(q.tracks) == 0 {
|
|
return
|
|
}
|
|
|
|
if q.shuffleMode {
|
|
q.generateShuffleOrder()
|
|
q.currentIndex = q.shuffleOrder[0]
|
|
} else {
|
|
q.currentIndex = 0
|
|
}
|
|
|
|
q.playCurrentTrack()
|
|
q.emitQueueChanged()
|
|
}
|
|
|
|
// PlayIndex jumps to and plays the track at the given index.
|
|
func (q *Queue) PlayIndex(index int) {
|
|
q.mu.Lock()
|
|
defer q.mu.Unlock()
|
|
|
|
if len(q.tracks) == 0 {
|
|
return
|
|
}
|
|
|
|
if index < 0 || index >= len(q.tracks) {
|
|
q.logger.Warn(
|
|
"PlayIndex: index out of range",
|
|
"index", index, "trackCount", len(q.tracks),
|
|
)
|
|
|
|
return
|
|
}
|
|
|
|
q.currentIndex = index
|
|
q.playCurrentTrack()
|
|
q.emitQueueChanged()
|
|
}
|
|
|
|
// ToggleShuffle toggles shuffle mode on/off.
|
|
func (q *Queue) ToggleShuffle() {
|
|
q.mu.Lock()
|
|
defer q.mu.Unlock()
|
|
|
|
q.shuffleMode = !q.shuffleMode
|
|
|
|
if q.shuffleMode {
|
|
q.generateShuffleOrder()
|
|
} else {
|
|
q.shuffleOrder = nil
|
|
}
|
|
|
|
q.persistState()
|
|
q.emitQueueChanged()
|
|
}
|
|
|
|
// CycleRepeat cycles through repeat modes: off -> all -> one -> off.
|
|
func (q *Queue) CycleRepeat() {
|
|
q.mu.Lock()
|
|
defer q.mu.Unlock()
|
|
|
|
switch q.repeatMode {
|
|
case RepeatOff:
|
|
q.repeatMode = RepeatAll
|
|
case RepeatAll:
|
|
q.repeatMode = RepeatOne
|
|
case RepeatOne:
|
|
q.repeatMode = RepeatOff
|
|
}
|
|
|
|
q.persistState()
|
|
q.emitQueueChanged()
|
|
}
|
|
|
|
// GetState returns the current queue state for the frontend.
|
|
func (q *Queue) GetState() State {
|
|
q.mu.Lock()
|
|
defer q.mu.Unlock()
|
|
|
|
tracks := make([]Track, len(q.tracks))
|
|
copy(tracks, q.tracks)
|
|
|
|
return State{
|
|
Tracks: tracks,
|
|
CurrentIndex: q.currentIndex,
|
|
ShuffleMode: q.shuffleMode,
|
|
RepeatMode: q.repeatMode,
|
|
SourcePlaylistID: q.sourcePlaylistID,
|
|
}
|
|
}
|
|
|
|
// EmitCurrentState emits the current queue state to the frontend.
|
|
// This is called after the frontend DOM is ready.
|
|
func (q *Queue) EmitCurrentState() {
|
|
q.mu.Lock()
|
|
defer q.mu.Unlock()
|
|
|
|
q.emitQueueChanged()
|
|
}
|
|
|
|
// SaveState persists the queue state to the database.
|
|
func (q *Queue) SaveState() {
|
|
q.mu.Lock()
|
|
defer q.mu.Unlock()
|
|
|
|
q.persistTracks()
|
|
q.persistState()
|
|
q.logger.Info("Queue state saved",
|
|
"trackCount", len(q.tracks),
|
|
"currentIndex", q.currentIndex,
|
|
"shuffleMode", q.shuffleMode,
|
|
"repeatMode", q.repeatMode,
|
|
)
|
|
}
|
|
|
|
// RestoreState loads the queue state from the database.
|
|
func (q *Queue) RestoreState() {
|
|
q.mu.Lock()
|
|
defer q.mu.Unlock()
|
|
|
|
// Restore queue metadata.
|
|
state, err := q.db.Queries.GetQueueState(q.db.Ctx)
|
|
if err != nil {
|
|
q.logger.Error("Failed to load queue state", "err", err)
|
|
|
|
return
|
|
}
|
|
|
|
q.currentIndex = int(state.CurrentPosition)
|
|
q.shuffleMode = state.ShuffleMode
|
|
q.repeatMode = RepeatMode(state.RepeatMode)
|
|
|
|
if state.SourcePlaylistID.Valid {
|
|
q.sourcePlaylistID = state.SourcePlaylistID.Int64
|
|
}
|
|
|
|
// Restore shuffle order.
|
|
if state.ShuffleOrder.Valid && state.ShuffleOrder.String != "" {
|
|
var order []int
|
|
|
|
if err := json.Unmarshal(
|
|
[]byte(state.ShuffleOrder.String), &order,
|
|
); err != nil {
|
|
q.logger.Warn("Failed to parse shuffle order", "err", err)
|
|
} else {
|
|
q.shuffleOrder = order
|
|
}
|
|
}
|
|
|
|
// Restore queue tracks.
|
|
rows, err := q.db.Queries.GetQueueTracks(q.db.Ctx)
|
|
if err != nil {
|
|
q.logger.Error("Failed to load queue tracks", "err", err)
|
|
|
|
return
|
|
}
|
|
|
|
q.tracks = make([]Track, 0, len(rows))
|
|
|
|
for _, row := range rows {
|
|
q.tracks = append(q.tracks, Track{
|
|
ID: row.ID,
|
|
AudioFileID: row.AudioFileID,
|
|
FilePath: row.FilePath,
|
|
Position: row.Position,
|
|
Title: row.Title,
|
|
Artist: row.Artist,
|
|
})
|
|
}
|
|
|
|
// Clamp current index. A value of -1 is valid and means "no current
|
|
// track" (e.g. the queue was exhausted before shutdown). Only clamp
|
|
// when the index exceeds the restored track count.
|
|
if q.currentIndex >= len(q.tracks) && len(q.tracks) > 0 {
|
|
q.currentIndex = len(q.tracks) - 1
|
|
}
|
|
|
|
q.logger.Info("Queue state restored",
|
|
"trackCount", len(q.tracks),
|
|
"currentIndex", q.currentIndex,
|
|
"shuffleMode", q.shuffleMode,
|
|
"repeatMode", q.repeatMode,
|
|
)
|
|
}
|
|
|
|
// nextIndex returns the next track index respecting shuffle and repeat modes.
|
|
// Returns -1 if there is no next track (queue exhausted).
|
|
func (q *Queue) nextIndex() int {
|
|
if len(q.tracks) == 0 {
|
|
return -1
|
|
}
|
|
|
|
if q.shuffleMode && len(q.shuffleOrder) > 0 {
|
|
return q.nextShuffledIndex()
|
|
}
|
|
|
|
next := q.currentIndex + 1
|
|
if next >= len(q.tracks) {
|
|
if q.repeatMode == RepeatAll {
|
|
return 0
|
|
}
|
|
|
|
return -1
|
|
}
|
|
|
|
return next
|
|
}
|
|
|
|
// previousIndex returns the previous track index respecting shuffle and repeat.
|
|
// Returns -1 if there is no previous track.
|
|
func (q *Queue) previousIndex() int {
|
|
if len(q.tracks) == 0 {
|
|
return -1
|
|
}
|
|
|
|
if q.shuffleMode && len(q.shuffleOrder) > 0 {
|
|
return q.previousShuffledIndex()
|
|
}
|
|
|
|
prev := q.currentIndex - 1
|
|
if prev < 0 {
|
|
if q.repeatMode == RepeatAll {
|
|
return len(q.tracks) - 1
|
|
}
|
|
|
|
return -1
|
|
}
|
|
|
|
return prev
|
|
}
|
|
|
|
// nextShuffledIndex finds the next index in the shuffle order.
|
|
func (q *Queue) nextShuffledIndex() int {
|
|
shufflePos := q.currentShufflePosition()
|
|
if shufflePos == -1 {
|
|
// Current track not found in shuffle order — shouldn't happen.
|
|
return -1
|
|
}
|
|
|
|
nextShufflePos := shufflePos + 1
|
|
if nextShufflePos >= len(q.shuffleOrder) {
|
|
if q.repeatMode == RepeatAll {
|
|
return q.shuffleOrder[0]
|
|
}
|
|
|
|
return -1
|
|
}
|
|
|
|
return q.shuffleOrder[nextShufflePos]
|
|
}
|
|
|
|
// previousShuffledIndex finds the previous index in the shuffle order.
|
|
func (q *Queue) previousShuffledIndex() int {
|
|
shufflePos := q.currentShufflePosition()
|
|
if shufflePos == -1 {
|
|
return -1
|
|
}
|
|
|
|
prevShufflePos := shufflePos - 1
|
|
if prevShufflePos < 0 {
|
|
if q.repeatMode == RepeatAll {
|
|
return q.shuffleOrder[len(q.shuffleOrder)-1]
|
|
}
|
|
|
|
return -1
|
|
}
|
|
|
|
return q.shuffleOrder[prevShufflePos]
|
|
}
|
|
|
|
// currentShufflePosition finds where the current track index is in the shuffle order.
|
|
func (q *Queue) currentShufflePosition() int {
|
|
for i, idx := range q.shuffleOrder {
|
|
if idx == q.currentIndex {
|
|
return i
|
|
}
|
|
}
|
|
|
|
return -1
|
|
}
|
|
|
|
// generateShuffleOrder creates a Fisher-Yates shuffled index order,
|
|
// placing the current track at position 0 so it doesn't replay immediately.
|
|
func (q *Queue) generateShuffleOrder() {
|
|
n := len(q.tracks)
|
|
if n == 0 {
|
|
q.shuffleOrder = nil
|
|
|
|
return
|
|
}
|
|
|
|
order := make([]int, n)
|
|
for i := range order {
|
|
order[i] = i
|
|
}
|
|
|
|
// Fisher-Yates shuffle.
|
|
for i := n - 1; i > 0; i-- {
|
|
j := rand.IntN(i + 1)
|
|
order[i], order[j] = order[j], order[i]
|
|
}
|
|
|
|
// Move the current track to position 0 so it doesn't replay immediately.
|
|
for i, idx := range order {
|
|
if idx == q.currentIndex {
|
|
order[0], order[i] = order[i], order[0]
|
|
|
|
break
|
|
}
|
|
}
|
|
|
|
q.shuffleOrder = order
|
|
}
|
|
|
|
// playOrLoadCurrentTrack loads the current track and optionally starts
|
|
// playback. When autoPlay is true it behaves like playCurrentTrack;
|
|
// when false it only loads the file (leaving the player paused).
|
|
func (q *Queue) playOrLoadCurrentTrack(autoPlay bool) {
|
|
if autoPlay {
|
|
q.playCurrentTrack()
|
|
} else {
|
|
q.loadCurrentTrack()
|
|
}
|
|
}
|
|
|
|
// loadCurrentTrack tells the player to load the current track without
|
|
// starting playback. It persists the updated queue state. Returns true
|
|
// if the file was loaded successfully.
|
|
func (q *Queue) loadCurrentTrack() bool {
|
|
if q.player == nil {
|
|
q.logger.Error("No player set, cannot load track")
|
|
|
|
return false
|
|
}
|
|
|
|
if q.currentIndex < 0 || q.currentIndex >= len(q.tracks) {
|
|
q.logger.Warn(
|
|
"Current index out of range",
|
|
"index", q.currentIndex,
|
|
"trackCount", len(q.tracks),
|
|
)
|
|
|
|
return false
|
|
}
|
|
|
|
track := q.tracks[q.currentIndex]
|
|
q.logger.Info(
|
|
"Loading track from queue",
|
|
"filePath", track.FilePath,
|
|
"position", q.currentIndex,
|
|
)
|
|
|
|
err := q.player.LoadFile(track.FilePath)
|
|
if err != nil {
|
|
q.logger.Error(
|
|
"Failed to load file from queue",
|
|
"filePath", track.FilePath, "err", err,
|
|
)
|
|
|
|
return false
|
|
}
|
|
|
|
q.persistState()
|
|
|
|
return true
|
|
}
|
|
|
|
// playCurrentTrack tells the player to load and play the current track.
|
|
func (q *Queue) playCurrentTrack() {
|
|
if !q.loadCurrentTrack() {
|
|
return
|
|
}
|
|
|
|
err := q.player.Play()
|
|
if err != nil {
|
|
track := q.tracks[q.currentIndex]
|
|
q.logger.Error(
|
|
"Failed to play file from queue",
|
|
"filePath", track.FilePath, "err", err,
|
|
)
|
|
}
|
|
}
|
|
|
|
// onQueueExhausted is called when there are no more tracks to play.
|
|
// It unloads the current track, resets the index to -1 (no current track),
|
|
// and notifies the frontend.
|
|
func (q *Queue) onQueueExhausted() {
|
|
q.logger.Info("Queue exhausted, unloading track")
|
|
|
|
q.currentIndex = -1
|
|
|
|
if q.player != nil {
|
|
q.player.UnloadTrack()
|
|
}
|
|
|
|
q.emitQueueChanged()
|
|
q.persistState()
|
|
}
|
|
|
|
// reindexPositions updates the Position field of all tracks to match slice index.
|
|
func (q *Queue) reindexPositions() {
|
|
for i := range q.tracks {
|
|
q.tracks[i].Position = int64(i)
|
|
}
|
|
}
|
|
|
|
// lookupTrackMetaBatch fetches audio file IDs and metadata for a batch of
|
|
// file paths using a single query per chunk (instead of 2 queries per track).
|
|
// Returns a map keyed by file path. This is safe to call without holding q.mu.
|
|
func (q *Queue) lookupTrackMetaBatch(
|
|
filePaths []string,
|
|
) 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 {
|
|
q.logger.Error("Batch metadata lookup failed", "err", err)
|
|
|
|
return
|
|
}
|
|
|
|
defer func() {
|
|
if closeErr := rows.Close(); closeErr != nil {
|
|
q.logger.Error(
|
|
"Failed to close rows",
|
|
"err", closeErr,
|
|
)
|
|
}
|
|
}()
|
|
|
|
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.
|
|
func (q *Queue) persistState() {
|
|
var shuffleOrderJSON sql.NullString
|
|
|
|
if len(q.shuffleOrder) > 0 {
|
|
data, err := json.Marshal(q.shuffleOrder)
|
|
if err != nil {
|
|
q.logger.Error(
|
|
"Failed to marshal shuffle order",
|
|
"err", err,
|
|
)
|
|
} else {
|
|
shuffleOrderJSON = sql.NullString{
|
|
String: string(data),
|
|
Valid: true,
|
|
}
|
|
}
|
|
}
|
|
|
|
sourcePlaylistID := sql.NullInt64{}
|
|
if q.sourcePlaylistID > 0 {
|
|
sourcePlaylistID = sql.NullInt64{
|
|
Int64: q.sourcePlaylistID,
|
|
Valid: true,
|
|
}
|
|
}
|
|
|
|
err := q.db.Queries.UpdateQueueState(
|
|
q.db.Ctx,
|
|
sqlcgen.UpdateQueueStateParams{
|
|
SourcePlaylistID: sourcePlaylistID,
|
|
CurrentPosition: int64(q.currentIndex),
|
|
ShuffleMode: q.shuffleMode,
|
|
RepeatMode: string(q.repeatMode),
|
|
ShuffleOrder: shuffleOrderJSON,
|
|
},
|
|
)
|
|
if err != nil {
|
|
q.logger.Error("Failed to persist queue state", "err", err)
|
|
}
|
|
}
|
|
|
|
// emitQueueChanged emits the full queue state to the frontend.
|
|
func (q *Queue) emitQueueChanged() {
|
|
if q.ctx == nil {
|
|
return
|
|
}
|
|
|
|
state := State{
|
|
Tracks: q.tracks,
|
|
CurrentIndex: q.currentIndex,
|
|
ShuffleMode: q.shuffleMode,
|
|
RepeatMode: q.repeatMode,
|
|
SourcePlaylistID: q.sourcePlaylistID,
|
|
}
|
|
|
|
// Ensure tracks is never nil in JSON.
|
|
if state.Tracks == nil {
|
|
state.Tracks = []Track{}
|
|
}
|
|
|
|
runtime.EventsEmit(q.ctx, events.QueueChanged, state)
|
|
}
|
|
|
|
// Sentinel errors.
|
|
var (
|
|
ErrEmptyQueue = errors.New("queue is empty")
|
|
ErrNoPlayer = errors.New("no player set")
|
|
)
|