diff --git a/backend/app.go b/backend/app.go index 53fa84e..3dd2475 100644 --- a/backend/app.go +++ b/backend/app.go @@ -377,9 +377,7 @@ func (a *App) LoadSavedPlayQueue() error { return nil } - if err := a.PlaybackManager.LoadTracks(queue.Tracks, Replace, false); err != nil { - return err - } + a.PlaybackManager.LoadTracks(queue.Tracks, Replace, false) if queue.TrackIndex >= 0 && queue.TrackIndex < len(queue.Tracks) { // TODO: This isn't ideal but doesn't seem to cause an audible play-for-a-split-second artifact a.PlaybackManager.PlayTrackAt(queue.TrackIndex) diff --git a/backend/ipc/server.go b/backend/ipc/server.go index fa32ba8..26c50ac 100644 --- a/backend/ipc/server.go +++ b/backend/ipc/server.go @@ -9,16 +9,16 @@ import ( ) type PlaybackHandler interface { - PlayPause() error - Stop() error - Pause() error - Continue() error - SeekBackOrPrevious() error - SeekNext() error - SeekSeconds(float64) error - SeekBySeconds(float64) error + PlayPause() + Stop() + Pause() + Continue() + SeekBackOrPrevious() + SeekNext() + SeekSeconds(float64) + SeekBySeconds(float64) Volume() int - SetVolume(int) error + SetVolume(int) } type IPCServer interface { @@ -57,14 +57,12 @@ func (s *serverImpl) createHandler() http.Handler { w.WriteHeader(http.StatusNotFound) w.Write([]byte("The given path is not valid")) }) - m.HandleFunc(PingPath, s.makeSimpleEndpointHandler(func() error { return nil })) - m.HandleFunc(ShowPath, s.makeSimpleEndpointHandler(func() error { + m.HandleFunc(PingPath, s.makeSimpleEndpointHandler(func() {})) + m.HandleFunc(ShowPath, s.makeSimpleEndpointHandler(func() { s.showFn() - return nil })) - m.HandleFunc(QuitPath, s.makeSimpleEndpointHandler(func() error { + m.HandleFunc(QuitPath, s.makeSimpleEndpointHandler(func() { s.quitFn() - return nil })) m.HandleFunc(PlayPath, s.makeSimpleEndpointHandler(s.pbHandler.Continue)) m.HandleFunc(PausePath, s.makeSimpleEndpointHandler(s.pbHandler.Pause)) @@ -77,7 +75,8 @@ func (s *serverImpl) createHandler() http.Handler { m.HandleFunc(VolumePath, func(w http.ResponseWriter, r *http.Request) { v := r.URL.Query().Get("v") if vol, err := strconv.Atoi(v); err == nil { - s.writeSimpleResponse(w, s.pbHandler.SetVolume(vol)) + s.pbHandler.SetVolume(vol) + s.writeOK(w) } else { s.writeErr(w, err) } @@ -85,31 +84,25 @@ func (s *serverImpl) createHandler() http.Handler { return m } -func (s *serverImpl) makeSimpleEndpointHandler(f func() error) func(http.ResponseWriter, *http.Request) { +func (s *serverImpl) makeSimpleEndpointHandler(f func()) func(http.ResponseWriter, *http.Request) { return func(w http.ResponseWriter, r *http.Request) { - s.writeSimpleResponse(w, f()) + f() + s.writeOK(w) } } -func (s *serverImpl) makeFloatEndpointHandler(f func(float64) error, queryParam string) func(http.ResponseWriter, *http.Request) { +func (s *serverImpl) makeFloatEndpointHandler(f func(float64), queryParam string) func(http.ResponseWriter, *http.Request) { return func(w http.ResponseWriter, r *http.Request) { v := r.URL.Query().Get(queryParam) if val, err := strconv.ParseFloat(v, 64); err == nil { - s.writeSimpleResponse(w, f(val)) + f(val) + s.writeOK(w) } else { s.writeErr(w, err) } } } -func (s *serverImpl) writeSimpleResponse(w http.ResponseWriter, err error) { - if err == nil { - s.writeOK(w) - } else { - s.writeErr(w, err) - } -} - func (s *serverImpl) writeOK(w http.ResponseWriter) (int, error) { var r Response b, err := json.Marshal(&r) diff --git a/backend/mediaprovider/mediaprovider.go b/backend/mediaprovider/mediaprovider.go index 256405e..9fb1951 100644 --- a/backend/mediaprovider/mediaprovider.go +++ b/backend/mediaprovider/mediaprovider.go @@ -298,11 +298,16 @@ type JukeboxProvider interface { JukeboxStop() error JukeboxSeek(idx, seconds int) error JukeboxClear() error - JukeboxSet(trackID string) error JukeboxAdd(trackID string) error JukeboxRemove(idx int) error - JukeboxSetVolume(vol int) error JukeboxGetStatus() (*JukeboxStatus, error) + + // Performs a Clear followed by an Add to set the queue + // to contain a single track + JukeboxSet(trackID string) error + + // Sets the volume of the jukebox player (0-100) + JukeboxSetVolume(vol int) error } type JukeboxStatus struct { diff --git a/backend/mediaprovider/subsonic/jukebox.go b/backend/mediaprovider/subsonic/jukebox.go index de95d93..d705ed4 100644 --- a/backend/mediaprovider/subsonic/jukebox.go +++ b/backend/mediaprovider/subsonic/jukebox.go @@ -1,6 +1,7 @@ package subsonic import ( + "fmt" "strconv" "github.com/dweymouth/supersonic/backend/mediaprovider" @@ -24,8 +25,9 @@ func (s *subsonicMediaProvider) JukeboxClear() error { } func (s *subsonicMediaProvider) JukeboxSetVolume(vol int) error { + v := float64(vol) / 100 _, err := s.client.JukeboxControl("setGain", - map[string]string{"gain": strconv.Itoa(vol)}) + map[string]string{"gain": fmt.Sprintf("%0.2f", v)}) return err } diff --git a/backend/mpris.go b/backend/mpris.go index d0716c3..9d90da4 100644 --- a/backend/mpris.go +++ b/backend/mpris.go @@ -150,46 +150,51 @@ func (m *MPRISHandler) SupportedMimeTypes() ([]string, error) { // OrgMprisMediaPlayer2PlayerAdapter implementation func (m *MPRISHandler) Next() error { - return m.pm.SeekNext() + m.pm.SeekNext() + return nil } func (m *MPRISHandler) Previous() error { - return m.pm.SeekBackOrPrevious() + m.pm.SeekBackOrPrevious() + return nil } func (m *MPRISHandler) Pause() error { if m.pm.PlayerStatus().State == player.Playing { - return m.pm.PlayPause() + m.pm.PlayPause() } return nil } func (m *MPRISHandler) PlayPause() error { - return m.pm.PlayPause() + m.pm.PlayPause() + return nil } func (m *MPRISHandler) Stop() error { - return m.pm.Stop() + m.pm.Stop() + return nil } func (m *MPRISHandler) Play() error { switch m.pm.PlayerStatus().State { case player.Paused: - return m.pm.PlayPause() + m.pm.PlayPause() case player.Stopped: - return m.pm.PlayFromBeginning() + m.pm.PlayFromBeginning() } return nil } func (m *MPRISHandler) Seek(offset types.Microseconds) error { // MPRIS seek command is relative to current position - return m.pm.SeekBySeconds(microsecondsToSeconds(offset)) + m.pm.SeekBySeconds(microsecondsToSeconds(offset)) + return nil } func (m *MPRISHandler) SetPosition(trackId string, position types.Microseconds) error { if m.curTrackPath == trackId { - return m.pm.SeekSeconds(microsecondsToSeconds(position)) + m.pm.SeekSeconds(microsecondsToSeconds(position)) } return nil } @@ -297,7 +302,8 @@ func (m *MPRISHandler) Volume() (float64, error) { } func (m *MPRISHandler) SetVolume(v float64) error { - return m.pm.SetVolume(int(v * 100)) + m.pm.SetVolume(int(v * 100)) + return nil } func (m *MPRISHandler) Position() (int64, error) { diff --git a/backend/playbackcommands.go b/backend/playbackcommands.go new file mode 100644 index 0000000..3e9c5b9 --- /dev/null +++ b/backend/playbackcommands.go @@ -0,0 +1,222 @@ +package backend + +import ( + "slices" + "sync" + + "github.com/dweymouth/supersonic/backend/mediaprovider" +) + +type playbackCommandType int + +const ( + cmdStop playbackCommandType = iota + cmdContinue + cmdPause + cmdPlayTrackAt // arg: int + cmdSeekSeconds // arg: float64 + cmdSeekFwdBackN // arg: int + cmdVolume // arg: int + cmdLoopMode // arg: LoopMode + cmdStopAndClearPlayQueue + cmdUpdatePlayQueue // arg: []mediaprovider.MediaItem + cmdRemoveTracksFromQueue // arg: []int + // arg: []mediaprovider.MediaItem + // arg2: InsertMode + // arg3: bool (shuffle) + cmdLoadItems + cmdLoadRadioStation // arg: *mediaprovider.RadioStation, arg2: InsertQueueMode +) + +type playbackCommand struct { + Type playbackCommandType + Arg any + Arg2 any + Arg3 any +} + +// playbackCommandQueue is a queue to accumulate player commands from the UI +// commands are processed by the playback engine as fast as they can, but if +// more commands arrive before the player can respond to them, they will queue up +// and some commands may coalesce together (e.g. multiple volume commands into just one) +type playbackCommandQueue struct { + mutex sync.Mutex + queue []playbackCommand + cmdAvailable *sync.Cond + nextChan chan playbackCommand +} + +func NewCommandQueue() *playbackCommandQueue { + c := &playbackCommandQueue{} + c.nextChan = make(chan playbackCommand) + c.cmdAvailable = sync.NewCond(&c.mutex) + go c.chanWriter() + return c +} + +func (c *playbackCommandQueue) C() <-chan playbackCommand { + return c.nextChan +} + +func (c *playbackCommandQueue) Stop() { + c.filterCommandsAndAdd([]playbackCommandType{cmdContinue, cmdPause, cmdStop}, + playbackCommand{Type: cmdStop}) +} + +func (c *playbackCommandQueue) Continue() { + c.filterCommandsAndAdd([]playbackCommandType{cmdContinue, cmdPause, cmdStop}, + playbackCommand{Type: cmdContinue}) +} + +func (c *playbackCommandQueue) Pause() { + c.filterCommandsAndAdd([]playbackCommandType{cmdContinue, cmdPause, cmdStop}, + playbackCommand{Type: cmdPause}) +} + +func (c *playbackCommandQueue) PlayTrackAt(idx int) { + c.filterCommandsAndAdd([]playbackCommandType{cmdContinue, cmdPause, cmdStop, cmdPlayTrackAt}, + playbackCommand{Type: cmdPlayTrackAt, Arg: idx}) +} + +func (c *playbackCommandQueue) StopAndClearPlayQueue() { + c.filterCommandsAndAdd([]playbackCommandType{cmdContinue, cmdPause, cmdStop, cmdStopAndClearPlayQueue}, + playbackCommand{Type: cmdStopAndClearPlayQueue}) +} + +func (c *playbackCommandQueue) SetVolume(vol int) { + c.filterCommandsAndAdd([]playbackCommandType{cmdVolume}, + playbackCommand{Type: cmdVolume, Arg: vol}) +} + +func (c *playbackCommandQueue) SetLoopMode(mode LoopMode) { + c.filterCommandsAndAdd([]playbackCommandType{cmdLoopMode}, + playbackCommand{Type: cmdLoopMode, Arg: mode}) +} + +func (c *playbackCommandQueue) SeekSeconds(s float64) { + c.filterCommandsAndAdd([]playbackCommandType{cmdSeekSeconds}, + playbackCommand{Type: cmdSeekSeconds, Arg: s}) +} + +func (c *playbackCommandQueue) SeekNext() { + c.seekBackOrFwd(1) +} + +func (c *playbackCommandQueue) SeekBackOrPrevious() { + c.seekBackOrFwd(-1) +} + +func (c *playbackCommandQueue) UpdatePlayQueue(items []mediaprovider.MediaItem) { + c.filterCommandsAndAdd([]playbackCommandType{cmdUpdatePlayQueue}, + playbackCommand{Type: cmdUpdatePlayQueue, Arg: items}) +} + +func (c *playbackCommandQueue) RemoveItemsFromQueue(idxs []int) { + c.mutex.Lock() + c.queue = append(c.queue, playbackCommand{ + Type: cmdRemoveTracksFromQueue, + Arg: idxs, + }) + c.mutex.Unlock() + c.cmdAvailable.Signal() +} + +func (c *playbackCommandQueue) LoadRadioStation(radio *mediaprovider.RadioStation, insertMode InsertQueueMode) { + c.mutex.Lock() + c.queue = append(c.queue, playbackCommand{ + Type: cmdLoadRadioStation, + Arg: radio, + Arg2: insertMode, + }) + c.mutex.Unlock() + c.cmdAvailable.Signal() +} + +func (c *playbackCommandQueue) LoadItems(items []mediaprovider.MediaItem, insertQueueMode InsertQueueMode, shuffle bool) { + c.mutex.Lock() + c.queue = append(c.queue, playbackCommand{ + Type: cmdLoadItems, + Arg: items, + Arg2: insertQueueMode, + Arg3: shuffle, + }) + c.mutex.Unlock() + c.cmdAvailable.Signal() +} + +func (c *playbackCommandQueue) filterCommandsAndAdd(excludeTypes []playbackCommandType, command playbackCommand) { + c.mutex.Lock() + j := 0 + for _, cmd := range c.queue { + if slices.Contains(excludeTypes, cmd.Type) { + continue + } + c.queue[j] = cmd + j++ + } + c.queue = c.queue[:j] + c.queue = append(c.queue, command) + c.mutex.Unlock() + c.cmdAvailable.Signal() +} + +func (c *playbackCommandQueue) seekBackOrFwd(direction int) { + // find the index of the last seekBackOrFwd command + // in the queue that can be coalesced with this one + lastIdx := -1 + c.mutex.Lock() + done := false + for i := len(c.queue) - 1; i >= 0 && !done; i-- { + cmd := c.queue[i] + switch cmd.Type { + case cmdSeekFwdBackN: + lastIdx = i + case cmdRemoveTracksFromQueue, cmdLoadItems, cmdPlayTrackAt, + cmdLoadRadioStation, cmdUpdatePlayQueue, cmdStopAndClearPlayQueue: + // any queue-modifying command means we can't coalesce any + // more seekFwdBackN commands before here + done = true + } + } + + if lastIdx == -1 { + // no coalescable seekFwdBackN commands, just append new one + c.queue = append(c.queue, playbackCommand{Type: cmdSeekFwdBackN, Arg: direction}) + } else { + newQueue := make([]playbackCommand, 0, len(c.queue)) + // copy over all cmds past the first coalescable idx + newQueue = append(newQueue, c.queue[0:lastIdx]...) + n := direction + for i := lastIdx; i < len(c.queue); i++ { + if cmd := c.queue[i]; cmd.Type == cmdSeekFwdBackN { + // coalesce this cmd with the new one + n += cmd.Arg.(int) + } else { + // copy over other non-seekFwdBackN command + newQueue = append(newQueue, cmd) + } + } + newQueue = append(newQueue, playbackCommand{ + Type: cmdSeekFwdBackN, + Arg: n, + }) + c.queue = newQueue + } + + c.mutex.Unlock() + c.cmdAvailable.Signal() +} + +func (c *playbackCommandQueue) chanWriter() { + for { + c.mutex.Lock() + for len(c.queue) == 0 { + c.cmdAvailable.Wait() + } + cmd := c.queue[0] + copy(c.queue, c.queue[1:]) + c.queue = c.queue[:len(c.queue)-1] + c.mutex.Unlock() + c.nextChan <- cmd + } +} diff --git a/backend/playbackengine.go b/backend/playbackengine.go index a68f94d..470e53c 100644 --- a/backend/playbackengine.go +++ b/backend/playbackengine.go @@ -48,11 +48,12 @@ type playbackEngine struct { latestTrackPosition float64 // cleared by checkScrobble callbacksDisabled bool - playQueue []mediaprovider.MediaItem - nowPlayingIdx int - isRadio bool - wasStopped bool // true iff player was stopped before handleOnTrackChange invocation - loopMode LoopMode + playQueue []mediaprovider.MediaItem + nowPlayingIdx int + isRadio bool + wasStopped bool // true iff player was stopped before handleOnTrackChange invocation + noIncrementNextTrackChange bool // true iff the nowPlayingIndex should not be incremente don the next onTrackChange + loopMode LoopMode // to pass to onSongChange listeners; clear once listeners have been called lastScrobbled *mediaprovider.Track @@ -119,8 +120,12 @@ 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) + p.noIncrementNextTrackChange = true + err := p.setTrack(idx, false) + if err == nil { + p.nowPlayingIdx = idx + } + return err } // Gets the curently playing media item, if any. @@ -183,6 +188,22 @@ func (p *playbackEngine) SeekBackOrPrevious() error { return p.PlayTrackAt(p.nowPlayingIdx - 1) } +func (p *playbackEngine) SeekFwdBackN(n int) error { + idx := p.nowPlayingIdx + if n < 0 && p.player.GetStatus().TimePos > 3 { + n += 1 // first seek back is just seek to beginning of current + } + if n == 0 || (idx == 0 && n < 0) { + return p.player.SeekSeconds(0) // seek back in current song + } + lastIdx := len(p.playQueue) - 1 + if idx == lastIdx && n > 0 { + return nil // already on last track, nothing to seek next to + } + newIdx := minInt(lastIdx, maxInt(0, idx+n)) + return p.PlayTrackAt(newIdx) +} + // Seek to given absolute position in the current track by seconds. func (p *playbackEngine) SeekSeconds(sec float64) error { if p.isRadio { @@ -213,14 +234,14 @@ func (p *playbackEngine) Continue() error { // Load items into the play queue. // If replacing the current queue (!appendToQueue), playback will be stopped. func (p *playbackEngine) LoadItems(items []mediaprovider.MediaItem, insertQueueMode InsertQueueMode, shuffle bool) error { - newItems := p.deepCopyMediaItemSlice(items) + newItems := deepCopyMediaItemSlice(items) return p.doLoaditems(newItems, insertQueueMode, shuffle) } // Load tracks into the play queue. // If replacing the current queue (!appendToQueue), playback will be stopped. func (p *playbackEngine) LoadTracks(tracks []*mediaprovider.Track, insertQueueMode InsertQueueMode, shuffle bool) error { - newTracks := p.copyTrackSliceToMediaItemSlice(tracks) + newTracks := copyTrackSliceToMediaItemSlice(tracks) return p.doLoaditems(newTracks, insertQueueMode, shuffle) } @@ -279,7 +300,6 @@ func (p *playbackEngine) LoadRadioStation(radio *mediaprovider.RadioStation, ins func (p *playbackEngine) StopAndClearPlayQueue() { changed := len(p.playQueue) > 0 p.player.Stop() - p.doUpdateTimePos(false) p.playQueue = nil p.nowPlayingIdx = -1 if changed { @@ -288,7 +308,7 @@ func (p *playbackEngine) StopAndClearPlayQueue() { } func (p *playbackEngine) GetPlayQueue() []mediaprovider.MediaItem { - return p.deepCopyMediaItemSlice(p.playQueue) + return deepCopyMediaItemSlice(p.playQueue) } // Any time the user changes the favorite status of a track elsewhere in the app, @@ -315,7 +335,7 @@ func (p *playbackEngine) OnTrackRatingChanged(id string, rating int) { // 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(items []mediaprovider.MediaItem) error { - newQueue := p.deepCopyMediaItemSlice(items) + newQueue := deepCopyMediaItemSlice(items) newNowPlayingIdx := -1 if p.nowPlayingIdx >= 0 { nowPlayingID := p.playQueue[p.nowPlayingIdx].Metadata().ID @@ -433,12 +453,13 @@ func (p *playbackEngine) handleOnTrackChange() { if p.player.GetStatus().State == player.Playing { p.playTimeStopwatch.Start() } - if p.wasStopped || p.loopMode != LoopOne { + if !p.noIncrementNextTrackChange && (p.wasStopped || p.loopMode != LoopOne) { p.nowPlayingIdx++ if p.loopMode == LoopAll && p.nowPlayingIdx == len(p.playQueue) { p.nowPlayingIdx = 0 // wrapped around } } + p.noIncrementNextTrackChange = false nowPlaying := p.playQueue[p.nowPlayingIdx] _, isRadio := nowPlaying.(*mediaprovider.RadioStation) p.isRadio = isRadio @@ -591,7 +612,7 @@ func (p *playbackEngine) sendNowPlayingScrobble() { // 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) deepCopyMediaItemSlice(tracks []mediaprovider.MediaItem) []mediaprovider.MediaItem { +func deepCopyMediaItemSlice(tracks []mediaprovider.MediaItem) []mediaprovider.MediaItem { newTracks := make([]mediaprovider.MediaItem, len(tracks)) for i, tr := range tracks { newTracks[i] = tr.Copy() @@ -599,7 +620,7 @@ func (p *playbackEngine) deepCopyMediaItemSlice(tracks []mediaprovider.MediaItem return newTracks } -func (p *playbackEngine) copyTrackSliceToMediaItemSlice(tracks []*mediaprovider.Track) []mediaprovider.MediaItem { +func copyTrackSliceToMediaItemSlice(tracks []*mediaprovider.Track) []mediaprovider.MediaItem { newTracks := make([]mediaprovider.MediaItem, len(tracks)) for i, tr := range tracks { newTracks[i] = tr.Copy() @@ -668,3 +689,17 @@ func (p *playbackEngine) doUpdateTimePos(seeked bool) { cb(s.TimePos, duration, seeked) } } + +func minInt(a, b int) int { + if a < b { + return a + } + return b +} + +func maxInt(a, b int) int { + if a > b { + return a + } + return b +} diff --git a/backend/playbackmanager.go b/backend/playbackmanager.go index e6edd7b..83c4dfa 100644 --- a/backend/playbackmanager.go +++ b/backend/playbackmanager.go @@ -2,7 +2,6 @@ package backend import ( "context" - "errors" "log" "math/rand" @@ -13,7 +12,8 @@ import ( // A high-level MediaProvider-aware playback engine, serves as an // intermediary between the frontend and various Player backends. type PlaybackManager struct { - engine *playbackEngine + engine *playbackEngine + cmdQueue *playbackCommandQueue } func NewPlaybackManager( @@ -23,9 +23,14 @@ func NewPlaybackManager( scrobbleCfg *ScrobbleConfig, transcodeCfg *TranscodingConfig, ) *PlaybackManager { - return &PlaybackManager{ - engine: NewPlaybackEngine(ctx, s, p, scrobbleCfg, transcodeCfg), + e := NewPlaybackEngine(ctx, s, p, scrobbleCfg, transcodeCfg) + q := NewCommandQueue() + pm := &PlaybackManager{ + engine: e, + cmdQueue: q, } + go pm.runCmdQueue(ctx) + return pm } func (p *PlaybackManager) CurrentPlayer() player.BasePlayer { @@ -106,7 +111,8 @@ func (p *PlaybackManager) LoadAlbum(albumID string, insertQueueMode InsertQueueM if err != nil { return err } - return p.LoadTracks(album.Tracks, insertQueueMode, shuffle) + p.LoadTracks(album.Tracks, insertQueueMode, shuffle) + return nil } // Loads the specified playlist into the play queue. @@ -115,26 +121,28 @@ func (p *PlaybackManager) LoadPlaylist(playlistID string, insertQueueMode Insert if err != nil { return err } - return p.LoadTracks(playlist.Tracks, insertQueueMode, shuffle) + p.LoadTracks(playlist.Tracks, insertQueueMode, shuffle) + return nil } // Load tracks into the play queue. // If replacing the current queue (!appendToQueue), playback will be stopped. -func (p *PlaybackManager) LoadTracks(tracks []*mediaprovider.Track, insertQueueMode InsertQueueMode, shuffle bool) error { - return p.engine.LoadTracks(tracks, insertQueueMode, shuffle) +func (p *PlaybackManager) LoadTracks(tracks []*mediaprovider.Track, insertQueueMode InsertQueueMode, shuffle bool) { + items := copyTrackSliceToMediaItemSlice(tracks) + p.cmdQueue.LoadItems(items, insertQueueMode, shuffle) } // Load items into the play queue. // If replacing the current queue (!appendToQueue), playback will be stopped. -func (p *PlaybackManager) LoadItems(items []mediaprovider.MediaItem, insertQueueMode InsertQueueMode, shuffle bool) error { - return p.engine.LoadItems(items, insertQueueMode, shuffle) +func (p *PlaybackManager) LoadItems(items []mediaprovider.MediaItem, insertQueueMode InsertQueueMode, shuffle bool) { + p.cmdQueue.LoadItems(items, insertQueueMode, 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(items []mediaprovider.MediaItem) error { - return p.engine.UpdatePlayQueue(items) +func (p *PlaybackManager) UpdatePlayQueue(items []mediaprovider.MediaItem) { + p.cmdQueue.UpdatePlayQueue(items) } func (p *PlaybackManager) PlayAlbum(albumID string, firstTrack int, shuffle bool) error { @@ -144,7 +152,8 @@ func (p *PlaybackManager) PlayAlbum(albumID string, firstTrack int, shuffle bool if p.engine.replayGainCfg.Mode == ReplayGainAuto { p.SetReplayGainMode(player.ReplayGainAlbum) } - return p.PlayTrackAt(firstTrack) + p.PlayTrackAt(firstTrack) + return nil } func (p *PlaybackManager) PlayPlaylist(playlistID string, firstTrack int, shuffle bool) error { @@ -154,7 +163,8 @@ func (p *PlaybackManager) PlayPlaylist(playlistID string, firstTrack int, shuffl if p.engine.replayGainCfg.Mode == ReplayGainAuto { p.SetReplayGainMode(player.ReplayGainTrack) } - return p.PlayTrackAt(firstTrack) + p.PlayTrackAt(firstTrack) + return nil } func (p *PlaybackManager) PlayTrack(trackID string) error { @@ -166,13 +176,14 @@ func (p *PlaybackManager) PlayTrack(trackID string) error { if p.engine.replayGainCfg.Mode == ReplayGainAuto { p.SetReplayGainMode(player.ReplayGainTrack) } - return p.PlayFromBeginning() + p.PlayFromBeginning() + return nil } func (p *PlaybackManager) ShuffleArtistAlbums(artistID string) { artist, err := p.engine.sm.Server.GetArtist(artistID) if err != nil { - log.Printf(err.Error()) + log.Printf("failed to get artist: %v\n", err) return } if len(artist.Albums) == 0 { @@ -196,7 +207,7 @@ func (p *PlaybackManager) ShuffleArtistAlbums(artistID string) { func (p *PlaybackManager) PlayArtistDiscography(artistID string, shuffleTracks bool) { tr, err := p.engine.sm.Server.GetArtistTracks(artistID) if err != nil { - log.Printf(err.Error()) + log.Printf("failed to get artist tracks: %v\n", err) return } p.LoadTracks(tr, Replace, shuffleTracks) @@ -210,12 +221,12 @@ func (p *PlaybackManager) PlayArtistDiscography(artistID string, shuffleTracks b p.PlayFromBeginning() } -func (p *PlaybackManager) PlayFromBeginning() error { - return p.engine.PlayTrackAt(0) +func (p *PlaybackManager) PlayFromBeginning() { + p.cmdQueue.PlayTrackAt(0) } -func (p *PlaybackManager) PlayTrackAt(idx int) error { - return p.engine.PlayTrackAt(idx) +func (p *PlaybackManager) PlayTrackAt(idx int) { + p.cmdQueue.PlayTrackAt(idx) } func (p *PlaybackManager) PlayRandomSongs(genreName string) { @@ -231,12 +242,12 @@ func (p *PlaybackManager) PlaySimilarSongs(id string) { } func (p *PlaybackManager) LoadRadioStation(station *mediaprovider.RadioStation, queueMode InsertQueueMode) { - p.engine.LoadRadioStation(station, queueMode) + p.cmdQueue.LoadRadioStation(station, queueMode) } -func (p *PlaybackManager) PlayRadioStation(station *mediaprovider.RadioStation) error { +func (p *PlaybackManager) PlayRadioStation(station *mediaprovider.RadioStation) { p.LoadRadioStation(station, Replace) - return p.PlayFromBeginning() + p.PlayFromBeginning() } func (p *PlaybackManager) fetchAndPlayTracks(fetchFn func() ([]*mediaprovider.Track, error)) { @@ -268,12 +279,12 @@ func (p *PlaybackManager) OnTrackRatingChanged(id string, rating int) { } func (p *PlaybackManager) RemoveTracksFromQueue(idxs []int) { - p.engine.RemoveTracksFromQueue(idxs) + p.cmdQueue.RemoveItemsFromQueue(idxs) } // Stop playback and clear the play queue. func (p *PlaybackManager) StopAndClearPlayQueue() { - p.engine.StopAndClearPlayQueue() + p.cmdQueue.StopAndClearPlayQueue() } func (p *PlaybackManager) SetReplayGainOptions(config ReplayGainConfig) { @@ -289,17 +300,16 @@ func (p *PlaybackManager) SetReplayGainMode(mode player.ReplayGainMode) { func (p *PlaybackManager) SetNextLoopMode() { switch p.engine.loopMode { case LoopNone: - p.engine.SetLoopMode(LoopAll) + p.cmdQueue.SetLoopMode(LoopAll) case LoopAll: - p.engine.SetLoopMode(LoopOne) + p.cmdQueue.SetLoopMode(LoopOne) case LoopOne: - p.engine.SetLoopMode(LoopNone) - + p.cmdQueue.SetLoopMode(LoopNone) } } func (p *PlaybackManager) SetLoopMode(loopMode LoopMode) { - p.engine.SetLoopMode(loopMode) + p.cmdQueue.SetLoopMode(loopMode) } func (p *PlaybackManager) GetLoopMode() LoopMode { @@ -310,29 +320,29 @@ func (p *PlaybackManager) PlayerStatus() player.Status { return p.engine.PlayerStatus() } -func (p *PlaybackManager) SetVolume(vol int) error { - return p.engine.SetVolume(vol) +func (p *PlaybackManager) SetVolume(vol int) { + p.cmdQueue.SetVolume(vol) } func (p *PlaybackManager) Volume() int { return p.engine.CurrentPlayer().GetVolume() } -func (p *PlaybackManager) SeekNext() error { - return p.engine.SeekNext() +func (p *PlaybackManager) SeekNext() { + p.cmdQueue.SeekNext() } -func (p *PlaybackManager) SeekBackOrPrevious() error { - return p.engine.SeekBackOrPrevious() +func (p *PlaybackManager) SeekBackOrPrevious() { + p.cmdQueue.SeekBackOrPrevious() } // Seek to given absolute position in the current track by seconds. -func (p *PlaybackManager) SeekSeconds(sec float64) error { - return p.engine.SeekSeconds(sec) +func (p *PlaybackManager) SeekSeconds(sec float64) { + p.cmdQueue.SeekSeconds(sec) } // Seek by given relative position in the current track by seconds. -func (p *PlaybackManager) SeekBySeconds(sec float64) error { +func (p *PlaybackManager) SeekBySeconds(sec float64) { status := p.engine.PlayerStatus() target := status.TimePos + sec if target < 0 { @@ -340,40 +350,84 @@ func (p *PlaybackManager) SeekBySeconds(sec float64) error { } else if target > status.Duration { target = status.Duration } - return p.engine.SeekSeconds(target) + p.cmdQueue.SeekSeconds(target) } // Seek to a fractional position in the current track [0..1] -func (p *PlaybackManager) SeekFraction(fraction float64) error { +func (p *PlaybackManager) SeekFraction(fraction float64) { if fraction < 0 { fraction = 0 } else if fraction > 1 { fraction = 1 } target := p.engine.curTrackDuration * fraction - return p.engine.SeekSeconds(target) + p.cmdQueue.SeekSeconds(target) } -func (p *PlaybackManager) Stop() error { - return p.engine.Stop() +func (p *PlaybackManager) Stop() { + p.cmdQueue.Stop() } -func (p *PlaybackManager) Pause() error { - return p.engine.Pause() +func (p *PlaybackManager) Pause() { + p.cmdQueue.Pause() } -func (p *PlaybackManager) Continue() error { - return p.engine.Continue() +func (p *PlaybackManager) Continue() { + p.cmdQueue.Continue() } -func (p *PlaybackManager) PlayPause() error { +func (p *PlaybackManager) PlayPause() { switch p.engine.PlayerStatus().State { case player.Playing: - return p.engine.Pause() + p.Pause() case player.Paused: - return p.engine.Continue() + p.Continue() case player.Stopped: - return p.engine.PlayTrackAt(0) + p.PlayTrackAt(0) + } +} + +func (p *PlaybackManager) runCmdQueue(ctx context.Context) { + for { + select { + case <-ctx.Done(): + return + case c := <-p.cmdQueue.C(): + switch c.Type { + case cmdStop: + p.engine.Stop() + case cmdContinue: + p.engine.Continue() + case cmdPause: + p.engine.Pause() + case cmdPlayTrackAt: + p.engine.PlayTrackAt(c.Arg.(int)) + case cmdSeekSeconds: + p.engine.SeekSeconds(c.Arg.(float64)) + case cmdSeekFwdBackN: + p.engine.SeekFwdBackN(c.Arg.(int)) + case cmdVolume: + p.engine.SetVolume(c.Arg.(int)) + case cmdLoopMode: + p.engine.SetLoopMode(c.Arg.(LoopMode)) + case cmdStopAndClearPlayQueue: + p.engine.StopAndClearPlayQueue() + case cmdUpdatePlayQueue: + p.engine.UpdatePlayQueue(c.Arg.([]mediaprovider.MediaItem)) + case cmdRemoveTracksFromQueue: + p.engine.RemoveTracksFromQueue(c.Arg.([]int)) + case cmdLoadItems: + p.engine.LoadItems( + c.Arg.([]mediaprovider.MediaItem), + c.Arg2.(InsertQueueMode), + c.Arg3.(bool), + ) + case cmdLoadRadioStation: + p.engine.LoadRadioStation( + c.Arg.(*mediaprovider.RadioStation), + c.Arg2.(InsertQueueMode), + ) + } + } } - return errors.New("unreached - invalid player state") } diff --git a/backend/player/jukebox/jukeboxplayer.go b/backend/player/jukebox/jukeboxplayer.go index d3cca0e..3518beb 100644 --- a/backend/player/jukebox/jukeboxplayer.go +++ b/backend/player/jukebox/jukeboxplayer.go @@ -1,6 +1,8 @@ package jukebox import ( + "time" + "github.com/dweymouth/supersonic/backend/mediaprovider" "github.com/dweymouth/supersonic/backend/player" ) @@ -12,25 +14,27 @@ const ( ) type JukeboxPlayer struct { + player.BasePlayerCallbackImpl + provider mediaprovider.JukeboxProvider - state int // stopped, playing, paused - volume int - seeking bool - numTracks int + state int // stopped, playing, paused + volume int + seeking bool - curTrack int - curTrackDuration float64 - startTrackTime float64 - startedAtUnixSecs float64 + curTrack int + queueLength int + curTrackDuration float64 + startTrackTime float64 + startedAtUnixMilli int64 + nextTrackTimer *time.Timer } func (j *JukeboxPlayer) SetVolume(vol int) error { - go func() { - if err := j.provider.JukeboxSetVolume(vol); err == nil { - j.volume = vol - } - }() + if err := j.provider.JukeboxSetVolume(vol); err != nil { + return err + } + j.volume = vol return nil } @@ -38,26 +42,16 @@ func (j *JukeboxPlayer) GetVolume() int { return j.volume } -func (j *JukeboxPlayer) PlayTrackAt(idx int) error { - go func() { - if err := j.provider.JukeboxSeek(idx, 0); err == nil { - j.curTrack = idx - j.Continue() - } - }() - return nil -} - func (j *JukeboxPlayer) Continue() error { if j.state == playing { return nil } - go func() { - if err := j.provider.JukeboxStart(); err != nil { - return - } - j.state = playing - }() + if err := j.startAndUpdateTime(); err != nil { + return err + } + + j.state = playing + j.InvokeOnPlaying() return nil } @@ -65,12 +59,12 @@ func (j *JukeboxPlayer) Pause() error { if j.state != playing { return nil } - go func() { - if err := j.provider.JukeboxStop(); err != nil { - return - } - j.state = paused - }() + if err := j.provider.JukeboxStop(); err != nil { + return err + } + // TODO: calculate paused at time + j.state = paused + j.InvokeOnPaused() return nil } @@ -78,38 +72,53 @@ func (j *JukeboxPlayer) Stop() error { if j.state == stopped { return nil } - go func() { - if err := j.provider.JukeboxStop(); err != nil { - return - } - j.state = stopped - }() + if err := j.provider.JukeboxStop(); err != nil { + return err + } + j.state = stopped + j.InvokeOnStopped() return nil } -func (j *JukeboxPlayer) SeekPrevious() error { - track := j.curTrack - if track > 0 { - track = j.curTrack - 1 +func (j *JukeboxPlayer) PlayTrack(track *mediaprovider.Track) error { + if err := j.provider.JukeboxSet(track.ID); err != nil { + return err } - return j.PlayTrackAt(track) + j.startTrackTime = 0 + if err := j.startAndUpdateTime(); err != nil { + return err + } + + j.curTrack = 0 + j.queueLength = 1 + j.curTrackDuration = float64(track.Duration) + + return nil } -func (j *JukeboxPlayer) SeekNext() error { - track := j.curTrack - if track >= j.numTracks { - return nil +func (j *JukeboxPlayer) SetNextTrack(track *mediaprovider.Track) error { + // we need to replace the last track in the queue, remove it first + if j.curTrack < j.queueLength-1 { + if err := j.provider.JukeboxRemove(j.curTrack + 1); err != nil { + return err + } + j.queueLength -= 1 } - return j.PlayTrackAt(track + 1) + // append the new track to the queue + if err := j.provider.JukeboxAdd(track.ID); err != nil { + return err + } + j.queueLength += 1 + return nil + } func (j *JukeboxPlayer) SeekSeconds(secs float64) error { j.seeking = true - go func() { - j.provider.JukeboxSeek(j.curTrack, int(secs)) - j.seeking = false - }() - return nil + err := j.provider.JukeboxSeek(j.curTrack, int(secs)) + j.seeking = false + j.InvokeOnSeek() + return err } func (j *JukeboxPlayer) IsSeeking() bool { @@ -130,3 +139,20 @@ func (j *JukeboxPlayer) GetStatus() player.Status { State: state, } } + +func (j *JukeboxPlayer) startAndUpdateTime() error { + beforeStart := time.Now() + if err := j.provider.JukeboxStart(); err != nil { + return err + } + afterStart := time.Now() + + // assume track started playing at (ie has been playing for) + // half the round-trip latency + j.startedAtUnixMilli = time.Now().Add(-afterStart.Sub(beforeStart)).UnixMilli() + return nil +} + +func (j *JukeboxPlayer) handleNextTrack() { + +} diff --git a/backend/player/mpv/player.go b/backend/player/mpv/player.go index 076f83f..18f50e2 100644 --- a/backend/player/mpv/player.go +++ b/backend/player/mpv/player.go @@ -7,8 +7,8 @@ import ( "math" "strconv" - "github.com/supersonic-app/go-mpv" "github.com/dweymouth/supersonic/backend/player" + "github.com/supersonic-app/go-mpv" ) // Error returned by many Player functions if called before the player has not been initialized. @@ -50,6 +50,8 @@ var _ player.URLPlayer = (*Player)(nil) // Player encapsulates the mpv instance and provides functions // to control it and to check its status. type Player struct { + player.BasePlayerCallbackImpl + mpv *mpv.Mpv initialized bool vol int @@ -66,13 +68,6 @@ type Player struct { peaksEnabled bool bgCancel context.CancelFunc - - // callbacks - onPaused []func() - onStopped []func() - onPlaying []func() - onSeek []func() - onTrackChange []func() } // Returns a new player. @@ -403,33 +398,6 @@ func (p *Player) IsSeeking() bool { return p.seeking && p.status.State == player.Playing } -// Registers a callback which is invoked when the player transitions to the Paused state. -func (p *Player) OnPaused(cb func()) { - p.onPaused = append(p.onPaused, cb) -} - -// Registers a callback which is invoked when the player transitions to the Stopped state. -func (p *Player) OnStopped(cb func()) { - p.onStopped = append(p.onStopped, cb) -} - -// Registers a callback which is invoked when the player transitions to the Playing state. -func (p *Player) OnPlaying(cb func()) { - p.onPlaying = append(p.onPlaying, cb) -} - -// Registers a callback which is invoked whenever a seek event occurs. -func (p *Player) OnSeek(cb func()) { - p.onSeek = append(p.onSeek, cb) -} - -// Registers a callback which is invoked when the currently playing track changes, -// or when playback begins at any time from the Stopped state. -// Callback is invoked with the index of the currently playing track (zero-based). -func (p *Player) OnTrackChange(cb func()) { - p.onTrackChange = append(p.onTrackChange, cb) -} - // Destroy the player. func (p *Player) Destroy() { if p.bgCancel != nil { @@ -466,23 +434,11 @@ func (p *Player) GetPeaks() (float64, float64, float64, float64) { func (p *Player) setState(s player.State) { switch { case s == player.Playing && p.status.State != player.Playing: - defer func() { - for _, cb := range p.onPlaying { - cb() - } - }() + defer p.InvokeOnPlaying() case s == player.Paused && p.status.State != player.Paused: - defer func() { - for _, cb := range p.onPaused { - cb() - } - }() + defer p.InvokeOnPaused() case s == player.Stopped && p.status.State != player.Stopped: - defer func() { - for _, cb := range p.onStopped { - cb() - } - }() + defer p.InvokeOnStopped() } p.status.State = s } @@ -523,21 +479,15 @@ func (p *Player) eventHandler(ctx context.Context) { p.seeking = false } case mpv.EVENT_SEEK: - for _, cb := range p.onSeek { - cb() - } + p.InvokeOnSeek() case mpv.EVENT_FILE_LOADED: p.curPlaylistPos, _ = p.getInt64Property("playlist-pos") if p.status.State == player.Paused { // seek while paused switches to a new file // mpv does not fire seek event in this case - for _, cb := range p.onSeek { - cb() - } - } - for _, cb := range p.onTrackChange { - cb() + p.InvokeOnSeek() } + p.InvokeOnTrackChange() case mpv.EVENT_IDLE: p.status.Duration = 0 p.status.TimePos = 0 diff --git a/backend/player/player.go b/backend/player/player.go index 5ab77e1..778c941 100644 --- a/backend/player/player.go +++ b/backend/player/player.go @@ -82,3 +82,68 @@ func (r ReplayGainMode) String() string { return "no" } } + +type BasePlayerCallbackImpl struct { + onPaused []func() + onStopped []func() + onPlaying []func() + onSeek []func() + onTrackChange []func() +} + +// Registers a callback which is invoked when the player transitions to the Paused state. +func (p *BasePlayerCallbackImpl) OnPaused(cb func()) { + p.onPaused = append(p.onPaused, cb) +} + +// Registers a callback which is invoked when the player transitions to the Stopped state. +func (p *BasePlayerCallbackImpl) OnStopped(cb func()) { + p.onStopped = append(p.onStopped, cb) +} + +// Registers a callback which is invoked when the player transitions to the Playing state. +func (p *BasePlayerCallbackImpl) OnPlaying(cb func()) { + p.onPlaying = append(p.onPlaying, cb) +} + +// Registers a callback which is invoked whenever a seek event occurs. +func (p *BasePlayerCallbackImpl) OnSeek(cb func()) { + p.onSeek = append(p.onSeek, cb) +} + +// Registers a callback which is invoked when the currently playing track changes, +// or when playback begins at any time from the Stopped state. +// Callback is invoked with the index of the currently playing track (zero-based). +func (p *BasePlayerCallbackImpl) OnTrackChange(cb func()) { + p.onTrackChange = append(p.onTrackChange, cb) +} + +func (p *BasePlayerCallbackImpl) InvokeOnPaused() { + for _, cb := range p.onPaused { + cb() + } +} + +func (p *BasePlayerCallbackImpl) InvokeOnPlaying() { + for _, cb := range p.onPlaying { + cb() + } +} + +func (p *BasePlayerCallbackImpl) InvokeOnStopped() { + for _, cb := range p.onStopped { + cb() + } +} + +func (p *BasePlayerCallbackImpl) InvokeOnSeek() { + for _, cb := range p.onSeek { + cb() + } +} + +func (p *BasePlayerCallbackImpl) InvokeOnTrackChange() { + for _, cb := range p.onTrackChange { + cb() + } +} diff --git a/ui/bottompanel.go b/ui/bottompanel.go index 383d013..c54b8cd 100644 --- a/ui/bottompanel.go +++ b/ui/bottompanel.go @@ -106,7 +106,7 @@ func NewBottomPanel(pm *backend.PlaybackManager, im *backend.ImageManager, contr pm.OnLoopModeChange(bp.AuxControls.SetLoopMode) pm.OnVolumeChange(bp.AuxControls.VolumeControl.SetVolume) bp.AuxControls.VolumeControl.OnSetVolume = func(v int) { - _ = pm.SetVolume(v) + pm.SetVolume(v) } bp.AuxControls.OnChangeLoopMode(func() { pm.SetNextLoopMode() diff --git a/ui/controller/connectactions.go b/ui/controller/connectactions.go index 8692447..d9006c8 100644 --- a/ui/controller/connectactions.go +++ b/ui/controller/connectactions.go @@ -158,7 +158,7 @@ func (c *Controller) ConnectPlayQueuelistActions(list *widgets.PlayQueueList) { } list.OnAddToPlaylist = c.DoAddTracksToPlaylistWorkflow list.OnPlayItemAt = func(tracknum int) { - _ = c.App.PlaybackManager.PlayTrackAt(tracknum) + c.App.PlaybackManager.PlayTrackAt(tracknum) } list.OnShowArtistPage = func(artistID string) { c.NavigateTo(ArtistRoute(artistID)) diff --git a/ui/mainwindow.go b/ui/mainwindow.go index 0d24089..706117a 100644 --- a/ui/mainwindow.go +++ b/ui/mainwindow.go @@ -241,13 +241,13 @@ func (m *MainWindow) SetupSystemTrayMenu(appName string, fyneApp fyne.App) { if desk, ok := fyneApp.(desktop.App); ok { menu := fyne.NewMenu(appName, fyne.NewMenuItem(fmt.Sprintf("%s/%s", lang.L("Play"), lang.L("Pause")), func() { - _ = m.App.PlaybackManager.PlayPause() + m.App.PlaybackManager.PlayPause() }), fyne.NewMenuItem(lang.L("Previous"), func() { - _ = m.App.PlaybackManager.SeekBackOrPrevious() + m.App.PlaybackManager.SeekBackOrPrevious() }), fyne.NewMenuItem(lang.L("Next"), func() { - _ = m.App.PlaybackManager.SeekNext() + m.App.PlaybackManager.SeekNext() }), fyne.NewMenuItemSeparator(), fyne.NewMenuItem(lang.L("Volume")+" +10%", func() {