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")