fix: defer MPV playback start until user unpauses on session restore

Instead of play → pause → sleep → seek when restoring a paused session,
introduce loadTrackPaused which sets internal state and fires UI/OS
callbacks (song change, paused) without touching MPV. On Continue(),
detect pendingLoadPaused and start MPV playback from the saved position.

Also triggers waveform pre-generation for the paused track via the
onBeforeSongChange hook.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Drew Weymouth
2026-02-21 09:40:59 -08:00
co-authored by Claude Sonnet 4.6
parent ce3ae4f15a
commit dba540861f
5 changed files with 80 additions and 6 deletions
+1 -5
View File
@@ -683,11 +683,7 @@ func (a *App) LoadSavedPlayQueue() error {
}
if playQueue.TrackIndex >= 0 && playQueue.TrackIndex < len(playQueue.Tracks) {
// TODO: This isn't ideal but doesn't seem to cause an audible play-for-a-split-second artifact
a.PlaybackManager.PlayTrackAt(playQueue.TrackIndex)
a.PlaybackManager.Pause()
time.Sleep(100 * time.Millisecond) // MPV seek fails if run quickly after
a.PlaybackManager.SeekSeconds(playQueue.TimePos)
a.PlaybackManager.LoadTrackPaused(playQueue.TrackIndex, playQueue.TimePos)
}
return nil
@@ -0,0 +1 @@
package subsonic
+13
View File
@@ -30,6 +30,8 @@ const (
cmdLoadRadioStation // arg: *mediaprovider.RadioStation, arg2: InsertQueueMode
cmdForceRestartPlayback
cmdLoadTrackPaused // arg: int (idx), arg2: float64 (startTime)
)
type playbackCommand struct {
@@ -185,6 +187,17 @@ func (c *playbackCommandQueue) SetQueueState(tracks []*mediaprovider.Track, queu
c.cmdAvailable.Signal()
}
func (c *playbackCommandQueue) LoadTrackPaused(idx int, startTime float64) {
c.mutex.Lock()
c.queue = append(c.queue, playbackCommand{
Type: cmdLoadTrackPaused,
Arg: idx,
Arg2: startTime,
})
c.mutex.Unlock()
c.cmdAvailable.Signal()
}
func (c *playbackCommandQueue) addCommand(command playbackCommand) {
c.mutex.Lock()
c.queue = append(c.queue, command)
+56 -1
View File
@@ -93,6 +93,11 @@ type playbackEngine struct {
pendingPlayerChange bool
pendingPlayerChangeStatus player.Status
// set when restoring session state: track is conceptually loaded+paused
// but MPV hasn't been touched yet; cleared on Continue or playTrackAt
pendingLoadPaused bool
pendingLoadStartTime float64
// Whether we need to set the next track on the Player
// before the current track completes (normally when 10 seconds remain
// in the time pos polling function)
@@ -301,10 +306,40 @@ func (p *playbackEngine) PlayTrackAt(idx int) error {
return p.playTrackAt(idx, 0)
}
// loadTrackPaused sets up engine state as if the track at idx is loaded and
// paused at startTime, firing UI/OS-integration callbacks, but does NOT touch
// the underlying player. Call Continue() to actually begin playback.
func (p *playbackEngine) loadTrackPaused(idx int, startTime float64) error {
if l := p.getPlayQueueLength(); idx < 0 || idx >= l {
return fmt.Errorf("track index (%d) out of range (0-%d)", idx, l)
}
p.nowPlayingIdx = idx
nowPlaying := p.getPlayQueueItemAt(idx)
_, p.isRadio = nowPlaying.(*mediaprovider.RadioStation)
p.wasStopped = false
p.alreadyScrobbled = false
p.curTrackDuration = nowPlaying.Metadata().Duration.Seconds()
p.pendingLoadPaused = true
p.pendingLoadStartTime = startTime
// Pre-generate the waveform image for the paused track using the same hook
// that normally pre-generates waveforms for the upcoming track.
for _, cb := range p.onBeforeSongChange {
cb(nowPlaying)
}
p.invokeOnSongChangeCallbacks()
p.invokeNoArgCallbacks(p.onPaused)
p.handleTimePosUpdate(false)
p.handleNextTrackUpdated()
return nil
}
func (p *playbackEngine) playTrackAt(idx int, startTime float64) error {
if l := p.getPlayQueueLength(); idx < 0 || idx >= l {
return fmt.Errorf("track index (%d) out of range (0-%d)", idx, l)
}
p.pendingLoadPaused = false
// scrobble current track if needed
p.checkScrobble()
p.alreadyScrobbled = true
@@ -315,7 +350,10 @@ func (p *playbackEngine) playTrackAt(idx int, startTime float64) error {
// Gets the curently playing media item, if any.
func (p *playbackEngine) NowPlaying() mediaprovider.MediaItem {
if p.nowPlayingIdx < 0 || p.getPlayQueueLength() == 0 || p.player.GetStatus().State == player.Stopped {
if p.nowPlayingIdx < 0 || p.getPlayQueueLength() == 0 {
return nil
}
if !p.pendingLoadPaused && p.player.GetStatus().State == player.Stopped {
return nil
}
return p.getPlayQueueItemAt(p.nowPlayingIdx)
@@ -395,6 +433,13 @@ func (p *playbackEngine) SetShuffle(shuffle bool) {
}
func (p *playbackEngine) PlaybackStatus() PlaybackStatus {
if p.pendingLoadPaused {
return PlaybackStatus{
State: player.Paused,
TimePos: p.pendingLoadStartTime,
Duration: p.curTrackDuration,
}
}
stat := p.pendingPlayerChangeStatus
if !p.pendingPlayerChange {
stat = p.CurrentPlayer().GetStatus()
@@ -466,6 +511,7 @@ func (p *playbackEngine) IsSeeking() bool {
}
func (p *playbackEngine) Stop() error {
p.pendingLoadPaused = false
return p.player.Stop(false)
}
@@ -478,6 +524,15 @@ func (p *playbackEngine) Pause() error {
}
func (p *playbackEngine) Continue() error {
if p.pendingLoadPaused {
p.pendingLoadPaused = false
// Use pendingTrackChangeNum so handleOnTrackChange doesn't advance nowPlayingIdx.
// Set alreadyScrobbled so it doesn't try to scrobble the not-yet-played track.
p.pendingTrackChangeNum = p.nowPlayingIdx
p.alreadyScrobbled = true
return p.setTrack(p.nowPlayingIdx, false, p.pendingLoadStartTime)
}
if p.pendingPlayerChange {
p.pendingPlayerChange = false
return p.playTrackAt(p.nowPlayingIdx, p.pendingPlayerChangeStatus.TimePos)
+9
View File
@@ -510,6 +510,13 @@ func (p *PlaybackManager) PlayTrackAt(idx int) {
p.cmdQueue.PlayTrackAt(idx)
}
// LoadTrackPaused sets up engine state as if the track at idx is loaded and
// paused at startTime, updating the UI and OS media integrations, without
// starting MPV. Call Continue (or PlayPause) to begin actual playback.
func (p *PlaybackManager) LoadTrackPaused(idx int, startTime float64) {
p.cmdQueue.LoadTrackPaused(idx, startTime)
}
func (p *PlaybackManager) PlayRandomSongs(genreName string) error {
return p.fetchAndPlayTracks(func() ([]*mediaprovider.Track, error) {
tr, err := p.engine.sm.Server.GetRandomTracks(genreName, p.appCfg.EnqueueBatchSize)
@@ -895,6 +902,8 @@ func (p *PlaybackManager) runCmdQueue(ctx context.Context) {
c.Arg.(*mediaprovider.RadioStation),
c.Arg2.(InsertQueueMode),
)
case cmdLoadTrackPaused:
logIfErr("LoadTrackPaused", p.engine.loadTrackPaused(c.Arg.(int), c.Arg2.(float64)))
case cmdForceRestartPlayback:
if mpv, ok := p.engine.CurrentPlayer().(*mpv.Player); ok {
log.Println("Force-restarting MPV playback")