This change fixes two `TODO` comments, by scrobbling the currently playing track if it's removed from the queue, and only run OnSongChange callbacks when the playing track is affected.
373 lines
10 KiB
Go
373 lines
10 KiB
Go
package backend
|
|
|
|
import (
|
|
"context"
|
|
"log"
|
|
"time"
|
|
|
|
"github.com/dweymouth/supersonic/backend/mediaprovider"
|
|
"github.com/dweymouth/supersonic/backend/util"
|
|
"github.com/dweymouth/supersonic/player"
|
|
"github.com/dweymouth/supersonic/sharedutil"
|
|
)
|
|
|
|
const (
|
|
ReplayGainNone = string(player.ReplayGainNone)
|
|
ReplayGainAlbum = string(player.ReplayGainAlbum)
|
|
ReplayGainTrack = string(player.ReplayGainTrack)
|
|
)
|
|
|
|
// A high-level Subsonic-aware playback backend.
|
|
// Manages loading tracks into the Player queue,
|
|
// sending callbacks on play time updates and track changes.
|
|
type PlaybackManager struct {
|
|
ctx context.Context
|
|
cancelPollPos context.CancelFunc
|
|
pollingTick *time.Ticker
|
|
sm *ServerManager
|
|
player *player.Player
|
|
|
|
playTimeStopwatch util.Stopwatch
|
|
curTrackTime float64
|
|
callbacksDisabled bool
|
|
|
|
playQueue []*mediaprovider.Track
|
|
nowPlayingIdx int64
|
|
|
|
// to pass to onSongChange listeners; clear once listeners have been called
|
|
lastScrobbled *mediaprovider.Track
|
|
scrobbleCfg *ScrobbleConfig
|
|
|
|
onSongChange []func(nowPlaying, justScrobbledIfAny *mediaprovider.Track)
|
|
onPlayTimeUpdate []func(float64, float64)
|
|
}
|
|
|
|
func NewPlaybackManager(
|
|
ctx context.Context,
|
|
s *ServerManager,
|
|
p *player.Player,
|
|
scrobbleCfg *ScrobbleConfig,
|
|
) *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,
|
|
}
|
|
p.OnTrackChange(func(tracknum int64) {
|
|
if tracknum >= int64(len(pm.playQueue)) {
|
|
return
|
|
}
|
|
pm.checkScrobble(pm.playTimeStopwatch.Elapsed())
|
|
pm.playTimeStopwatch.Reset()
|
|
if pm.player.GetStatus().State == player.Playing {
|
|
pm.playTimeStopwatch.Start()
|
|
}
|
|
pm.nowPlayingIdx = tracknum
|
|
pm.curTrackTime = float64(pm.playQueue[pm.nowPlayingIdx].Duration)
|
|
pm.invokeOnSongChangeCallbacks()
|
|
pm.doUpdateTimePos()
|
|
pm.sendNowPlayingScrobble()
|
|
})
|
|
p.OnSeek(func() {
|
|
pm.doUpdateTimePos()
|
|
})
|
|
p.OnStopped(func() {
|
|
pm.playTimeStopwatch.Stop()
|
|
pm.checkScrobble(pm.playTimeStopwatch.Elapsed())
|
|
pm.playTimeStopwatch.Reset()
|
|
pm.stopPollTimePos()
|
|
pm.doUpdateTimePos()
|
|
pm.invokeOnSongChangeCallbacks()
|
|
})
|
|
p.OnPaused(func() {
|
|
pm.playTimeStopwatch.Stop()
|
|
pm.stopPollTimePos()
|
|
})
|
|
p.OnPlaying(func() {
|
|
pm.playTimeStopwatch.Start()
|
|
pm.startPollTimePos()
|
|
})
|
|
|
|
s.OnLogout(func() {
|
|
pm.StopAndClearPlayQueue()
|
|
})
|
|
|
|
return pm
|
|
}
|
|
|
|
func (p *PlaybackManager) IsSeeking() bool {
|
|
return p.player.IsSeeking()
|
|
}
|
|
|
|
// Should only be called before quitting.
|
|
// Disables playback state callbacks being sent
|
|
func (p *PlaybackManager) DisableCallbacks() {
|
|
p.callbacksDisabled = true
|
|
}
|
|
|
|
// Gets the curently playing song, if any.
|
|
func (p *PlaybackManager) NowPlaying() *mediaprovider.Track {
|
|
if len(p.playQueue) == 0 || p.player.GetStatus().State == player.Stopped {
|
|
return nil
|
|
}
|
|
return p.playQueue[p.nowPlayingIdx]
|
|
}
|
|
|
|
func (p *PlaybackManager) NowPlayingIndex() int {
|
|
return int(p.nowPlayingIdx)
|
|
}
|
|
|
|
// 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)
|
|
}
|
|
|
|
// 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)
|
|
}
|
|
|
|
// 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)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return p.LoadTracks(album.Tracks, appendToQueue, 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)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return p.LoadTracks(playlist.Tracks, appendToQueue, shuffle)
|
|
}
|
|
|
|
func (p *PlaybackManager) LoadTracks(tracks []*mediaprovider.Track, appendToQueue, shuffle bool) error {
|
|
if !appendToQueue {
|
|
p.player.Stop()
|
|
p.nowPlayingIdx = 0
|
|
p.playQueue = nil
|
|
}
|
|
nums := util.Range(len(tracks))
|
|
if shuffle {
|
|
util.ShuffleSlice(nums)
|
|
}
|
|
for _, i := range nums {
|
|
url, err := p.sm.Server.GetStreamURL(tracks[i].ID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
p.player.AppendFile(url)
|
|
// ensure a deep copy of the track info so that we can maintain our own state
|
|
// (tracking play count increases, favorite, and rating) without messing up
|
|
// other views' track models
|
|
tr := *tracks[i]
|
|
p.playQueue = append(p.playQueue, &tr)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (p *PlaybackManager) PlayAlbum(albumID string, firstTrack int, shuffle bool) error {
|
|
if err := p.LoadAlbum(albumID, false, shuffle); err != nil {
|
|
return err
|
|
}
|
|
if firstTrack <= 0 {
|
|
return p.player.PlayFromBeginning()
|
|
}
|
|
return p.player.PlayTrackAt(firstTrack)
|
|
}
|
|
|
|
func (p *PlaybackManager) PlayPlaylist(playlistID string, firstTrack int, shuffle bool) error {
|
|
if err := p.LoadPlaylist(playlistID, false, shuffle); err != nil {
|
|
return err
|
|
}
|
|
if firstTrack <= 0 {
|
|
return p.player.PlayFromBeginning()
|
|
}
|
|
return p.player.PlayTrackAt(firstTrack)
|
|
}
|
|
|
|
func (p *PlaybackManager) PlayFromBeginning() error {
|
|
return p.player.PlayFromBeginning()
|
|
}
|
|
|
|
func (p *PlaybackManager) PlayTrackAt(idx int) error {
|
|
return p.player.PlayTrackAt(idx)
|
|
}
|
|
|
|
func (p *PlaybackManager) PlayRandomSongs(genreName string) {
|
|
if songs, err := p.sm.Server.GetRandomTracks(genreName, 100); err != nil {
|
|
log.Printf("error getting random songs: %s", err.Error())
|
|
} else {
|
|
p.LoadTracks(songs, false, false)
|
|
p.PlayFromBeginning()
|
|
}
|
|
}
|
|
|
|
func (p *PlaybackManager) PlaySimilarSongs(id string) {
|
|
if songs, err := p.sm.Server.GetSimilarTracks(id, 100); err != nil {
|
|
log.Printf("error getting similar songs: %s", err.Error())
|
|
} else {
|
|
p.LoadTracks(songs, false, false)
|
|
p.PlayFromBeginning()
|
|
}
|
|
}
|
|
|
|
func (p *PlaybackManager) GetPlayQueue() []*mediaprovider.Track {
|
|
pq := make([]*mediaprovider.Track, len(p.playQueue))
|
|
for i, tr := range p.playQueue {
|
|
copy := *tr
|
|
pq[i] = ©
|
|
}
|
|
return pq
|
|
}
|
|
|
|
// 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
|
|
}
|
|
}
|
|
|
|
// 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
|
|
}
|
|
}
|
|
|
|
func (p *PlaybackManager) RemoveTracksFromQueue(trackIDs []string) {
|
|
newQueue := make([]*mediaprovider.Track, 0, len(p.playQueue)-len(trackIDs))
|
|
rmCount := 0
|
|
idSet := sharedutil.ToSet(trackIDs)
|
|
isPlayingTrackRemoved := false
|
|
for i, tr := range p.playQueue {
|
|
if _, ok := idSet[tr.ID]; ok {
|
|
// removing this track
|
|
if i == p.NowPlayingIndex() {
|
|
isPlayingTrackRemoved = true
|
|
// If we are removing the currently playing track, we need to scrobble it
|
|
p.checkScrobble(p.playTimeStopwatch.Elapsed())
|
|
}
|
|
if err := p.player.RemoveTrackAt(i - rmCount); err == nil {
|
|
rmCount++
|
|
} else {
|
|
log.Printf("error removing track: %v", err.Error())
|
|
// did not remove this track
|
|
newQueue = append(newQueue, tr)
|
|
}
|
|
} else {
|
|
// not removing this track
|
|
newQueue = append(newQueue, tr)
|
|
}
|
|
}
|
|
p.playQueue = newQueue
|
|
p.nowPlayingIdx = p.player.GetStatus().PlaylistPos
|
|
// fire on song change callbacks in case the playing track was removed
|
|
if isPlayingTrackRemoved {
|
|
p.invokeOnSongChangeCallbacks()
|
|
}
|
|
}
|
|
|
|
// Stop playback and clear the play queue.
|
|
func (p *PlaybackManager) StopAndClearPlayQueue() {
|
|
p.player.Stop()
|
|
p.player.ClearPlayQueue()
|
|
p.doUpdateTimePos()
|
|
p.playQueue = nil
|
|
}
|
|
|
|
func (p *PlaybackManager) SetReplayGainOptions(config ReplayGainConfig) {
|
|
p.player.SetReplayGainOptions(player.ReplayGainOptions{
|
|
Mode: player.ReplayGainMode(config.Mode),
|
|
PreventClipping: config.PreventClipping,
|
|
PreampGain: config.PreampGainDB,
|
|
})
|
|
}
|
|
|
|
// call BEFORE updating p.nowPlayingIdx
|
|
func (p *PlaybackManager) checkScrobble(playDur time.Duration) {
|
|
if !p.scrobbleCfg.Enabled || len(p.playQueue) == 0 || p.nowPlayingIdx < 0 {
|
|
return
|
|
}
|
|
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)
|
|
if timeThresholdMet || pcnt >= float64(p.scrobbleCfg.ThresholdPercent) {
|
|
song := p.playQueue[p.nowPlayingIdx]
|
|
log.Printf("Scrobbling %q", song.Name)
|
|
song.PlayCount += 1
|
|
p.lastScrobbled = song
|
|
go p.sm.Server.Scrobble(song.ID, true)
|
|
}
|
|
}
|
|
|
|
func (p *PlaybackManager) sendNowPlayingScrobble() {
|
|
if !p.scrobbleCfg.Enabled || len(p.playQueue) == 0 || p.nowPlayingIdx < 0 {
|
|
return
|
|
}
|
|
song := p.playQueue[p.nowPlayingIdx]
|
|
go p.sm.Server.Scrobble(song.ID, false)
|
|
}
|
|
|
|
func (p *PlaybackManager) invokeOnSongChangeCallbacks() {
|
|
if p.callbacksDisabled {
|
|
return
|
|
}
|
|
for _, cb := range p.onSongChange {
|
|
cb(p.NowPlaying(), p.lastScrobbled)
|
|
}
|
|
p.lastScrobbled = nil
|
|
}
|
|
|
|
func (p *PlaybackManager) startPollTimePos() {
|
|
ctx, cancel := context.WithCancel(p.ctx)
|
|
p.cancelPollPos = cancel
|
|
p.pollingTick = time.NewTicker(250 * time.Millisecond)
|
|
|
|
// TODO: fix occasional nil pointer dereference on app quit
|
|
go func() {
|
|
for {
|
|
select {
|
|
case <-ctx.Done():
|
|
p.pollingTick.Stop()
|
|
p.pollingTick = nil
|
|
return
|
|
case <-p.pollingTick.C:
|
|
p.doUpdateTimePos()
|
|
}
|
|
}
|
|
}()
|
|
}
|
|
|
|
func (p *PlaybackManager) doUpdateTimePos() {
|
|
if p.callbacksDisabled {
|
|
return
|
|
}
|
|
s := p.player.GetStatus()
|
|
for _, cb := range p.onPlayTimeUpdate {
|
|
cb(s.TimePos, s.Duration)
|
|
}
|
|
}
|
|
|
|
func (p *PlaybackManager) stopPollTimePos() {
|
|
if p.cancelPollPos != nil {
|
|
p.cancelPollPos()
|
|
p.cancelPollPos = nil
|
|
}
|
|
if p.pollingTick != nil {
|
|
p.pollingTick.Stop()
|
|
}
|
|
}
|