diff --git a/backend/player/player.go b/backend/player/player.go index bc5ff9b..c3a70aa 100644 --- a/backend/player/player.go +++ b/backend/player/player.go @@ -151,6 +151,7 @@ func (p *Player) registerEventHandlers() { } p.emitVolumeChanged() + p.saveState() }) } @@ -349,6 +350,7 @@ func (p *Player) LoadFile(filePath string) error { p.startPaused() p.emitPlaybackStateChanged(p.state) p.emitTrackChanged() + p.saveState() p.logger.Info("File loaded, state set to paused", "file", filePath) return nil @@ -433,6 +435,7 @@ func (p *Player) Pause() error { p.state = Paused p.logger.Info("Paused playback") p.emitPlaybackStateChanged(p.state) + p.saveState() } else { p.logger.Info("Already paused or not playing") } @@ -440,6 +443,11 @@ func (p *Player) Pause() error { return nil } +// IsPlaying reports whether the player is currently playing audio. +func (p *Player) IsPlaying() bool { + return p.state == Playing +} + // UnloadTrack tears down the current track, releasing the file and streamer // chain. The player returns to the initial "no track loaded" state and emits // events so the frontend clears its current-track display. @@ -473,6 +481,7 @@ func (p *Player) UnloadTrack() { // Notify frontend that there is no longer a current track. p.emitPlaybackStateChanged(p.state) runtime.EventsEmit(p.ctx, events.TrackChanged, nil) + p.saveState() p.logger.Info("Track unloaded") } @@ -503,6 +512,7 @@ func (p *Player) getUserVolume() UserVolume { // MuteToggle toggles the mute state. func (p *Player) MuteToggle() error { p.volume.Silent = !p.volume.Silent + p.saveState() return nil } @@ -649,7 +659,16 @@ func (p *Player) TrackLengthInSeconds() (int, error) { } // SaveState persists the current player state to the database. +// This is called during shutdown to capture the final state. func (p *Player) SaveState() { + p.saveState() +} + +// saveState is the internal helper that writes the current player state to the +// database. It is called both from the public SaveState (shutdown) and from +// individual operations that change state (volume, mute, track load/unload) +// so that the persisted state stays up-to-date between clean shutdowns. +func (p *Player) saveState() { if p.db == nil { p.logger.Warn("No database available, cannot save player state") diff --git a/backend/queue/queue.go b/backend/queue/queue.go index c783df7..53ce5fe 100644 --- a/backend/queue/queue.go +++ b/backend/queue/queue.go @@ -35,6 +35,7 @@ const PreviousRestartThreshold = 3 type TrackLoader interface { LoadFile(filePath string) error Play() error + IsPlaying() bool CurrentPositionSeconds() (int, error) UnloadTrack() } @@ -462,6 +463,7 @@ func (q *Queue) AddTrack(filePath string) { q.playCurrentTrack() } + q.persistState() q.emitQueueChanged() } @@ -618,6 +620,7 @@ func (q *Queue) InsertNext(filePath string) { } q.persistTracks() + q.persistState() q.emitQueueChanged() } @@ -653,8 +656,9 @@ func (q *Queue) RemoveTrack(position int) { q.emitQueueChanged() } -// Next advances to the next track. In RepeatOne mode, the current -// track is replayed instead of advancing. +// 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() @@ -663,9 +667,11 @@ func (q *Queue) Next() { return } + wasPlaying := q.player != nil && q.player.IsPlaying() + // Repeat One: replay the current track. if q.repeatMode == RepeatOne { - q.playCurrentTrack() + q.playOrLoadCurrentTrack(wasPlaying) q.emitQueueChanged() return @@ -679,11 +685,12 @@ func (q *Queue) Next() { } q.currentIndex = nextIdx - q.playCurrentTrack() + q.playOrLoadCurrentTrack(wasPlaying) q.emitQueueChanged() } // Previous goes to the previous track (or restarts current if >3s in). +// If the player was paused, the track is loaded but not played. // In RepeatOne mode, the current track is replayed instead of navigating. func (q *Queue) Previous() { q.mu.Lock() @@ -693,9 +700,11 @@ func (q *Queue) Previous() { return } + wasPlaying := q.player != nil && q.player.IsPlaying() + // Repeat One: replay the current track. if q.repeatMode == RepeatOne { - q.playCurrentTrack() + q.playOrLoadCurrentTrack(wasPlaying) q.emitQueueChanged() return @@ -705,7 +714,7 @@ func (q *Queue) Previous() { if q.player != nil { posSecs, err := q.player.CurrentPositionSeconds() if err == nil && posSecs > PreviousRestartThreshold { - q.playCurrentTrack() + q.playOrLoadCurrentTrack(wasPlaying) q.emitQueueChanged() return @@ -715,14 +724,14 @@ func (q *Queue) Previous() { prevIdx := q.previousIndex() if prevIdx == -1 { // At the beginning — just restart the current track. - q.playCurrentTrack() + q.playOrLoadCurrentTrack(wasPlaying) q.emitQueueChanged() return } q.currentIndex = prevIdx - q.playCurrentTrack() + q.playOrLoadCurrentTrack(wasPlaying) q.emitQueueChanged() } @@ -1046,12 +1055,25 @@ func (q *Queue) generateShuffleOrder() { q.shuffleOrder = order } -// playCurrentTrack tells the player to load and play the current track. -func (q *Queue) playCurrentTrack() { - if q.player == nil { - q.logger.Error("No player set, cannot play track") +// playOrLoadCurrentTrack loads the current track and optionally starts +// playback. When autoPlay is true it behaves like playCurrentTrack; +// when false it only loads the file (leaving the player paused). +func (q *Queue) playOrLoadCurrentTrack(autoPlay bool) { + if autoPlay { + q.playCurrentTrack() + } else { + q.loadCurrentTrack() + } +} - return +// 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) { @@ -1060,28 +1082,44 @@ func (q *Queue) playCurrentTrack() { "index", q.currentIndex, "trackCount", len(q.tracks), ) - return + return false } track := q.tracks[q.currentIndex] q.logger.Info( - "Playing track from queue", + "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.logger.Error( + "Failed to load file from queue", + "filePath", track.FilePath, "err", err, + ) - return - } - - err = q.player.Play() - if err != nil { - q.logger.Error("Failed to play file from queue", "filePath", track.FilePath, "err", err) + return false } q.persistState() + + return true +} + +// playCurrentTrack tells the player to load and play the current track. +func (q *Queue) playCurrentTrack() { + if !q.loadCurrentTrack() { + return + } + + err := q.player.Play() + if err != nil { + track := q.tracks[q.currentIndex] + q.logger.Error( + "Failed to play file from queue", + "filePath", track.FilePath, "err", err, + ) + } } // onQueueExhausted is called when there are no more tracks to play. @@ -1108,6 +1146,9 @@ func (q *Queue) reindexPositions() { } // persistTracks writes the current queue tracks to the database. +// TODO: wrap in a transaction so the DELETE-all + INSERT-all is atomic. +// Without a transaction a crash mid-write could leave queue_tracks empty or +// partially populated. Low practical risk but worth addressing for robustness. func (q *Queue) persistTracks() { err := q.db.Queries.ClearQueueTracks(q.db.Ctx) if err != nil {