Files
yellowjacket/backend/queue/queue.go
T
yonluandClaude Opus 5 4471db3aef feat(wails): move the Go side to v3
Phases 2 and 3 of plan 009, plus the parts of phase 1 that could not
land before them. Nothing in the tree imports wails/v2 any more; all
three lint and test configurations are green and `go build .` produces
a running binary.

The point of the migration is one file. backend/events/emit.go probed
ctx.Value("events") — a v2-*private* context key — to decide whether
emitting was safe, because runtime.EventsEmit called log.Fatalf on a
context without the runtime and took the process down with it. v3's
emit takes no context, so that is now application.Get() == nil. D1
held: events.Emit keeps its ctx as the WithSink test seam, and all 45
call sites and 7 test files are untouched.

The bootstrap splits into application.New + Window.NewWithOptions +
Run. Ten bound services implement ServiceStartup instead of being
handed a context by hand from OnStartup, which also stops ten
SetContext methods being exported as bindings. jobs.Registry and
explore.SearchIndex keep theirs — neither is bound, so converting them
would be churn for no binding removed.

Four things differed from the plan and are written up in it: GPU policy
moved to the per-window LinuxWindow options rather than surviving on
LinuxOptions; there is no OnStartup/OnDomReady option, so app-level
wiring hangs off ApplicationStarted; application.NewService is generic,
so FEBindings []any could not survive (the binding generator is a
static analyser and would have seen nothing); and the quit veto had to
be restructured, because v3's dialog answers on a callback rather than
returning the button, so ShouldQuit vetoes, asks, and quits again from
the callback.

Window state saving moves to a WindowClosing hook — the size has to be
read while the window still exists, and v3's OnShutdown has neither
context nor window. backend/logging is deleted rather than ported:
v3 takes a *slog.Logger directly, so the v2 logger.Logger adapter had
no caller left.

Phase 1's tail rides along, now that it can: the Makefile's wails
invocations, all 50 webkit2_41 sites, lefthook, both packaging recipes
and ci.yml's apt lists. v3 builds against GTK4 + WebKitGTK 6.0, which
Arch and ubuntu:24.04 both ship, so the tag is a deletion rather than
a translation.

Phase 4 is next and the branch is not usable until it lands: the app
builds, but frontend/wailsjs/ is v2's tree and nothing regenerates it,
so the frontend cannot reach the backend yet.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UDCbcCZQepnpSQYJ6SxxZm
2026-08-14 14:01:02 -04:00

1551 lines
36 KiB
Go

// Package queue manages the playback queue and auto-advance logic.
package queue
import (
"context"
"log/slog"
"slices"
"sync"
"sync/atomic"
"github.com/wailsapp/wails/v3/pkg/application"
"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,
}
}
// ServiceStartup is v3's service lifecycle hook: it runs once the
// runtime exists, and ctx is cancelled when the app shuts down. It
// replaces v2's SetContext, which had to be called by hand from
// OnStartup and was exported, so it was also bound to the frontend.
func (q *Queue) ServiceStartup(
ctx context.Context,
_ application.ServiceOptions,
) error {
q.mu.Lock()
defer q.mu.Unlock()
q.ctx = ctx
return nil
}
// 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()
}