From dba540861fd28cb8c6c6bfd6dc70f12a93649183 Mon Sep 17 00:00:00 2001 From: Drew Weymouth Date: Sat, 21 Feb 2026 09:40:59 -0800 Subject: [PATCH 1/6] fix: defer MPV playback start until user unpauses on session restore MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- backend/app.go | 6 +- .../mediaprovider/subsonic/jukebox_mock.go | 1 + backend/playbackcommands.go | 13 +++++ backend/playbackengine.go | 57 ++++++++++++++++++- backend/playbackmanager.go | 9 +++ 5 files changed, 80 insertions(+), 6 deletions(-) create mode 100644 backend/mediaprovider/subsonic/jukebox_mock.go diff --git a/backend/app.go b/backend/app.go index 90b26ef..77769e3 100644 --- a/backend/app.go +++ b/backend/app.go @@ -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 diff --git a/backend/mediaprovider/subsonic/jukebox_mock.go b/backend/mediaprovider/subsonic/jukebox_mock.go new file mode 100644 index 0000000..81d2d25 --- /dev/null +++ b/backend/mediaprovider/subsonic/jukebox_mock.go @@ -0,0 +1 @@ +package subsonic diff --git a/backend/playbackcommands.go b/backend/playbackcommands.go index 3bb9d8b..441f011 100644 --- a/backend/playbackcommands.go +++ b/backend/playbackcommands.go @@ -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) diff --git a/backend/playbackengine.go b/backend/playbackengine.go index 3819e38..c34f5fb 100644 --- a/backend/playbackengine.go +++ b/backend/playbackengine.go @@ -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) diff --git a/backend/playbackmanager.go b/backend/playbackmanager.go index 41378bb..c79540f 100644 --- a/backend/playbackmanager.go +++ b/backend/playbackmanager.go @@ -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") From 6782e8d470839b390e30ad185339466f35709564 Mon Sep 17 00:00:00 2001 From: Drew Weymouth Date: Sat, 21 Feb 2026 10:17:20 -0800 Subject: [PATCH 2/6] make sure waveform shows up for track loaded paused when starting app --- backend/playbackmanager.go | 22 ++++++++++++++++------ 1 file changed, 16 insertions(+), 6 deletions(-) diff --git a/backend/playbackmanager.go b/backend/playbackmanager.go index c79540f..f6f1c8c 100644 --- a/backend/playbackmanager.go +++ b/backend/playbackmanager.go @@ -115,14 +115,20 @@ func (p *PlaybackManager) addOnTrackChangeHook() { }) p.engine.onBeforeSongChange = append(p.engine.onBeforeSongChange, func(item mediaprovider.MediaItem) { - if p.engine.playbackCfg.UseWaveformSeekbar { - if p.wfmGen != nil && item != nil && item.Metadata().Type == mediaprovider.MediaItemTypeTrack { - if _, ok := p.findWfmImageJob(item.Metadata().ID, true); !ok { - // start generating waveform image for next-up track - p.addWfmImageJob(p.wfmGen.StartWaveformGeneration(item.(*mediaprovider.Track))) - } + if item == nil || !p.engine.playbackCfg.UseWaveformSeekbar { + return + } + if p.wfmGen != nil && item.Metadata().Type == mediaprovider.MediaItemTypeTrack { + if _, ok := p.findWfmImageJob(item.Metadata().ID, true); !ok { + // start generating waveform image for next-up track + p.addWfmImageJob(p.wfmGen.StartWaveformGeneration(item.(*mediaprovider.Track))) } } + if p.isLoadTrackPaused() { + // we need to call handleWaveformImageSongChange to ensure the waveform image is updated + // for the track that is loaded paused when starting the app + p.handleWaveformImageSongChange(item) + } }) p.OnSongChange(func(item mediaprovider.MediaItem, _ *mediaprovider.Track) { @@ -517,6 +523,10 @@ func (p *PlaybackManager) LoadTrackPaused(idx int, startTime float64) { p.cmdQueue.LoadTrackPaused(idx, startTime) } +func (p *PlaybackManager) isLoadTrackPaused() bool { + return p.engine.pendingLoadPaused +} + 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) From 47668bcec9d3bcf1d6fd79bd818ad12bd44cbef2 Mon Sep 17 00:00:00 2001 From: Drew Weymouth Date: Fri, 27 Feb 2026 08:29:57 -0800 Subject: [PATCH 3/6] stop waveform from refreshing when unpausing the initial track --- backend/playbackmanager.go | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/backend/playbackmanager.go b/backend/playbackmanager.go index f6f1c8c..7ab190c 100644 --- a/backend/playbackmanager.go +++ b/backend/playbackmanager.go @@ -45,7 +45,8 @@ type PlaybackManager struct { wfmImageJobs [3]*WaveformImageJob // whether autoplay tracks are currently being fetched/enqueued - pendingAutoplay bool + pendingAutoplay bool + wasLoadTrackPaused bool } type RemotePlaybackDevice struct { @@ -128,11 +129,18 @@ func (p *PlaybackManager) addOnTrackChangeHook() { // we need to call handleWaveformImageSongChange to ensure the waveform image is updated // for the track that is loaded paused when starting the app p.handleWaveformImageSongChange(item) + p.wasLoadTrackPaused = true } }) p.OnSongChange(func(item mediaprovider.MediaItem, _ *mediaprovider.Track) { - p.handleWaveformImageSongChange(item) + if p.wasLoadTrackPaused { + // if the song change was triggered by LoadTrackPaused when starting the app, + // we already called handleWaveformImageSongChange in the onBeforeSongChange hook above + p.wasLoadTrackPaused = false + } else { + p.handleWaveformImageSongChange(item) + } if runtime.GOOS != "windows" { return From c3b97637246640a4be36ba035bb82249c475ad8d Mon Sep 17 00:00:00 2001 From: Drew Weymouth Date: Thu, 5 Mar 2026 06:42:22 -0800 Subject: [PATCH 4/6] handle stop properly when pendingLoadPaused --- backend/playbackengine.go | 6 +++++- ui/bottompanel.go | 5 ++++- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/backend/playbackengine.go b/backend/playbackengine.go index c34f5fb..7dfb832 100644 --- a/backend/playbackengine.go +++ b/backend/playbackengine.go @@ -511,7 +511,11 @@ func (p *playbackEngine) IsSeeking() bool { } func (p *playbackEngine) Stop() error { - p.pendingLoadPaused = false + if p.pendingLoadPaused { + p.pendingLoadPaused = false + p.pendingLoadStartTime = 0 + p.handleOnStopped() + } return p.player.Stop(false) } diff --git a/ui/bottompanel.go b/ui/bottompanel.go index c6c26b6..234cb14 100644 --- a/ui/bottompanel.go +++ b/ui/bottompanel.go @@ -47,7 +47,10 @@ func NewBottomPanel(pm *backend.PlaybackManager, im *backend.ImageManager, contr pm.OnPaused(util.FyneDoFunc(func() { bp.Controls.SetPlaying(false) })) pm.OnPlaying(util.FyneDoFunc(func() { bp.Controls.SetPlaying(true) })) - pm.OnStopped(util.FyneDoFunc(func() { bp.Controls.SetPlaying(false) })) + pm.OnStopped(util.FyneDoFunc(func() { + bp.Controls.SetPlaying(false) + bp.Controls.UpdatePlayTime(0, 0) + })) bp.NowPlaying = widgets.NewNowPlayingCard() bp.NowPlaying.ShowAlbumYear = cfg.AlbumsPage.ShowYears From cfcd7a52e4ee7d70824df9c127ff7d2e4ea8595b Mon Sep 17 00:00:00 2001 From: Drew Weymouth Date: Thu, 5 Mar 2026 06:54:51 -0800 Subject: [PATCH 5/6] delete accidental committed file --- backend/mediaprovider/subsonic/jukebox_mock.go | 1 - 1 file changed, 1 deletion(-) delete mode 100644 backend/mediaprovider/subsonic/jukebox_mock.go diff --git a/backend/mediaprovider/subsonic/jukebox_mock.go b/backend/mediaprovider/subsonic/jukebox_mock.go deleted file mode 100644 index 81d2d25..0000000 --- a/backend/mediaprovider/subsonic/jukebox_mock.go +++ /dev/null @@ -1 +0,0 @@ -package subsonic From 02e12b3cbecf3a6710cca00d585382e4ebdfbf1e Mon Sep 17 00:00:00 2001 From: Drew Weymouth Date: Thu, 5 Mar 2026 06:59:28 -0800 Subject: [PATCH 6/6] handle switch players while in pendingLoadPaused state --- backend/playbackengine.go | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/backend/playbackengine.go b/backend/playbackengine.go index 7dfb832..16e24be 100644 --- a/backend/playbackengine.go +++ b/backend/playbackengine.go @@ -196,9 +196,13 @@ func (p *playbackEngine) SetPlayer(pl player.BasePlayer) error { needToUnpause := false stat := p.CurrentPlayer().GetStatus() - if p.pendingPlayerChange { + if p.pendingPlayerChange || p.pendingLoadPaused { stat.State = player.Paused } + if p.pendingLoadPaused { + stat.TimePos = p.pendingLoadStartTime + stat.Duration = p.curTrackDuration + } switch stat.State { case player.Stopped: