Plans 013 and 014, the album page that prompted them, and the smaller fixes they turned up. Changelog, largest first. ## The local library is shaped like files, not like MusicBrainz `audio_files` carries its own tags and points at `albums` and `artists`; `file_genres` is the one real many-to-many. `recordings`, `release_group_recordings`, `artist_credit`, `artist_credit_artist`, `recording_genres`, `release_groups` and `release_to_rg` are gone from the local side, and with them a six-way join in every read, a `MIN(release_group_id)` subquery in eleven queries and a first-credited-artist subquery in nine. Measured on a real 25,966-file library, every many-to-many that model expressed was 1:1 in the data. - Ownership is a file. `GetFilePathsByRecordingMBIDs`, `LibraryMBIDIndex.CheckMBIDs`, `collectLibraryEntities` and `pruneStaleLocalCrossReferences` all join `audio_files`, so the 812 orphaned recordings, 216 release groups and 260 artists that library carried are now structurally impossible. - One projection: every track query selects from the `track_metadata` view, one row type, one mapper. Nine hand-rolled copies had drifted far enough to report different years on different screens. - `library_id = 0` means every library, so each list query exists once instead of scoped and unscoped with a branch at every call site. - No migration chain. `sql/schemas/` is the one description of the shape; `sql/migrations/`, `applyMigrations` and `schema_migrations` are squashed away, along with the drift between them that had sqlc generating against a stale schema. - `database.InsertTestTrack` is the one test seeder; twenty test files had been assembling the old FK chain each in its own order. ## The catalog stores its ids as bytes `explore_index`'s three 36-char MBID columns and its entity-type text are 16 raw bytes and a small integer. The table and its six indexes go 780 MB to 405 MB on a real 2,052,200-row catalog, which is why a fresh install is ~0.6 GB rather than ~1.0 GB. - `backend/explore/mbid.go` is the only place the encoding is known; everything above it speaks dashed strings. - `CHECK(length(mbid) = 16)` makes a stringly write fail at the insert rather than silently returning no rows, since SQLite does not coerce between TEXT and BLOB. - The importer asks the artifact what encoding it carries and converts on the way in, so the artifact already published keeps working and no format bump is needed. - `indexRowColumns`/`scanIndexRow` replace four copies of a 22-column list, and `TestStoredEncodingRoundTrips` sweeps every read path. ## An album page that says how much of the album is yours - One question, asked once: is there a file. `filePaths` is filled by a single batched lookup when the tracklist settles, and the badge, the Play count, the dimmed rows and every menu item read it — replacing four claims of decreasing confidence that could show a green tick on an album whose every action did nothing. - Play, Play 7 of 12, or no play button at all. - `total_tracks` on `explore_index` (~2 bytes over 400,677 release groups) and on `audio_files` from tags that have always carried it: a complete MBID-matched album now makes no catalog call at all, where it used to spend the most expensive request the app makes. - A merged cluster shows the running order the most releases agree on, and the version list marks the release you own rather than standing a synthetic entry in for it. - `AlbumReleasesFailed`: a slow fetch is no longer reported as a failed one by a 12-second timer. - Rows not in the library are dimmed in place (with `aria-disabled`) instead of the owned ones wearing a green tick and a legend. ## Caches and cover art get ceilings - Only the three tiers of a cover are stored; the full-resolution copy nothing rendered was 1,134 MB of a 1.4 GB covers directory. - One artist portrait is downloaded and the rest are remembered as URLs — 4.1 GB of a 5.3 GB cache was candidates no code path reads. - `browsedArtBudget` and `httpCacheBudget` bound what an age cannot: the same install held art for 5,770 artists in a 1,301-artist library. - `OrphanedArtistImagesJob` joined a bare MBID onto a sharded directory, so it deleted the rows that were the only record of the files it left behind. `explore.ArtistImageDir` is that layout's one definition now. ## The autotag queue asks whether there is work `tagging_items` was a row per album folder, not a queue, and no query read the `tag_status` column that held the answer. The four queue queries ask the files, which matters most where it is least visible: `startPrefetch` was scoring every album in a tagged library against MusicBrainz. ## Phantom playlist tracks resolve in place An M3U8 imported before its files leaves phantom rows; they now match by path and fall back to position, keep their place in the playlist when resolved, and pair best-first so two phantoms cannot claim the same file. ## Playing a track plays the list it is in Double-click, and Play on a single row's menu, queue the list as displayed with `startIndex` on that row — the album page and the track list used to queue one track and discard the album around it. A multi-row selection still plays exactly itself. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AfVYUVExXsx1nSWrXN8mAh
1555 lines
36 KiB
Go
1555 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.
|
|
//
|
|
//wails:ignore // internal wiring, not part of the app's IPC surface.
|
|
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.
|
|
//
|
|
//wails:ignore // internal wiring, not part of the app's IPC surface.
|
|
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()
|
|
}
|