Every write goes through one connection — MaxOpenConns(1), because SQLite has one writer — and a background pass can hold it for a long time. The player and the queue wrote inline from paths that hold their own mutexes, so a contended writer did not merely slow persistence down: SetQueue blocked in LoadFile's saveState and then in persistState, while holding q.mu and p.mu. That is the exact shape of the report: the track changed and the transport sat at paused, nothing appeared in the queue, and the play button did nothing because Queue.Play waited on the same held q.mu. Diagnosed by profiling the running app — 91% of its CPU was BackfillLibraryDiscographies → upsertBatch, with four of its six workers parked in sql.(*DB).conn. Jobs now run in submission order on one goroutine per component, each carrying its own snapshot. A job must not touch the component's fields — it holds no lock and the state has moved on — which is why persistTracks clones. SaveState still flushes and waits, because that is the one caller for which the row has to exist on return. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UDCbcCZQepnpSQYJ6SxxZm
1541 lines
35 KiB
Go
1541 lines
35 KiB
Go
// Package queue manages the playback queue and auto-advance logic.
|
|
package queue
|
|
|
|
import (
|
|
"context"
|
|
"log/slog"
|
|
"slices"
|
|
"sync"
|
|
"sync/atomic"
|
|
|
|
"yellowjacket/backend/coverart"
|
|
"yellowjacket/backend/database"
|
|
"yellowjacket/backend/profiling"
|
|
)
|
|
|
|
// 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
|
|
|
|
// initialBatchSize is the number of tracks resolved eagerly in the first
|
|
// phase of SetQueue so the queue panel is populated immediately.
|
|
const initialBatchSize = 50
|
|
|
|
// trackMeta holds the result of a batch metadata lookup.
|
|
type trackMeta struct {
|
|
AudioFileID int64
|
|
FilePath string
|
|
Title string
|
|
Artist string
|
|
Album string
|
|
CoverArtPath string
|
|
ArtistMBID string
|
|
ReleaseGroupMBID string
|
|
RecordingMBID string
|
|
}
|
|
|
|
// toTrack converts metadata lookup results into a queue Track.
|
|
func (m trackMeta) toTrack(position int64) Track {
|
|
var coverArtURL string
|
|
if m.CoverArtPath != "" {
|
|
coverArtURL = coverart.ResolveURLs(m.CoverArtPath).Small
|
|
}
|
|
|
|
return Track{
|
|
AudioFileID: m.AudioFileID,
|
|
FilePath: m.FilePath,
|
|
Position: position,
|
|
Title: m.Title,
|
|
Artist: m.Artist,
|
|
Album: m.Album,
|
|
CoverArtPath: coverArtURL,
|
|
ArtistMBID: m.ArtistMBID,
|
|
ReleaseGroupMBID: m.ReleaseGroupMBID,
|
|
RecordingMBID: m.RecordingMBID,
|
|
}
|
|
}
|
|
|
|
// 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()
|
|
}
|
|
|
|
// FallbackSource resolves what should auto-play, if anything, once the
|
|
// queue is exhausted. Implemented outside this package (see app.go) so
|
|
// the queue does not need to know about config, playlists or
|
|
// similarity data.
|
|
type FallbackSource interface {
|
|
// ResolveFallback returns the tracks to auto-play next, or an empty
|
|
// slice if the configured mode is "stop" or nothing is available.
|
|
ResolveFallback(ctx context.Context, fctx FallbackContext) ([]string, Source, error)
|
|
}
|
|
|
|
// FallbackContext is what a FallbackSource needs to decide whether to
|
|
// continue an existing fallback (a dynamic mix keeps extending itself)
|
|
// or resolve fresh.
|
|
type FallbackContext struct {
|
|
// PreviousSource is the source of the queue that just exhausted.
|
|
PreviousSource Source
|
|
// SeedPaths are that queue's track paths, in order.
|
|
SeedPaths []string
|
|
}
|
|
|
|
// 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"`
|
|
Album string `json:"album"`
|
|
CoverArtPath string `json:"coverArtPath"`
|
|
ArtistMBID string `json:"artistMbid"`
|
|
ReleaseGroupMBID string `json:"releaseGroupMbid"`
|
|
RecordingMBID string `json:"recordingMbid"`
|
|
}
|
|
|
|
// 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"`
|
|
Source Source `json:"source"`
|
|
}
|
|
|
|
// Source describes the collection a queue was built from — an album, a
|
|
// playlist, a genre, an artist — so the frontend can offer to navigate
|
|
// back to it. An empty Type means the queue has no single source (the
|
|
// whole library, or one ad-hoc track).
|
|
type Source struct {
|
|
Type string `json:"type"`
|
|
ID int64 `json:"id"`
|
|
Label string `json:"label"`
|
|
}
|
|
|
|
// IndexChanged is the payload for the QueueIndexChanged event.
|
|
type IndexChanged struct {
|
|
CurrentIndex int `json:"currentIndex"`
|
|
}
|
|
|
|
// ModeChanged is the payload for the QueueModeChanged event.
|
|
type ModeChanged struct {
|
|
ShuffleMode bool `json:"shuffleMode"`
|
|
RepeatMode RepeatMode `json:"repeatMode"`
|
|
}
|
|
|
|
// PlaybackFailure is the payload for the PlaybackFailed event. It
|
|
// carries enough to name the track in a message without the frontend
|
|
// having to look anything up, and the raw reason for the log.
|
|
type PlaybackFailure struct {
|
|
FilePath string `json:"filePath"`
|
|
Title string `json:"title"`
|
|
Artist string `json:"artist"`
|
|
Reason string `json:"reason"`
|
|
}
|
|
|
|
// TracksModified is the payload for the QueueTracksModified event.
|
|
type TracksModified struct {
|
|
Action string `json:"action"`
|
|
Tracks []Track `json:"tracks,omitempty"`
|
|
Index int `json:"index"`
|
|
Positions []int `json:"positions,omitempty"`
|
|
CurrentIndex int `json:"currentIndex"`
|
|
}
|
|
|
|
// Queue manages an ordered list of tracks for playback.
|
|
type Queue struct {
|
|
ctx context.Context
|
|
logger *slog.Logger
|
|
db *database.DB
|
|
player TrackLoader
|
|
fallbackSource FallbackSource
|
|
|
|
mu sync.Mutex
|
|
tracks []Track
|
|
currentIndex int
|
|
shuffleMode bool
|
|
repeatMode RepeatMode
|
|
shuffleOrder []int
|
|
source Source
|
|
|
|
// setQueueGen is incremented each time SetQueue is called. Background
|
|
// goroutines check this to detect if they have been superseded.
|
|
setQueueGen atomic.Int64
|
|
|
|
// persistCh carries database writes to the single goroutine that
|
|
// runs them, so no mutation path waits on the writer connection
|
|
// while holding mu. See persistwriter.go.
|
|
persistOnce sync.Once
|
|
persistCh chan func()
|
|
}
|
|
|
|
// 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 for event emission.
|
|
func (q *Queue) SetContext(ctx context.Context) {
|
|
q.mu.Lock()
|
|
defer q.mu.Unlock()
|
|
|
|
q.ctx = ctx
|
|
}
|
|
|
|
// SetPlayer provides the queue with a reference to the player for auto-advance.
|
|
func (q *Queue) SetPlayer(player TrackLoader) {
|
|
q.player = player
|
|
}
|
|
|
|
// SetFallbackSource provides the queue with what to auto-play, if
|
|
// anything, once it runs out. A nil source (the default) leaves
|
|
// today's behavior: the queue just goes idle.
|
|
func (q *Queue) SetFallbackSource(fs FallbackSource) {
|
|
q.fallbackSource = fs
|
|
}
|
|
|
|
// SetQueue replaces the entire queue with new tracks and starts playing.
|
|
// When shuffleStart is true and shuffle mode is active, a random first
|
|
// track is chosen instead of the one at startIndex. This is intended for
|
|
// "Play All" type actions where no specific track was selected.
|
|
// It uses a two-phase approach: the first batch of tracks (up to
|
|
// initialBatchSize) is resolved immediately so playback begins and the
|
|
// queue panel is populated without delay. The remaining tracks are then
|
|
// resolved in the background. A generation counter ensures stale
|
|
// background work is discarded if SetQueue is called again.
|
|
func (q *Queue) SetQueue(
|
|
filePaths []string,
|
|
startIndex int,
|
|
shuffleStart bool,
|
|
source Source,
|
|
) {
|
|
defer profiling.TimeOp(q.logger, "queue.SetQueue")()
|
|
|
|
gen := q.setQueueGen.Add(1)
|
|
|
|
if startIndex < 0 || startIndex >= len(filePaths) {
|
|
startIndex = 0
|
|
}
|
|
|
|
// Phase 1: resolve an initial window of tracks centered on startIndex
|
|
// so the queue panel is populated around the playing track immediately.
|
|
windowStart := max(0, startIndex-initialBatchSize/2)
|
|
windowEnd := min(len(filePaths), windowStart+initialBatchSize)
|
|
windowStart = max(0, windowEnd-initialBatchSize)
|
|
|
|
initialPaths := filePaths[windowStart:windowEnd]
|
|
|
|
batchMeta := q.lookupTrackMetaBatch(initialPaths)
|
|
|
|
q.mu.Lock()
|
|
|
|
// Build the initial tracks slice preserving original order.
|
|
tracks := make([]Track, 0, len(initialPaths))
|
|
|
|
for i, fp := range initialPaths {
|
|
m, ok := batchMeta[fp]
|
|
if !ok {
|
|
continue
|
|
}
|
|
|
|
tracks = append(tracks, m.toTrack(int64(i)))
|
|
}
|
|
|
|
if len(tracks) == 0 {
|
|
q.logger.Warn("No tracks found in initial batch")
|
|
q.mu.Unlock()
|
|
|
|
return
|
|
}
|
|
|
|
q.tracks = tracks
|
|
q.source = source
|
|
q.shuffleOrder = nil
|
|
|
|
// Find the start track within the initial batch.
|
|
q.currentIndex = 0
|
|
startPath := filePaths[startIndex]
|
|
|
|
for i, t := range q.tracks {
|
|
if t.FilePath == startPath {
|
|
q.currentIndex = i
|
|
|
|
break
|
|
}
|
|
}
|
|
|
|
// When the caller signals that shuffle should pick the first track
|
|
// (e.g. "Play All" rather than a specific track click) and shuffle
|
|
// mode is active, generate a shuffle order and start from its first
|
|
// element — a random track.
|
|
if shuffleStart && q.shuffleMode && len(q.tracks) > 1 {
|
|
q.currentIndex = -1
|
|
q.generateShuffleOrder()
|
|
q.currentIndex = q.shuffleOrder[0]
|
|
}
|
|
|
|
// Start playing immediately.
|
|
q.playCurrentTrack()
|
|
q.emitQueueChanged()
|
|
|
|
// Record the path actually playing so Phase 2 can find it after the
|
|
// full track list is rebuilt.
|
|
playingPath := q.tracks[q.currentIndex].FilePath
|
|
|
|
q.mu.Unlock()
|
|
|
|
// Phase 2: if there are more tracks beyond the initial batch,
|
|
// resolve them in the background. If everything fits in the initial
|
|
// batch we can persist and finish synchronously.
|
|
if len(filePaths) <= initialBatchSize {
|
|
q.mu.Lock()
|
|
|
|
if shuffleStart && q.shuffleMode {
|
|
q.generateShuffleOrder()
|
|
}
|
|
|
|
q.persistTracks()
|
|
q.persistState()
|
|
|
|
q.mu.Unlock()
|
|
|
|
return
|
|
}
|
|
|
|
go q.resolveRemainingTracks(gen, filePaths, playingPath, batchMeta)
|
|
}
|
|
|
|
// 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. playingPath is the
|
|
// file path of the track that is currently playing so the correct
|
|
// currentIndex can be located in the rebuilt track list.
|
|
// phase1Meta contains metadata already resolved in Phase 1; those paths
|
|
// are skipped to avoid redundant database lookups.
|
|
func (q *Queue) resolveRemainingTracks(
|
|
gen int64,
|
|
filePaths []string,
|
|
playingPath string,
|
|
phase1Meta map[string]trackMeta,
|
|
) {
|
|
// Exclude paths already resolved in Phase 1.
|
|
var unresolvedPaths []string
|
|
|
|
for _, fp := range filePaths {
|
|
if _, alreadyResolved := phase1Meta[fp]; !alreadyResolved {
|
|
unresolvedPaths = append(unresolvedPaths, fp)
|
|
}
|
|
}
|
|
|
|
// Only look up paths that Phase 1 didn't cover.
|
|
allMeta := q.lookupTrackMetaBatch(unresolvedPaths)
|
|
|
|
// Merge Phase 1 results into the lookup.
|
|
for k, v := range phase1Meta {
|
|
allMeta[k] = v
|
|
}
|
|
|
|
// 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, meta.toTrack(int64(i)))
|
|
}
|
|
|
|
q.tracks = tracks
|
|
|
|
// Recalculate currentIndex: find the track that is actually playing.
|
|
// This may differ from the original startIndex when shuffleStart was
|
|
// used to pick a random first track.
|
|
q.currentIndex = 0
|
|
|
|
for i, t := range q.tracks {
|
|
if t.FilePath == playingPath {
|
|
q.currentIndex = i
|
|
|
|
break
|
|
}
|
|
}
|
|
|
|
q.commitMutation(false)
|
|
q.emitQueueChanged()
|
|
}
|
|
|
|
// AddTrack appends a track to the end of the queue.
|
|
// If the queue was empty, it loads the added track in a paused state.
|
|
func (q *Queue) AddTrack(filePath string) {
|
|
meta := q.lookupTrackMetaBatch([]string{filePath})
|
|
|
|
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 := m.toTrack(int64(len(q.tracks)))
|
|
|
|
q.tracks = append(q.tracks, track)
|
|
|
|
// Load (paused) if this is the first track added to an empty queue.
|
|
if wasEmpty {
|
|
q.currentIndex = 0
|
|
q.loadCurrentTrack()
|
|
}
|
|
|
|
if q.shuffleMode {
|
|
q.generateShuffleOrder()
|
|
}
|
|
|
|
q.persistAddTrack(track)
|
|
q.persistState()
|
|
q.emitTracksModified(
|
|
"add",
|
|
[]Track{track},
|
|
len(q.tracks)-1,
|
|
nil,
|
|
)
|
|
}
|
|
|
|
// AddTracks appends multiple tracks to the end of the queue.
|
|
// If the queue was empty, it loads the first added track in a paused state.
|
|
func (q *Queue) AddTracks(filePaths []string) {
|
|
allMeta := q.lookupTrackMetaBatch(filePaths)
|
|
|
|
q.mu.Lock()
|
|
defer q.mu.Unlock()
|
|
|
|
wasEmpty := len(q.tracks) == 0
|
|
insertIndex := len(q.tracks)
|
|
|
|
var newTracks []Track
|
|
|
|
for _, fp := range filePaths {
|
|
m, ok := allMeta[fp]
|
|
if !ok {
|
|
q.logger.Warn(
|
|
"Could not find audio file",
|
|
"path", fp,
|
|
)
|
|
|
|
continue
|
|
}
|
|
|
|
track := m.toTrack(int64(len(q.tracks)))
|
|
q.tracks = append(q.tracks, track)
|
|
|
|
newTracks = append(newTracks, track)
|
|
}
|
|
|
|
// Load (paused) if this is the first track added to an empty queue.
|
|
if wasEmpty && len(q.tracks) > 0 {
|
|
q.currentIndex = 0
|
|
q.loadCurrentTrack()
|
|
}
|
|
|
|
if q.shuffleMode {
|
|
q.generateShuffleOrder()
|
|
}
|
|
|
|
q.persistAddTracks(newTracks)
|
|
q.persistState()
|
|
q.emitTracksModified(
|
|
"add",
|
|
newTracks,
|
|
insertIndex,
|
|
nil,
|
|
)
|
|
}
|
|
|
|
// InsertNextTracks inserts multiple tracks as a contiguous block after the current track.
|
|
// If the queue was empty, it loads the first inserted track in a paused state.
|
|
func (q *Queue) InsertNextTracks(filePaths []string) {
|
|
allMeta := q.lookupTrackMetaBatch(filePaths)
|
|
|
|
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, m.toTrack(0))
|
|
}
|
|
|
|
if len(newTracks) == 0 {
|
|
return
|
|
}
|
|
|
|
q.tracks = slices.Insert(q.tracks, insertPos, newTracks...)
|
|
|
|
if wasEmpty {
|
|
q.currentIndex = 0
|
|
q.loadCurrentTrack()
|
|
}
|
|
|
|
q.reindexPositions()
|
|
|
|
if q.shuffleMode {
|
|
q.generateShuffleOrder()
|
|
}
|
|
|
|
q.persistInsertTracks(newTracks, insertPos)
|
|
q.persistState()
|
|
q.emitTracksModified(
|
|
"insert",
|
|
newTracks,
|
|
insertPos,
|
|
nil,
|
|
)
|
|
}
|
|
|
|
// InsertNext inserts a track right after the currently playing track.
|
|
// If the queue was empty, it loads the inserted track in a paused state.
|
|
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
|
|
}
|
|
|
|
wasEmpty := len(q.tracks) == 0
|
|
|
|
insertPos := q.currentIndex + 1
|
|
if insertPos > len(q.tracks) {
|
|
insertPos = len(q.tracks)
|
|
}
|
|
|
|
track := m.toTrack(int64(insertPos))
|
|
q.tracks = slices.Insert(q.tracks, insertPos, track)
|
|
|
|
// Load (paused) if this is the first track added to an empty queue.
|
|
if wasEmpty {
|
|
q.currentIndex = 0
|
|
q.loadCurrentTrack()
|
|
}
|
|
|
|
q.reindexPositions()
|
|
|
|
if q.shuffleMode {
|
|
q.generateShuffleOrder()
|
|
}
|
|
|
|
q.persistInsertTracks([]Track{track}, insertPos)
|
|
q.persistState()
|
|
q.emitTracksModified(
|
|
"insert",
|
|
[]Track{track},
|
|
insertPos,
|
|
nil,
|
|
)
|
|
}
|
|
|
|
// InsertTracksAt inserts multiple tracks at the given index.
|
|
// If the queue was empty, it loads the first inserted track in a paused state.
|
|
func (q *Queue) InsertTracksAt(filePaths []string, index int) {
|
|
allMeta := q.lookupTrackMetaBatch(filePaths)
|
|
|
|
q.mu.Lock()
|
|
defer q.mu.Unlock()
|
|
|
|
wasEmpty := len(q.tracks) == 0
|
|
|
|
// Clamp index to valid range.
|
|
if index < 0 {
|
|
index = 0
|
|
}
|
|
|
|
if index > len(q.tracks) {
|
|
index = len(q.tracks)
|
|
}
|
|
|
|
var newTracks []Track
|
|
|
|
for _, fp := range filePaths {
|
|
m, ok := allMeta[fp]
|
|
if !ok {
|
|
q.logger.Warn(
|
|
"Could not find audio file",
|
|
"path", fp,
|
|
)
|
|
|
|
continue
|
|
}
|
|
|
|
newTracks = append(newTracks, m.toTrack(0))
|
|
}
|
|
|
|
if len(newTracks) == 0 {
|
|
return
|
|
}
|
|
|
|
q.tracks = slices.Insert(q.tracks, index, newTracks...)
|
|
|
|
// Shift currentIndex if insertion is at or before it.
|
|
if q.currentIndex >= 0 && index <= q.currentIndex {
|
|
q.currentIndex += len(newTracks)
|
|
}
|
|
|
|
if wasEmpty {
|
|
q.currentIndex = 0
|
|
q.loadCurrentTrack()
|
|
}
|
|
|
|
q.reindexPositions()
|
|
|
|
if q.shuffleMode {
|
|
q.generateShuffleOrder()
|
|
}
|
|
|
|
q.persistInsertTracks(newTracks, index)
|
|
q.persistState()
|
|
q.emitTracksModified(
|
|
"insert",
|
|
newTracks,
|
|
index,
|
|
nil,
|
|
)
|
|
}
|
|
|
|
// MoveQueueTracks moves tracks at the given indices to a new position
|
|
// as a contiguous block. The toIndex is the target position in the
|
|
// original (pre-move) array.
|
|
func (q *Queue) MoveQueueTracks(
|
|
fromIndices []int,
|
|
toIndex int,
|
|
) {
|
|
q.mu.Lock()
|
|
defer q.mu.Unlock()
|
|
|
|
if len(fromIndices) == 0 || len(q.tracks) == 0 {
|
|
return
|
|
}
|
|
|
|
// De-duplicate and sort source indices.
|
|
seen := make(map[int]bool, len(fromIndices))
|
|
|
|
var sorted []int
|
|
|
|
for _, idx := range fromIndices {
|
|
if idx >= 0 && idx < len(q.tracks) && !seen[idx] {
|
|
seen[idx] = true
|
|
|
|
sorted = append(sorted, idx)
|
|
}
|
|
}
|
|
|
|
if len(sorted) == 0 {
|
|
return
|
|
}
|
|
|
|
slices.Sort(sorted)
|
|
|
|
// Clamp toIndex.
|
|
if toIndex < 0 {
|
|
toIndex = 0
|
|
}
|
|
|
|
if toIndex > len(q.tracks) {
|
|
toIndex = len(q.tracks)
|
|
}
|
|
|
|
// Check if this is a no-op: all source indices are contiguous
|
|
// and already start at the target position.
|
|
isContiguous := true
|
|
|
|
for i := 1; i < len(sorted); i++ {
|
|
if sorted[i] != sorted[i-1]+1 {
|
|
isContiguous = false
|
|
|
|
break
|
|
}
|
|
}
|
|
|
|
lastSorted := sorted[len(sorted)-1]
|
|
|
|
if isContiguous &&
|
|
(sorted[0] == toIndex || lastSorted+1 == toIndex) {
|
|
return
|
|
}
|
|
|
|
// Find where currentIndex ends up after the move.
|
|
currentTrackIdx := q.currentIndex
|
|
|
|
// Extract the tracks to move.
|
|
moving := make([]Track, len(sorted))
|
|
for i, idx := range sorted {
|
|
moving[i] = q.tracks[idx]
|
|
}
|
|
|
|
// Build a new slice without the moved tracks.
|
|
remaining := make([]Track, 0, len(q.tracks)-len(sorted))
|
|
removeSet := make(map[int]bool, len(sorted))
|
|
|
|
for _, idx := range sorted {
|
|
removeSet[idx] = true
|
|
}
|
|
|
|
for i, t := range q.tracks {
|
|
if !removeSet[i] {
|
|
remaining = append(remaining, t)
|
|
}
|
|
}
|
|
|
|
// Calculate adjusted insertion index in the remaining slice.
|
|
adjustedIdx := toIndex
|
|
|
|
for _, idx := range sorted {
|
|
if idx < toIndex {
|
|
adjustedIdx--
|
|
}
|
|
}
|
|
|
|
if adjustedIdx < 0 {
|
|
adjustedIdx = 0
|
|
}
|
|
|
|
if adjustedIdx > len(remaining) {
|
|
adjustedIdx = len(remaining)
|
|
}
|
|
|
|
// Insert the moved block at the adjusted position.
|
|
q.tracks = slices.Insert(remaining, adjustedIdx, moving...)
|
|
|
|
// Track currentIndex through the move.
|
|
if currentTrackIdx >= 0 {
|
|
if removeSet[currentTrackIdx] {
|
|
// The current track was moved — find its new position.
|
|
for ri, orig := range sorted {
|
|
if orig == currentTrackIdx {
|
|
q.currentIndex = adjustedIdx + ri
|
|
|
|
break
|
|
}
|
|
}
|
|
} else {
|
|
// The current track was not moved. Find its position
|
|
// in 'remaining', then account for the insertion.
|
|
posInRemaining := currentTrackIdx
|
|
|
|
for _, idx := range sorted {
|
|
if idx < currentTrackIdx {
|
|
posInRemaining--
|
|
}
|
|
}
|
|
|
|
if adjustedIdx <= posInRemaining {
|
|
q.currentIndex = posInRemaining + len(sorted)
|
|
} else {
|
|
q.currentIndex = posInRemaining
|
|
}
|
|
}
|
|
}
|
|
|
|
q.commitMutation(true)
|
|
q.emitTracksModified(
|
|
"move",
|
|
moving,
|
|
toIndex,
|
|
sorted,
|
|
)
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
removingCurrent := q.currentIndex >= 0 &&
|
|
position == q.currentIndex
|
|
|
|
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.persistRemoveTrack(position)
|
|
q.reindexPositions()
|
|
|
|
if q.shuffleMode {
|
|
q.generateShuffleOrder()
|
|
}
|
|
|
|
q.persistState()
|
|
q.emitTracksModified(
|
|
"remove",
|
|
nil,
|
|
0,
|
|
[]int{position},
|
|
)
|
|
|
|
if removingCurrent {
|
|
q.handleCurrentTrackRemoved()
|
|
}
|
|
}
|
|
|
|
// RemoveTracks removes multiple tracks at the given positions from the queue.
|
|
// Positions are deduplicated, validated, and removed in descending order so
|
|
// that indices remain stable during removal.
|
|
func (q *Queue) RemoveTracks(positions []int) {
|
|
q.mu.Lock()
|
|
defer q.mu.Unlock()
|
|
|
|
if len(positions) == 0 {
|
|
return
|
|
}
|
|
|
|
// Deduplicate and filter out-of-range positions.
|
|
seen := make(map[int]bool, len(positions))
|
|
|
|
valid := make([]int, 0, len(positions))
|
|
|
|
for _, p := range positions {
|
|
if p < 0 || p >= len(q.tracks) || seen[p] {
|
|
continue
|
|
}
|
|
|
|
seen[p] = true
|
|
|
|
valid = append(valid, p)
|
|
}
|
|
|
|
if len(valid) == 0 {
|
|
return
|
|
}
|
|
|
|
removedCurrent := q.currentIndex >= 0 && seen[q.currentIndex]
|
|
|
|
// Sort ascending so we can iterate in reverse for descending removal.
|
|
slices.Sort(valid)
|
|
|
|
// Remove in descending order to keep earlier indices stable.
|
|
for i := len(valid) - 1; i >= 0; i-- {
|
|
pos := valid[i]
|
|
q.tracks = append(q.tracks[:pos], q.tracks[pos+1:]...)
|
|
|
|
if q.currentIndex >= 0 && pos < q.currentIndex {
|
|
q.currentIndex--
|
|
} else if pos == 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.emitTracksModified(
|
|
"remove",
|
|
nil,
|
|
0,
|
|
valid,
|
|
)
|
|
|
|
q.logger.Info(
|
|
"Removed tracks from queue",
|
|
"count", len(valid),
|
|
)
|
|
|
|
if removedCurrent {
|
|
q.handleCurrentTrackRemoved()
|
|
}
|
|
}
|
|
|
|
// 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 {
|
|
if q.playOrLoadCurrentTrack(wasPlaying) {
|
|
q.emitIndexChanged()
|
|
}
|
|
|
|
return
|
|
}
|
|
|
|
nextIdx := q.nextIndex()
|
|
if nextIdx == -1 {
|
|
q.onQueueExhausted(false)
|
|
|
|
return
|
|
}
|
|
|
|
q.currentIndex = nextIdx
|
|
|
|
if !q.playCurrentOrSkip(wasPlaying, q.nextIndex) {
|
|
q.onQueueExhausted(false)
|
|
|
|
return
|
|
}
|
|
|
|
q.emitIndexChanged()
|
|
}
|
|
|
|
// 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 {
|
|
if q.playOrLoadCurrentTrack(wasPlaying) {
|
|
q.emitIndexChanged()
|
|
}
|
|
|
|
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 {
|
|
if q.playOrLoadCurrentTrack(wasPlaying) {
|
|
q.emitIndexChanged()
|
|
}
|
|
|
|
return
|
|
}
|
|
}
|
|
|
|
prevIdx := q.previousIndex()
|
|
if prevIdx == -1 {
|
|
// At the beginning — just restart the current track.
|
|
if q.playOrLoadCurrentTrack(wasPlaying) {
|
|
q.emitIndexChanged()
|
|
}
|
|
|
|
return
|
|
}
|
|
|
|
prevCurrentIndex := q.currentIndex
|
|
q.currentIndex = prevIdx
|
|
|
|
// Backwards for the same reason Next skips forwards: otherwise a
|
|
// bad file behind you makes Previous a button that does nothing.
|
|
if !q.playCurrentOrSkip(wasPlaying, q.previousIndex) {
|
|
q.currentIndex = prevCurrentIndex
|
|
|
|
return
|
|
}
|
|
|
|
q.emitIndexChanged()
|
|
}
|
|
|
|
// Play handles a play request by either resuming the current track or
|
|
// starting playback from the beginning of the queue. When a track is
|
|
// already active (currentIndex != -1) the player is told to resume;
|
|
// otherwise playback starts from the first track (or a random one when
|
|
// shuffle is enabled).
|
|
func (q *Queue) Play() {
|
|
q.mu.Lock()
|
|
defer q.mu.Unlock()
|
|
|
|
if len(q.tracks) == 0 {
|
|
return
|
|
}
|
|
|
|
// A track is already active — ask the player to resume.
|
|
if q.currentIndex != -1 {
|
|
if q.player == nil {
|
|
q.logger.Error(
|
|
"No player set, cannot resume",
|
|
)
|
|
|
|
return
|
|
}
|
|
|
|
if err := q.player.Play(); err != nil {
|
|
q.logger.Warn(
|
|
"Resume requested but player not ready",
|
|
"err", err,
|
|
)
|
|
|
|
// A play button that does nothing and says nothing is the
|
|
// fault PlaybackFailed exists for (errors.C1); this was the
|
|
// one press path still ending at a log line.
|
|
if q.currentIndex < len(q.tracks) {
|
|
q.emitPlaybackFailed(q.tracks[q.currentIndex], err)
|
|
}
|
|
}
|
|
|
|
return
|
|
}
|
|
|
|
// No active track — start from the beginning.
|
|
q.playFromStart()
|
|
}
|
|
|
|
// 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.
|
|
// The caller must hold q.mu.
|
|
func (q *Queue) playFromStart() {
|
|
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
|
|
}
|
|
|
|
if !q.playCurrentOrSkip(true, q.nextIndex) {
|
|
q.currentIndex = -1
|
|
|
|
return
|
|
}
|
|
|
|
q.emitIndexChanged()
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
prevIndex := q.currentIndex
|
|
q.currentIndex = index
|
|
|
|
if !q.playCurrentTrack() {
|
|
q.currentIndex = prevIndex
|
|
|
|
return
|
|
}
|
|
|
|
q.emitIndexChanged()
|
|
}
|
|
|
|
// 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.emitModeChanged()
|
|
}
|
|
|
|
// 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.emitModeChanged()
|
|
}
|
|
|
|
// 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,
|
|
Source: q.source,
|
|
}
|
|
}
|
|
|
|
// Clear removes all tracks from the queue, stops playback, and
|
|
// resets the queue state. It persists the cleared state and
|
|
// notifies the frontend.
|
|
func (q *Queue) Clear() {
|
|
q.mu.Lock()
|
|
defer q.mu.Unlock()
|
|
|
|
q.logger.Info("Clearing queue")
|
|
|
|
q.tracks = nil
|
|
q.currentIndex = -1
|
|
q.shuffleOrder = nil
|
|
q.source = Source{}
|
|
|
|
if q.player != nil {
|
|
q.player.UnloadTrack()
|
|
}
|
|
|
|
q.commitMutation(false)
|
|
q.emitQueueChanged()
|
|
}
|
|
|
|
// 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()
|
|
}
|
|
|
|
// 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).
|
|
// Returns true if the file was loaded (and optionally played) successfully.
|
|
func (q *Queue) playOrLoadCurrentTrack(autoPlay bool) bool {
|
|
if autoPlay {
|
|
return q.playCurrentTrack()
|
|
}
|
|
|
|
return 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,
|
|
)
|
|
q.emitPlaybackFailed(track, err)
|
|
|
|
return false
|
|
}
|
|
|
|
q.persistState()
|
|
|
|
return true
|
|
}
|
|
|
|
// playCurrentTrack tells the player to load and play the current track.
|
|
// Returns true if the file was loaded and playback started successfully.
|
|
func (q *Queue) playCurrentTrack() bool {
|
|
if !q.loadCurrentTrack() {
|
|
return false
|
|
}
|
|
|
|
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,
|
|
)
|
|
q.emitPlaybackFailed(track, err)
|
|
|
|
return false
|
|
}
|
|
|
|
return true
|
|
}
|
|
|
|
// playCurrentOrSkip plays (or loads) the track at currentIndex, and on
|
|
// failure steps to the next candidate rather than giving up. A moved
|
|
// or unreadable file in the middle of a queue used to stop playback
|
|
// dead, and Next did not help because it hit the same track and
|
|
// reverted.
|
|
//
|
|
// The attempt count is bounded by the queue length so a queue whose
|
|
// files have all gone (a disconnected drive) stops after one pass
|
|
// instead of spinning forever through a RepeatAll wrap. Returns false
|
|
// when nothing reachable can be played.
|
|
func (q *Queue) playCurrentOrSkip(autoPlay bool, step func() int) bool {
|
|
for range len(q.tracks) {
|
|
if q.playOrLoadCurrentTrack(autoPlay) {
|
|
return true
|
|
}
|
|
|
|
next := step()
|
|
if next == -1 {
|
|
return false
|
|
}
|
|
|
|
q.currentIndex = next
|
|
}
|
|
|
|
return false
|
|
}
|
|
|
|
// handleCurrentTrackRemoved handles the case where the currently loaded
|
|
// track was removed from the queue. If tracks remain it loads the track
|
|
// now at currentIndex (paused); otherwise it exhausts the queue.
|
|
func (q *Queue) handleCurrentTrackRemoved() {
|
|
if len(q.tracks) == 0 {
|
|
q.onQueueExhausted(true)
|
|
|
|
return
|
|
}
|
|
|
|
q.loadCurrentTrack()
|
|
}
|
|
|
|
// onQueueExhausted is called when there are no more tracks to play.
|
|
// It resets the index to -1 (no current track) and notifies the
|
|
// frontend.
|
|
//
|
|
// unload says whether the player should also let go of the file it
|
|
// holds. When the queue simply ran out, it should not: the finished
|
|
// track stays on the now-playing bar, paused at 0:00, rather than the
|
|
// bar blanking while the queue panel still lists what just played
|
|
// (H-18). When the current track was removed from the queue, or the
|
|
// queue was cleared, there is nothing left to show and it does.
|
|
//
|
|
// Called with q.mu already held by every caller — so the fallback
|
|
// playlist (if any) is only kicked off here, not resolved: resolving
|
|
// one can mean library/similarity queries, which must not run under
|
|
// this lock. See resolveFallback.
|
|
func (q *Queue) onQueueExhausted(unload bool) {
|
|
q.logger.Info("Queue exhausted", "unload", unload)
|
|
|
|
q.currentIndex = -1
|
|
|
|
if unload && q.player != nil {
|
|
q.player.UnloadTrack()
|
|
}
|
|
|
|
q.emitIndexChanged()
|
|
q.persistState()
|
|
|
|
if q.fallbackSource != nil {
|
|
prevSource := q.source
|
|
seedPaths := pathsOf(q.tracks)
|
|
gen := q.setQueueGen.Add(1)
|
|
|
|
go q.resolveFallback(gen, prevSource, seedPaths)
|
|
}
|
|
}
|
|
|
|
// resolveFallback runs outside q.mu — the fallback source may do
|
|
// library/similarity lookups — and, if it finds something, replaces
|
|
// the queue via the ordinary SetQueue path. gen guards against a user
|
|
// starting something else (or another exhaustion) while this was
|
|
// still resolving: SetQueue itself bumps setQueueGen again, so a stale
|
|
// result here is simply discarded.
|
|
func (q *Queue) resolveFallback(
|
|
gen int64,
|
|
prevSource Source,
|
|
seedPaths []string,
|
|
) {
|
|
paths, source, err := q.fallbackSource.ResolveFallback(
|
|
q.ctx,
|
|
FallbackContext{PreviousSource: prevSource, SeedPaths: seedPaths},
|
|
)
|
|
if err != nil {
|
|
q.logger.Error("Failed to resolve fallback playlist", "err", err)
|
|
|
|
return
|
|
}
|
|
|
|
if len(paths) == 0 {
|
|
return
|
|
}
|
|
|
|
if q.setQueueGen.Load() != gen {
|
|
return
|
|
}
|
|
|
|
q.SetQueue(paths, 0, false, source)
|
|
}
|
|
|
|
// pathsOf returns the file paths of a track list, in order.
|
|
func pathsOf(tracks []Track) []string {
|
|
paths := make([]string, len(tracks))
|
|
|
|
for i, t := range tracks {
|
|
paths[i] = t.FilePath
|
|
}
|
|
|
|
return paths
|
|
}
|
|
|
|
// CompactAfterLibraryRemoval reloads queue state from the database
|
|
// after a library removal has cascade-deleted queue_tracks rows.
|
|
// It resets currentIndex to 0 (or -1 if empty), clears shuffleOrder,
|
|
// unloads the current track if it was removed, and emits QueueChanged.
|
|
func (q *Queue) CompactAfterLibraryRemoval() {
|
|
q.mu.Lock()
|
|
defer q.mu.Unlock()
|
|
|
|
// Remember the current track's file path so we can detect if it survived.
|
|
var previousFilePath string
|
|
|
|
if q.currentIndex >= 0 && q.currentIndex < len(q.tracks) {
|
|
previousFilePath = q.tracks[q.currentIndex].FilePath
|
|
}
|
|
|
|
// Reload surviving tracks from the database. The CASCADE delete
|
|
// already removed the rows from queue_tracks — we just need to
|
|
// reload and reindex.
|
|
//
|
|
// This is one of the two places that reads back what it wrote, so
|
|
// it waits for the pending writes first: a queue built moments ago
|
|
// may still be in flight, and reloading from rows it has not
|
|
// reached yet would replace the queue with the previous one.
|
|
q.flushWrites()
|
|
|
|
rows, err := q.db.Queries.GetQueueTracks(q.db.Ctx)
|
|
if err != nil {
|
|
q.logger.Error("could not reload queue tracks after library removal", "err", err)
|
|
|
|
return
|
|
}
|
|
|
|
q.tracks = make([]Track, 0, len(rows))
|
|
|
|
for _, row := range rows {
|
|
var coverArtURL string
|
|
if row.CoverArtPath != "" {
|
|
coverArtURL = coverart.ResolveURLs(row.CoverArtPath).Small
|
|
}
|
|
|
|
q.tracks = append(q.tracks, Track{
|
|
ID: row.ID,
|
|
AudioFileID: row.AudioFileID,
|
|
FilePath: row.FilePath,
|
|
Position: row.Position,
|
|
Title: row.Title,
|
|
Artist: row.Artist,
|
|
Album: row.Album,
|
|
CoverArtPath: coverArtURL,
|
|
})
|
|
}
|
|
|
|
// Check if the previously-playing track survived.
|
|
found := false
|
|
|
|
if previousFilePath != "" {
|
|
for i, t := range q.tracks {
|
|
if t.FilePath == previousFilePath {
|
|
q.currentIndex = i
|
|
found = true
|
|
|
|
break
|
|
}
|
|
}
|
|
}
|
|
|
|
if !found {
|
|
if len(q.tracks) > 0 {
|
|
q.currentIndex = 0
|
|
} else {
|
|
q.currentIndex = -1
|
|
}
|
|
|
|
// The current track was removed — unload it from the player.
|
|
if q.player != nil {
|
|
q.player.UnloadTrack()
|
|
}
|
|
}
|
|
|
|
// Clear shuffle order — it will be regenerated on next shuffle toggle.
|
|
q.shuffleOrder = nil
|
|
|
|
// Reindex positions and persist the compacted state.
|
|
q.reindexPositions()
|
|
q.persistTracks()
|
|
q.persistState()
|
|
q.emitQueueChanged()
|
|
|
|
q.logger.Info("queue compacted after library removal",
|
|
"survivingTracks", len(q.tracks),
|
|
"currentIndex", q.currentIndex,
|
|
)
|
|
}
|
|
|
|
// 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)
|
|
}
|
|
}
|
|
|
|
// commitMutation persists the current queue state after a mutation.
|
|
// When reindex is true, track positions are renumbered first.
|
|
// The caller must hold q.mu.
|
|
func (q *Queue) commitMutation(reindex bool) {
|
|
if reindex {
|
|
q.reindexPositions()
|
|
}
|
|
|
|
if q.shuffleMode {
|
|
q.generateShuffleOrder()
|
|
}
|
|
|
|
q.persistTracks()
|
|
q.persistState()
|
|
}
|