From e1bc745645ef713b206421d14c7ab6db67707750 Mon Sep 17 00:00:00 2001 From: Drew Weymouth Date: Sat, 13 Jan 2024 11:53:18 -0800 Subject: [PATCH] refactor: split a lower-level playbackEngine out of PlaybackManager --- backend/playbackengine.go | 572 +++++++++++++++++++++++++++++++++++++ backend/playbackmanager.go | 554 ++++------------------------------- 2 files changed, 630 insertions(+), 496 deletions(-) create mode 100644 backend/playbackengine.go diff --git a/backend/playbackengine.go b/backend/playbackengine.go new file mode 100644 index 00000000..ebc84191 --- /dev/null +++ b/backend/playbackengine.go @@ -0,0 +1,572 @@ +package backend + +import ( + "context" + "errors" + "log" + "math/rand" + "time" + + "github.com/dweymouth/supersonic/backend/mediaprovider" + "github.com/dweymouth/supersonic/backend/player" + "github.com/dweymouth/supersonic/backend/util" + "github.com/dweymouth/supersonic/sharedutil" +) + +var ( + ReplayGainNone = player.ReplayGainNone.String() + ReplayGainAlbum = player.ReplayGainAlbum.String() + ReplayGainTrack = player.ReplayGainTrack.String() + ReplayGainAuto = "Auto" +) + +// The playback loop mode (LoopNone, LoopAll, LoopOne). +type LoopMode int + +const ( + LoopNone LoopMode = iota + LoopAll + LoopOne +) + +type playbackEngine struct { + ctx context.Context + cancelPollPos context.CancelFunc + sm *ServerManager + player player.BasePlayer + + playTimeStopwatch util.Stopwatch + curTrackTime float64 + latestTrackPosition float64 // cleared by checkScrobble + callbacksDisabled bool + + playQueue []*mediaprovider.Track + nowPlayingIdx int + wasStopped bool // true iff player was stopped before handleOnTrackChange invocation + loopMode LoopMode + + // to pass to onSongChange listeners; clear once listeners have been called + lastScrobbled *mediaprovider.Track + scrobbleCfg *ScrobbleConfig + transcodeCfg *TranscodingConfig + replayGainCfg ReplayGainConfig + + // registered callbacks + onSongChange []func(nowPlaying, justScrobbledIfAny *mediaprovider.Track) + onPlayTimeUpdate []func(float64, float64) + onLoopModeChange []func(LoopMode) + onVolumeChange []func(int) + onSeek []func() + onPaused []func() + onStopped []func() + onPlaying []func() + onPlayerChange []func() +} + +func NewPlaybackEngine( + ctx context.Context, + s *ServerManager, + p player.BasePlayer, + scrobbleCfg *ScrobbleConfig, + transcodeCfg *TranscodingConfig, +) *playbackEngine { + // clamp to 99% to avoid any possible rounding issues + scrobbleCfg.ThresholdPercent = clamp(scrobbleCfg.ThresholdPercent, 0, 99) + pm := &playbackEngine{ + ctx: ctx, + sm: s, + player: p, + scrobbleCfg: scrobbleCfg, + transcodeCfg: transcodeCfg, + nowPlayingIdx: -1, + wasStopped: true, + } + p.OnTrackChange(pm.handleOnTrackChange) + p.OnSeek(func() { + pm.doUpdateTimePos() + pm.invokeNoArgCallbacks(pm.onSeek) + }) + p.OnStopped(pm.handleOnStopped) + p.OnPaused(func() { + pm.playTimeStopwatch.Stop() + pm.stopPollTimePos() + pm.invokeNoArgCallbacks(pm.onPaused) + }) + p.OnPlaying(func() { + pm.playTimeStopwatch.Start() + pm.startPollTimePos() + pm.invokeNoArgCallbacks(pm.onPlaying) + }) + + s.OnLogout(func() { + pm.StopAndClearPlayQueue() + }) + + return pm +} + +func (p *playbackEngine) PlayTrackAt(idx int) error { + if idx < 0 || idx >= len(p.playQueue) { + return errors.New("track index out of range") + } + p.nowPlayingIdx = idx - 1 + return p.setTrack(idx, false) +} + +// Gets the curently playing song, if any. +func (p *playbackEngine) NowPlaying() *mediaprovider.Track { + if p.nowPlayingIdx < 0 || len(p.playQueue) == 0 || p.player.GetStatus().State == player.Stopped { + return nil + } + return p.playQueue[p.nowPlayingIdx] +} + +func (p *playbackEngine) NowPlayingIndex() int { + return int(p.nowPlayingIdx) +} + +func (p *playbackEngine) SetLoopMode(loopMode LoopMode) { + p.loopMode = loopMode + if p.nowPlayingIdx >= 0 { + p.setNextTrackBasedOnLoopMode(true) + } + + for _, cb := range p.onLoopModeChange { + cb(loopMode) + } +} + +func (p *playbackEngine) GetLoopMode() LoopMode { + return p.loopMode +} + +func (p *playbackEngine) PlayerStatus() player.Status { + return p.player.GetStatus() +} + +func (p *playbackEngine) SetVolume(vol int) error { + vol = clamp(vol, 0, 100) + if err := p.player.SetVolume(vol); err != nil { + return err + } + for _, cb := range p.onVolumeChange { + cb(vol) + } + return nil +} + +func (p *playbackEngine) CurrentPlayer() player.BasePlayer { + return p.player +} + +func (p *playbackEngine) SeekNext() error { + if p.CurrentPlayer().GetStatus().State == player.Stopped { + return nil + } + return p.PlayTrackAt(p.nowPlayingIdx + 1) +} + +func (p *playbackEngine) SeekBackOrPrevious() error { + if p.nowPlayingIdx == 0 || p.player.GetStatus().TimePos > 3 { + return p.player.SeekSeconds(0) + } + return p.PlayTrackAt(p.nowPlayingIdx - 1) +} + +// Seek to given absolute position in the current track by seconds. +func (p *playbackEngine) SeekSeconds(sec float64) error { + return p.player.SeekSeconds(sec) +} + +func (p *playbackEngine) IsSeeking() bool { + return p.player.IsSeeking() +} + +func (p *playbackEngine) Stop() error { + return p.player.Stop() +} + +func (p *playbackEngine) Pause() error { + return p.player.Pause() +} + +func (p *playbackEngine) Continue() error { + if p.PlayerStatus().State == player.Stopped { + return p.PlayTrackAt(0) + } + return p.player.Continue() +} + +// Load tracks into the play queue. +// If replacing the current queue (!appendToQueue), playback will be stopped. +func (p *playbackEngine) LoadTracks(tracks []*mediaprovider.Track, appendToQueue, shuffle bool) error { + if !appendToQueue { + p.player.Stop() + p.nowPlayingIdx = -1 + p.playQueue = nil + } + needToSetNext := appendToQueue && len(tracks) > 0 && p.nowPlayingIdx == len(p.playQueue)-1 + + newTracks := p.deepCopyTrackSlice(tracks) + if shuffle { + rand.Shuffle(len(newTracks), func(i, j int) { newTracks[i], newTracks[j] = newTracks[j], newTracks[i] }) + } + p.playQueue = append(p.playQueue, newTracks...) + + if needToSetNext { + p.setNextTrack(p.nowPlayingIdx + 1) + } + return nil +} + +// Stop playback and clear the play queue. +func (p *playbackEngine) StopAndClearPlayQueue() { + p.player.Stop() + p.doUpdateTimePos() + p.playQueue = nil + p.nowPlayingIdx = -1 +} + +func (p *playbackEngine) GetPlayQueue() []*mediaprovider.Track { + return p.deepCopyTrackSlice(p.playQueue) +} + +// Any time the user changes the favorite status of a track elsewhere in the app, +// this should be called to ensure the in-memory track model is updated. +func (p *playbackEngine) OnTrackFavoriteStatusChanged(id string, fav bool) { + if tr := sharedutil.FindTrackByID(id, p.playQueue); tr != nil { + tr.Favorite = fav + } +} + +// Any time the user changes the rating of a track elsewhere in the app, +// this should be called to ensure the in-memory track model is updated. +func (p *playbackEngine) OnTrackRatingChanged(id string, rating int) { + if tr := sharedutil.FindTrackByID(id, p.playQueue); tr != nil { + tr.Rating = rating + } +} + +// Replaces the play queue with the given set of tracks. +// Does not stop playback if the currently playing track is in the new queue, +// but updates the now playing index to point to the first instance of the track in the new queue. +func (p *playbackEngine) UpdatePlayQueue(tracks []*mediaprovider.Track) error { + newQueue := p.deepCopyTrackSlice(tracks) + newNowPlayingIdx := -1 + if p.nowPlayingIdx >= 0 { + nowPlayingID := p.playQueue[p.nowPlayingIdx].ID + for i, tr := range newQueue { + if tr.ID == nowPlayingID { + newNowPlayingIdx = i + break + } + } + } + + p.playQueue = newQueue + if p.nowPlayingIdx >= 0 && newNowPlayingIdx == -1 { + return p.Stop() + } + needToUpdateNext := p.nowPlayingIdx >= 0 + p.nowPlayingIdx = newNowPlayingIdx + if needToUpdateNext { + p.setNextTrackAfterQueueUpdate() + } + + return nil +} + +func (p *playbackEngine) RemoveTracksFromQueue(trackIDs []string) { + newQueue := make([]*mediaprovider.Track, 0, len(p.playQueue)-len(trackIDs)) + idSet := sharedutil.ToSet(trackIDs) + isPlayingTrackRemoved := false + isNextPlayingTrackremoved := false + nowPlaying := p.NowPlayingIndex() + newNowPlaying := nowPlaying + for i, tr := range p.playQueue { + if _, ok := idSet[tr.ID]; ok { + if i < nowPlaying { + // if removing a track earlier than the currently playing one (if any), + // decrement new now playing index by one to account for new position in queue + newNowPlaying-- + } else if i == nowPlaying { + isPlayingTrackRemoved = true + // If we are removing the currently playing track, we need to scrobble it + p.checkScrobble() + } else if nowPlaying >= 0 && i == nowPlaying+1 { + isNextPlayingTrackremoved = true + } + } else { + // not removing this track + newQueue = append(newQueue, tr) + } + } + p.playQueue = newQueue + p.nowPlayingIdx = newNowPlaying + if isPlayingTrackRemoved { + if newNowPlaying == len(newQueue) { + // we had been playing the last track, and removed it + p.Stop() + } else { + p.nowPlayingIdx -= 1 // will be incremented in newtrack callback from player + p.setTrack(newNowPlaying, false) + } + // setNextTrack and onSongChange callbacks will be handled + // when we receive new track event from player + } else if isNextPlayingTrackremoved { + if newNowPlaying < len(newQueue)-1 { + p.setNextTrack(p.nowPlayingIdx + 1) + } else { + // no next track to play + p.setNextTrack(-1) + } + } +} + +func (p *playbackEngine) SetReplayGainOptions(config ReplayGainConfig) { + rGainPlayer, ok := p.player.(player.ReplayGainPlayer) + if !ok { + log.Println("Error: player doesn't support ReplayGain") + return + } + + p.replayGainCfg = config + mode := player.ReplayGainNone + switch config.Mode { + case ReplayGainAuto: + mode = player.ReplayGainTrack + case ReplayGainTrack: + mode = player.ReplayGainTrack + case ReplayGainAlbum: + mode = player.ReplayGainAlbum + } + + rGainPlayer.SetReplayGainOptions(player.ReplayGainOptions{ + Mode: mode, + PreventClipping: config.PreventClipping, + PreampGain: config.PreampGainDB, + }) +} + +func (p *playbackEngine) SetReplayGainMode(mode player.ReplayGainMode) { + rGainPlayer, ok := p.player.(player.ReplayGainPlayer) + if !ok { + log.Println("Error: player doesn't support ReplayGain") + return + } + rGainPlayer.SetReplayGainOptions(player.ReplayGainOptions{ + PreventClipping: p.replayGainCfg.PreventClipping, + PreampGain: p.replayGainCfg.PreampGainDB, + Mode: mode, + }) +} + +func (p *playbackEngine) handleOnTrackChange() { + p.checkScrobble() // scrobble the previous song if needed + if p.player.GetStatus().State == player.Playing { + p.playTimeStopwatch.Start() + } + if p.wasStopped || p.loopMode != LoopOne { + p.nowPlayingIdx++ + if p.loopMode == LoopAll && p.nowPlayingIdx == len(p.playQueue) { + p.nowPlayingIdx = 0 // wrapped around + } + } + p.wasStopped = false + p.curTrackTime = float64(p.playQueue[p.nowPlayingIdx].Duration) + p.sendNowPlayingScrobble() // Must come before invokeOnChangeCallbacks b/c track may immediately be scrobbled + p.invokeOnSongChangeCallbacks() + p.doUpdateTimePos() + p.setNextTrackBasedOnLoopMode(false) +} + +func (p *playbackEngine) handleOnStopped() { + p.playTimeStopwatch.Stop() + p.checkScrobble() + p.stopPollTimePos() + p.doUpdateTimePos() + p.invokeOnSongChangeCallbacks() + p.invokeNoArgCallbacks(p.onStopped) + p.wasStopped = true + p.nowPlayingIdx = -1 +} + +func (p *playbackEngine) setNextTrackBasedOnLoopMode(onLoopModeChange bool) { + switch p.loopMode { + case LoopNone: + if p.nowPlayingIdx < len(p.playQueue)-1 { + p.setNextTrack(p.nowPlayingIdx + 1) + } else if onLoopModeChange { + // prev was LoopOne - need to erase next track + p.setNextTrack(-1) + } + case LoopOne: + p.setNextTrack(p.nowPlayingIdx) + case LoopAll: + if p.nowPlayingIdx >= len(p.playQueue)-1 { + p.setNextTrack(0) + } else if !onLoopModeChange { + // if onloopmodechange, prev mode was LoopNone and next track is already set + p.setNextTrack(p.nowPlayingIdx + 1) + } + } +} + +func (p *playbackEngine) setNextTrackAfterQueueUpdate() { + switch p.loopMode { + case LoopNone: + if p.nowPlayingIdx < len(p.playQueue)-1 { + p.setNextTrack(p.nowPlayingIdx + 1) + } else { + // need to erase next track + p.setNextTrack(-1) + } + case LoopOne: + p.setNextTrack(p.nowPlayingIdx) + case LoopAll: + if p.nowPlayingIdx >= len(p.playQueue)-1 { + p.setNextTrack(0) + } else { + p.setNextTrack(p.nowPlayingIdx + 1) + } + } +} + +func (p *playbackEngine) setTrack(idx int, next bool) error { + if urlP, ok := p.player.(player.URLPlayer); ok { + url := "" + if idx >= 0 { + var err error + url, err = p.sm.Server.GetStreamURL(p.playQueue[idx].ID, p.transcodeCfg.ForceRawFile) + if err != nil { + return err + } + } + if next { + return urlP.SetNextFile(url) + } + return urlP.PlayFile(url) + } else if trP, ok := p.player.(player.TrackPlayer); ok { + var track *mediaprovider.Track + if idx >= 0 { + track = p.playQueue[idx] + } + if next { + return trP.SetNextTrack(track) + } + return trP.PlayTrack(track) + } + panic("Unsupported player type") +} + +func (p *playbackEngine) setNextTrack(idx int) error { + return p.setTrack(idx, true) +} + +// call BEFORE updating p.nowPlayingIdx +func (p *playbackEngine) checkScrobble() { + if !p.scrobbleCfg.Enabled || len(p.playQueue) == 0 || p.nowPlayingIdx < 0 { + return + } + playDur := p.playTimeStopwatch.Elapsed() + if playDur.Seconds() < 0.1 || p.curTrackTime < 0.1 { + return + } + pcnt := playDur.Seconds() / p.curTrackTime * 100 + timeThresholdMet := p.scrobbleCfg.ThresholdTimeSeconds >= 0 && + playDur.Seconds() >= float64(p.scrobbleCfg.ThresholdTimeSeconds) + + track := p.playQueue[p.nowPlayingIdx] + var submission bool + server := p.sm.Server + if server.ClientDecidesScrobble() && (timeThresholdMet || pcnt >= float64(p.scrobbleCfg.ThresholdPercent)) { + track.PlayCount += 1 + p.lastScrobbled = track + submission = true + } + go server.TrackEndedPlayback(track.ID, int(p.latestTrackPosition), submission) + p.latestTrackPosition = 0 + p.playTimeStopwatch.Reset() +} + +func (p *playbackEngine) sendNowPlayingScrobble() { + if !p.scrobbleCfg.Enabled || len(p.playQueue) == 0 || p.nowPlayingIdx < 0 { + return + } + track := p.playQueue[p.nowPlayingIdx] + server := p.sm.Server + if !server.ClientDecidesScrobble() { + // server will count track as scrobbled as soon as it starts playing + p.lastScrobbled = track + track.PlayCount += 1 + } + go p.sm.Server.TrackBeganPlayback(track.ID) +} + +// creates a deep copy of the track info so that we can maintain our own state +// (play count increases, favorite, and rating) without messing up other views' track models +func (p *playbackEngine) deepCopyTrackSlice(tracks []*mediaprovider.Track) []*mediaprovider.Track { + newTracks := make([]*mediaprovider.Track, len(tracks)) + for i, tr := range tracks { + copy := *tr + newTracks[i] = © + } + return newTracks +} + +func (p *playbackEngine) invokeOnSongChangeCallbacks() { + if p.callbacksDisabled { + return + } + for _, cb := range p.onSongChange { + cb(p.NowPlaying(), p.lastScrobbled) + } + p.lastScrobbled = nil +} + +func (pm *playbackEngine) invokeNoArgCallbacks(cbs []func()) { + if pm.callbacksDisabled { + return + } + for _, cb := range cbs { + cb() + } +} + +func (p *playbackEngine) startPollTimePos() { + ctx, cancel := context.WithCancel(p.ctx) + p.cancelPollPos = cancel + pollingTick := time.NewTicker(250 * time.Millisecond) + + go func() { + for { + select { + case <-ctx.Done(): + pollingTick.Stop() + return + case <-pollingTick.C: + p.doUpdateTimePos() + } + } + }() +} + +func (p *playbackEngine) stopPollTimePos() { + if p.cancelPollPos != nil { + p.cancelPollPos() + p.cancelPollPos = nil + } +} + +func (p *playbackEngine) doUpdateTimePos() { + if p.callbacksDisabled { + return + } + s := p.player.GetStatus() + if s.TimePos > p.latestTrackPosition { + p.latestTrackPosition = s.TimePos + } + for _, cb := range p.onPlayTimeUpdate { + cb(s.TimePos, s.Duration) + } +} diff --git a/backend/playbackmanager.go b/backend/playbackmanager.go index fe1a343f..e5288a53 100644 --- a/backend/playbackmanager.go +++ b/backend/playbackmanager.go @@ -4,65 +4,15 @@ import ( "context" "errors" "log" - "math/rand" - "time" "github.com/dweymouth/supersonic/backend/mediaprovider" "github.com/dweymouth/supersonic/backend/player" - "github.com/dweymouth/supersonic/backend/util" - "github.com/dweymouth/supersonic/sharedutil" -) - -var ( - ReplayGainNone = player.ReplayGainNone.String() - ReplayGainAlbum = player.ReplayGainAlbum.String() - ReplayGainTrack = player.ReplayGainTrack.String() - ReplayGainAuto = "Auto" -) - -// The playback loop mode (LoopNone, LoopAll, LoopOne). -type LoopMode int - -const ( - LoopNone LoopMode = iota - LoopAll - LoopOne ) // A high-level MediaProvider-aware playback engine, serves as an // intermediary between the frontend and various Player backends. type PlaybackManager struct { - ctx context.Context - cancelPollPos context.CancelFunc - sm *ServerManager - player player.BasePlayer - - playTimeStopwatch util.Stopwatch - curTrackTime float64 - latestTrackPosition float64 // cleared by checkScrobble - callbacksDisabled bool - - playQueue []*mediaprovider.Track - nowPlayingIdx int - wasStopped bool // true iff player was stopped before handleOnTrackChange invocation - loopMode LoopMode - - // to pass to onSongChange listeners; clear once listeners have been called - lastScrobbled *mediaprovider.Track - scrobbleCfg *ScrobbleConfig - transcodeCfg *TranscodingConfig - replayGainCfg ReplayGainConfig - - // registered callbacks - onSongChange []func(nowPlaying, justScrobbledIfAny *mediaprovider.Track) - onPlayTimeUpdate []func(float64, float64) - onLoopModeChange []func(LoopMode) - onVolumeChange []func(int) - onSeek []func() - onPaused []func() - onStopped []func() - onPlaying []func() - onPlayerChange []func() + engine *playbackEngine } func NewPlaybackManager( @@ -72,114 +22,81 @@ func NewPlaybackManager( scrobbleCfg *ScrobbleConfig, transcodeCfg *TranscodingConfig, ) *PlaybackManager { - // clamp to 99% to avoid any possible rounding issues - scrobbleCfg.ThresholdPercent = clamp(scrobbleCfg.ThresholdPercent, 0, 99) - pm := &PlaybackManager{ - ctx: ctx, - sm: s, - player: p, - scrobbleCfg: scrobbleCfg, - transcodeCfg: transcodeCfg, - nowPlayingIdx: -1, - wasStopped: true, + return &PlaybackManager{ + engine: NewPlaybackEngine(ctx, s, p, scrobbleCfg, transcodeCfg), } - p.OnTrackChange(pm.handleOnTrackChange) - p.OnSeek(func() { - pm.doUpdateTimePos() - pm.invokeNoArgCallbacks(pm.onSeek) - }) - p.OnStopped(pm.handleOnStopped) - p.OnPaused(func() { - pm.playTimeStopwatch.Stop() - pm.stopPollTimePos() - pm.invokeNoArgCallbacks(pm.onPaused) - }) - p.OnPlaying(func() { - pm.playTimeStopwatch.Start() - pm.startPollTimePos() - pm.invokeNoArgCallbacks(pm.onPlaying) - }) - - s.OnLogout(func() { - pm.StopAndClearPlayQueue() - }) - - return pm } func (p *PlaybackManager) CurrentPlayer() player.BasePlayer { - return p.player + return p.engine.CurrentPlayer() } func (p *PlaybackManager) OnPlayerChange(cb func()) { - p.onPlayerChange = append(p.onPlayerChange, cb) + p.engine.onPlayerChange = append(p.engine.onPlayerChange, cb) } func (p *PlaybackManager) IsSeeking() bool { - return p.player.IsSeeking() + return p.engine.IsSeeking() } // Should only be called before quitting. // Disables playback state callbacks being sent func (p *PlaybackManager) DisableCallbacks() { - p.callbacksDisabled = true + p.engine.callbacksDisabled = true } // Gets the curently playing song, if any. func (p *PlaybackManager) NowPlaying() *mediaprovider.Track { - if p.nowPlayingIdx < 0 || len(p.playQueue) == 0 || p.player.GetStatus().State == player.Stopped { - return nil - } - return p.playQueue[p.nowPlayingIdx] + return p.engine.NowPlaying() } func (p *PlaybackManager) NowPlayingIndex() int { - return int(p.nowPlayingIdx) + return p.engine.NowPlayingIndex() } // Sets a callback that is notified whenever a new song begins playing. func (p *PlaybackManager) OnSongChange(cb func(nowPlaying *mediaprovider.Track, justScrobbledIfAny *mediaprovider.Track)) { - p.onSongChange = append(p.onSongChange, cb) + p.engine.onSongChange = append(p.engine.onSongChange, cb) } // Registers a callback that is notified whenever the play time should be updated. func (p *PlaybackManager) OnPlayTimeUpdate(cb func(float64, float64)) { - p.onPlayTimeUpdate = append(p.onPlayTimeUpdate, cb) + p.engine.onPlayTimeUpdate = append(p.engine.onPlayTimeUpdate, cb) } // Registers a callback that is notified whenever the loop mode changes. func (p *PlaybackManager) OnLoopModeChange(cb func(LoopMode)) { - p.onLoopModeChange = append(p.onLoopModeChange, cb) + p.engine.onLoopModeChange = append(p.engine.onLoopModeChange, cb) } // Registers a callback that is notified whenever the volume changes. func (p *PlaybackManager) OnVolumeChange(cb func(int)) { - p.onVolumeChange = append(p.onVolumeChange, cb) + p.engine.onVolumeChange = append(p.engine.onVolumeChange, cb) } // Registers a callback that is notified whenever the player has been seeked. func (p *PlaybackManager) OnSeek(cb func()) { - p.onSeek = append(p.onSeek, cb) + p.engine.onSeek = append(p.engine.onSeek, cb) } // Registers a callback that is notified whenever the player has been paused. func (p *PlaybackManager) OnPaused(cb func()) { - p.onPaused = append(p.onPaused, cb) + p.engine.onPaused = append(p.engine.onPaused, cb) } // Registers a callback that is notified whenever the player is stopped. func (p *PlaybackManager) OnStopped(cb func()) { - p.onStopped = append(p.onStopped, cb) + p.engine.onStopped = append(p.engine.onStopped, cb) } // Registers a callback that is notified whenever the player begins playing. func (p *PlaybackManager) OnPlaying(cb func()) { - p.onPlaying = append(p.onPlaying, cb) + p.engine.onPlaying = append(p.engine.onPlaying, cb) } // Loads the specified album into the play queue. func (p *PlaybackManager) LoadAlbum(albumID string, appendToQueue bool, shuffle bool) error { - album, err := p.sm.Server.GetAlbum(albumID) + album, err := p.engine.sm.Server.GetAlbum(albumID) if err != nil { return err } @@ -188,7 +105,7 @@ func (p *PlaybackManager) LoadAlbum(albumID string, appendToQueue bool, shuffle // Loads the specified playlist into the play queue. func (p *PlaybackManager) LoadPlaylist(playlistID string, appendToQueue bool, shuffle bool) error { - playlist, err := p.sm.Server.GetPlaylist(playlistID) + playlist, err := p.engine.sm.Server.GetPlaylist(playlistID) if err != nil { return err } @@ -198,59 +115,21 @@ func (p *PlaybackManager) LoadPlaylist(playlistID string, appendToQueue bool, sh // Load tracks into the play queue. // If replacing the current queue (!appendToQueue), playback will be stopped. func (p *PlaybackManager) LoadTracks(tracks []*mediaprovider.Track, appendToQueue, shuffle bool) error { - if !appendToQueue { - p.player.Stop() - p.nowPlayingIdx = -1 - p.playQueue = nil - } - needToSetNext := appendToQueue && len(tracks) > 0 && p.nowPlayingIdx == len(p.playQueue)-1 - - newTracks := p.deepCopyTrackSlice(tracks) - if shuffle { - rand.Shuffle(len(newTracks), func(i, j int) { newTracks[i], newTracks[j] = newTracks[j], newTracks[i] }) - } - p.playQueue = append(p.playQueue, newTracks...) - - if needToSetNext { - p.setNextTrack(p.nowPlayingIdx + 1) - } - return nil + return p.engine.LoadTracks(tracks, appendToQueue, shuffle) } // Replaces the play queue with the given set of tracks. // Does not stop playback if the currently playing track is in the new queue, // but updates the now playing index to point to the first instance of the track in the new queue. func (p *PlaybackManager) UpdatePlayQueue(tracks []*mediaprovider.Track) error { - newQueue := p.deepCopyTrackSlice(tracks) - newNowPlayingIdx := -1 - if p.nowPlayingIdx >= 0 { - nowPlayingID := p.playQueue[p.nowPlayingIdx].ID - for i, tr := range newQueue { - if tr.ID == nowPlayingID { - newNowPlayingIdx = i - break - } - } - } - - p.playQueue = newQueue - if p.nowPlayingIdx >= 0 && newNowPlayingIdx == -1 { - return p.Stop() - } - needToUpdateNext := p.nowPlayingIdx >= 0 - p.nowPlayingIdx = newNowPlayingIdx - if needToUpdateNext { - p.setNextTrackAfterQueueUpdate() - } - - return nil + return p.engine.UpdatePlayQueue(tracks) } func (p *PlaybackManager) PlayAlbum(albumID string, firstTrack int, shuffle bool) error { if err := p.LoadAlbum(albumID, false, shuffle); err != nil { return err } - if p.replayGainCfg.Mode == ReplayGainAuto { + if p.engine.replayGainCfg.Mode == ReplayGainAuto { p.SetReplayGainMode(player.ReplayGainAlbum) } return p.PlayTrackAt(firstTrack) @@ -260,45 +139,41 @@ func (p *PlaybackManager) PlayPlaylist(playlistID string, firstTrack int, shuffl if err := p.LoadPlaylist(playlistID, false, shuffle); err != nil { return err } - if p.replayGainCfg.Mode == ReplayGainAuto { + if p.engine.replayGainCfg.Mode == ReplayGainAuto { p.SetReplayGainMode(player.ReplayGainTrack) } return p.PlayTrackAt(firstTrack) } func (p *PlaybackManager) PlayTrack(trackID string) error { - tr, err := p.sm.Server.GetTrack(trackID) + tr, err := p.engine.sm.Server.GetTrack(trackID) if err != nil { return err } p.LoadTracks([]*mediaprovider.Track{tr}, false, false) - if p.replayGainCfg.Mode == ReplayGainAuto { + if p.engine.replayGainCfg.Mode == ReplayGainAuto { p.SetReplayGainMode(player.ReplayGainTrack) } return p.PlayFromBeginning() } func (p *PlaybackManager) PlayFromBeginning() error { - return p.PlayTrackAt(0) + return p.engine.PlayTrackAt(0) } func (p *PlaybackManager) PlayTrackAt(idx int) error { - if idx < 0 || idx >= len(p.playQueue) { - return errors.New("track index out of range") - } - p.nowPlayingIdx = idx - 1 - return p.setTrack(idx, false) + return p.engine.PlayTrackAt(idx) } func (p *PlaybackManager) PlayRandomSongs(genreName string) { p.fetchAndPlayTracks(func() ([]*mediaprovider.Track, error) { - return p.sm.Server.GetRandomTracks(genreName, 100) + return p.engine.sm.Server.GetRandomTracks(genreName, 100) }) } func (p *PlaybackManager) PlaySimilarSongs(id string) { p.fetchAndPlayTracks(func() ([]*mediaprovider.Track, error) { - return p.sm.Server.GetSimilarTracks(id, 100) + return p.engine.sm.Server.GetSimilarTracks(id, 100) }) } @@ -307,7 +182,7 @@ func (p *PlaybackManager) fetchAndPlayTracks(fetchFn func() ([]*mediaprovider.Tr log.Printf("error fetching tracks: %s", err.Error()) } else { p.LoadTracks(songs, false, false) - if p.replayGainCfg.Mode == ReplayGainAuto { + if p.engine.replayGainCfg.Mode == ReplayGainAuto { p.SetReplayGainMode(player.ReplayGainTrack) } p.PlayFromBeginning() @@ -315,183 +190,83 @@ func (p *PlaybackManager) fetchAndPlayTracks(fetchFn func() ([]*mediaprovider.Tr } func (p *PlaybackManager) GetPlayQueue() []*mediaprovider.Track { - return p.deepCopyTrackSlice(p.playQueue) + return p.engine.GetPlayQueue() } // Any time the user changes the favorite status of a track elsewhere in the app, // this should be called to ensure the in-memory track model is updated. func (p *PlaybackManager) OnTrackFavoriteStatusChanged(id string, fav bool) { - if tr := sharedutil.FindTrackByID(id, p.playQueue); tr != nil { - tr.Favorite = fav - } + p.engine.OnTrackFavoriteStatusChanged(id, fav) } // Any time the user changes the rating of a track elsewhere in the app, // this should be called to ensure the in-memory track model is updated. func (p *PlaybackManager) OnTrackRatingChanged(id string, rating int) { - if tr := sharedutil.FindTrackByID(id, p.playQueue); tr != nil { - tr.Rating = rating - } + p.engine.OnTrackRatingChanged(id, rating) } func (p *PlaybackManager) RemoveTracksFromQueue(trackIDs []string) { - newQueue := make([]*mediaprovider.Track, 0, len(p.playQueue)-len(trackIDs)) - idSet := sharedutil.ToSet(trackIDs) - isPlayingTrackRemoved := false - isNextPlayingTrackremoved := false - nowPlaying := p.NowPlayingIndex() - newNowPlaying := nowPlaying - for i, tr := range p.playQueue { - if _, ok := idSet[tr.ID]; ok { - if i < nowPlaying { - // if removing a track earlier than the currently playing one (if any), - // decrement new now playing index by one to account for new position in queue - newNowPlaying-- - } else if i == nowPlaying { - isPlayingTrackRemoved = true - // If we are removing the currently playing track, we need to scrobble it - p.checkScrobble() - } else if nowPlaying >= 0 && i == nowPlaying+1 { - isNextPlayingTrackremoved = true - } - } else { - // not removing this track - newQueue = append(newQueue, tr) - } - } - p.playQueue = newQueue - p.nowPlayingIdx = newNowPlaying - if isPlayingTrackRemoved { - if newNowPlaying == len(newQueue) { - // we had been playing the last track, and removed it - p.Stop() - } else { - p.nowPlayingIdx -= 1 // will be incremented in newtrack callback from player - p.setTrack(newNowPlaying, false) - } - // setNextTrack and onSongChange callbacks will be handled - // when we receive new track event from player - } else if isNextPlayingTrackremoved { - if newNowPlaying < len(newQueue)-1 { - p.setNextTrack(p.nowPlayingIdx + 1) - } else { - // no next track to play - p.setNextTrack(-1) - } - } + p.engine.RemoveTracksFromQueue(trackIDs) } // Stop playback and clear the play queue. func (p *PlaybackManager) StopAndClearPlayQueue() { - p.player.Stop() - p.doUpdateTimePos() - p.playQueue = nil - p.nowPlayingIdx = -1 + p.engine.StopAndClearPlayQueue() } func (p *PlaybackManager) SetReplayGainOptions(config ReplayGainConfig) { - rGainPlayer, ok := p.player.(player.ReplayGainPlayer) - if !ok { - log.Println("Error: player doesn't support ReplayGain") - return - } - - p.replayGainCfg = config - mode := player.ReplayGainNone - switch config.Mode { - case ReplayGainAuto: - mode = player.ReplayGainTrack - case ReplayGainTrack: - mode = player.ReplayGainTrack - case ReplayGainAlbum: - mode = player.ReplayGainAlbum - } - - rGainPlayer.SetReplayGainOptions(player.ReplayGainOptions{ - Mode: mode, - PreventClipping: config.PreventClipping, - PreampGain: config.PreampGainDB, - }) + p.engine.SetReplayGainOptions(config) } func (p *PlaybackManager) SetReplayGainMode(mode player.ReplayGainMode) { - rGainPlayer, ok := p.player.(player.ReplayGainPlayer) - if !ok { - log.Println("Error: player doesn't support ReplayGain") - return - } - rGainPlayer.SetReplayGainOptions(player.ReplayGainOptions{ - PreventClipping: p.replayGainCfg.PreventClipping, - PreampGain: p.replayGainCfg.PreampGainDB, - Mode: mode, - }) + p.engine.SetReplayGainMode(mode) } // Changes the loop mode of the player to the next one. // Useful for toggling UI elements, to change modes without knowing the current player mode. func (p *PlaybackManager) SetNextLoopMode() { - switch p.loopMode { + switch p.engine.loopMode { case LoopNone: - p.SetLoopMode(LoopAll) + p.engine.SetLoopMode(LoopAll) case LoopAll: - p.SetLoopMode(LoopOne) + p.engine.SetLoopMode(LoopOne) case LoopOne: - p.SetLoopMode(LoopNone) + p.engine.SetLoopMode(LoopNone) } } func (p *PlaybackManager) SetLoopMode(loopMode LoopMode) { - p.loopMode = loopMode - if p.nowPlayingIdx >= 0 { - p.setNextTrackBasedOnLoopMode(true) - } - - for _, cb := range p.onLoopModeChange { - cb(loopMode) - } + p.engine.SetLoopMode(loopMode) } func (p *PlaybackManager) GetLoopMode() LoopMode { - return p.loopMode + return p.engine.loopMode } func (p *PlaybackManager) PlayerStatus() player.Status { - return p.player.GetStatus() + return p.engine.PlayerStatus() } func (p *PlaybackManager) SetVolume(vol int) error { - vol = clamp(vol, 0, 100) - if err := p.player.SetVolume(vol); err != nil { - return err - } - for _, cb := range p.onVolumeChange { - cb(vol) - } - return nil + return p.engine.SetVolume(vol) } func (p *PlaybackManager) Volume() int { - return p.player.GetVolume() + return p.engine.CurrentPlayer().GetVolume() } func (p *PlaybackManager) SeekNext() error { - if p.CurrentPlayer().GetStatus().State == player.Stopped { - return nil - } - return p.PlayTrackAt(p.nowPlayingIdx + 1) + return p.engine.SeekNext() } func (p *PlaybackManager) SeekBackOrPrevious() error { - if p.nowPlayingIdx == 0 || p.player.GetStatus().TimePos > 3 { - return p.player.SeekSeconds(0) - } - return p.PlayTrackAt(p.nowPlayingIdx - 1) + return p.engine.SeekBackOrPrevious() } // Seek to given absolute position in the current track by seconds. func (p *PlaybackManager) SeekSeconds(sec float64) error { - return p.player.SeekSeconds(sec) + return p.engine.SeekSeconds(sec) } // Seek to a fractional position in the current track [0..1] @@ -501,243 +276,30 @@ func (p *PlaybackManager) SeekFraction(fraction float64) error { } else if fraction > 1 { fraction = 1 } - target := p.curTrackTime * fraction - return p.player.SeekSeconds(target) + target := p.engine.curTrackTime * fraction + return p.engine.SeekSeconds(target) } func (p *PlaybackManager) Stop() error { - return p.player.Stop() + return p.engine.Stop() } func (p *PlaybackManager) Pause() error { - return p.player.Pause() + return p.engine.Pause() } func (p *PlaybackManager) Continue() error { - if p.player.GetStatus().State == player.Stopped { - return p.PlayFromBeginning() - } - return p.player.Continue() + return p.engine.Continue() } func (p *PlaybackManager) PlayPause() error { - switch p.player.GetStatus().State { + switch p.engine.PlayerStatus().State { case player.Playing: - return p.player.Pause() + return p.engine.Pause() case player.Paused: - return p.player.Continue() + return p.engine.Continue() case player.Stopped: - return p.PlayFromBeginning() + return p.engine.PlayTrackAt(0) } return errors.New("unreached - invalid player state") } - -func (p *PlaybackManager) handleOnTrackChange() { - p.checkScrobble() // scrobble the previous song if needed - if p.player.GetStatus().State == player.Playing { - p.playTimeStopwatch.Start() - } - if p.wasStopped || p.loopMode != LoopOne { - p.nowPlayingIdx++ - if p.loopMode == LoopAll && p.nowPlayingIdx == len(p.playQueue) { - p.nowPlayingIdx = 0 // wrapped around - } - } - p.wasStopped = false - p.curTrackTime = float64(p.playQueue[p.nowPlayingIdx].Duration) - p.sendNowPlayingScrobble() // Must come before invokeOnChangeCallbacks b/c track may immediately be scrobbled - p.invokeOnSongChangeCallbacks() - p.doUpdateTimePos() - p.setNextTrackBasedOnLoopMode(false) -} - -func (p *PlaybackManager) handleOnStopped() { - p.playTimeStopwatch.Stop() - p.checkScrobble() - p.stopPollTimePos() - p.doUpdateTimePos() - p.invokeOnSongChangeCallbacks() - p.invokeNoArgCallbacks(p.onStopped) - p.wasStopped = true - p.nowPlayingIdx = -1 -} - -func (p *PlaybackManager) setNextTrackBasedOnLoopMode(onLoopModeChange bool) { - switch p.loopMode { - case LoopNone: - if p.nowPlayingIdx < len(p.playQueue)-1 { - p.setNextTrack(p.nowPlayingIdx + 1) - } else if onLoopModeChange { - // prev was LoopOne - need to erase next track - p.setNextTrack(-1) - } - case LoopOne: - p.setNextTrack(p.nowPlayingIdx) - case LoopAll: - if p.nowPlayingIdx >= len(p.playQueue)-1 { - p.setNextTrack(0) - } else if !onLoopModeChange { - // if onloopmodechange, prev mode was LoopNone and next track is already set - p.setNextTrack(p.nowPlayingIdx + 1) - } - } -} - -func (p *PlaybackManager) setNextTrackAfterQueueUpdate() { - switch p.loopMode { - case LoopNone: - if p.nowPlayingIdx < len(p.playQueue)-1 { - p.setNextTrack(p.nowPlayingIdx + 1) - } else { - // need to erase next track - p.setNextTrack(-1) - } - case LoopOne: - p.setNextTrack(p.nowPlayingIdx) - case LoopAll: - if p.nowPlayingIdx >= len(p.playQueue)-1 { - p.setNextTrack(0) - } else { - p.setNextTrack(p.nowPlayingIdx + 1) - } - } -} - -func (p *PlaybackManager) setTrack(idx int, next bool) error { - if urlP, ok := p.player.(player.URLPlayer); ok { - url := "" - if idx >= 0 { - var err error - url, err = p.sm.Server.GetStreamURL(p.playQueue[idx].ID, p.transcodeCfg.ForceRawFile) - if err != nil { - return err - } - } - if next { - return urlP.SetNextFile(url) - } - return urlP.PlayFile(url) - } else if trP, ok := p.player.(player.TrackPlayer); ok { - var track *mediaprovider.Track - if idx >= 0 { - track = p.playQueue[idx] - } - if next { - return trP.SetNextTrack(track) - } - return trP.PlayTrack(track) - } - panic("Unsupported player type") -} - -func (p *PlaybackManager) setNextTrack(idx int) error { - return p.setTrack(idx, true) -} - -// call BEFORE updating p.nowPlayingIdx -func (p *PlaybackManager) checkScrobble() { - if !p.scrobbleCfg.Enabled || len(p.playQueue) == 0 || p.nowPlayingIdx < 0 { - return - } - playDur := p.playTimeStopwatch.Elapsed() - if playDur.Seconds() < 0.1 || p.curTrackTime < 0.1 { - return - } - pcnt := playDur.Seconds() / p.curTrackTime * 100 - timeThresholdMet := p.scrobbleCfg.ThresholdTimeSeconds >= 0 && - playDur.Seconds() >= float64(p.scrobbleCfg.ThresholdTimeSeconds) - - track := p.playQueue[p.nowPlayingIdx] - var submission bool - server := p.sm.Server - if server.ClientDecidesScrobble() && (timeThresholdMet || pcnt >= float64(p.scrobbleCfg.ThresholdPercent)) { - track.PlayCount += 1 - p.lastScrobbled = track - submission = true - } - go server.TrackEndedPlayback(track.ID, int(p.latestTrackPosition), submission) - p.latestTrackPosition = 0 - p.playTimeStopwatch.Reset() -} - -func (p *PlaybackManager) sendNowPlayingScrobble() { - if !p.scrobbleCfg.Enabled || len(p.playQueue) == 0 || p.nowPlayingIdx < 0 { - return - } - track := p.playQueue[p.nowPlayingIdx] - server := p.sm.Server - if !server.ClientDecidesScrobble() { - // server will count track as scrobbled as soon as it starts playing - p.lastScrobbled = track - track.PlayCount += 1 - } - go p.sm.Server.TrackBeganPlayback(track.ID) -} - -// creates a deep copy of the track info so that we can maintain our own state -// (play count increases, favorite, and rating) without messing up other views' track models -func (p *PlaybackManager) deepCopyTrackSlice(tracks []*mediaprovider.Track) []*mediaprovider.Track { - newTracks := make([]*mediaprovider.Track, len(tracks)) - for i, tr := range tracks { - copy := *tr - newTracks[i] = © - } - return newTracks -} - -func (p *PlaybackManager) invokeOnSongChangeCallbacks() { - if p.callbacksDisabled { - return - } - for _, cb := range p.onSongChange { - cb(p.NowPlaying(), p.lastScrobbled) - } - p.lastScrobbled = nil -} - -func (pm *PlaybackManager) invokeNoArgCallbacks(cbs []func()) { - if pm.callbacksDisabled { - return - } - for _, cb := range cbs { - cb() - } -} - -func (p *PlaybackManager) startPollTimePos() { - ctx, cancel := context.WithCancel(p.ctx) - p.cancelPollPos = cancel - pollingTick := time.NewTicker(250 * time.Millisecond) - - go func() { - for { - select { - case <-ctx.Done(): - pollingTick.Stop() - return - case <-pollingTick.C: - p.doUpdateTimePos() - } - } - }() -} - -func (p *PlaybackManager) stopPollTimePos() { - if p.cancelPollPos != nil { - p.cancelPollPos() - p.cancelPollPos = nil - } -} - -func (p *PlaybackManager) doUpdateTimePos() { - if p.callbacksDisabled { - return - } - s := p.player.GetStatus() - if s.TimePos > p.latestTrackPosition { - p.latestTrackPosition = s.TimePos - } - for _, cb := range p.onPlayTimeUpdate { - cb(s.TimePos, s.Duration) - } -}