From 188a94b41b893406e3303d98c50e18489a0f2b81 Mon Sep 17 00:00:00 2001 From: Drew Weymouth Date: Tue, 27 Aug 2024 18:16:16 -0700 Subject: [PATCH 01/10] beginning of reworking playback to use a command queue --- backend/playbackcommands.go | 102 ++++++++++++++++++++++++++++++++++++ 1 file changed, 102 insertions(+) create mode 100644 backend/playbackcommands.go diff --git a/backend/playbackcommands.go b/backend/playbackcommands.go new file mode 100644 index 0000000..152a861 --- /dev/null +++ b/backend/playbackcommands.go @@ -0,0 +1,102 @@ +package backend + +import ( + "slices" + "sync" +) + +type PlaybackCommandType int + +const ( + CmdStop PlaybackCommandType = iota + CmdContinue + CmdPause + CmdPlayTrackAt + CmdSeekSeconds + CmdSeekFwdBackN + CmdVolume + CmdLoopMode +) + +type PlaybackCommand struct { + Type PlaybackCommandType + Arg any +} + +type CommandQueue struct { + mutex sync.Mutex + queue []PlaybackCommand + cmdAvailable *sync.Cond + nextChan chan (PlaybackCommand) +} + +func NewCommandQueue() *CommandQueue { + c := &CommandQueue{} + c.cmdAvailable = sync.NewCond(&c.mutex) + go c.chanWriter() + return c +} + +func (c *CommandQueue) C() <-chan PlaybackCommand { + return c.nextChan +} + +func (c *CommandQueue) Stop() { + c.filterCommandsAndAdd([]PlaybackCommandType{CmdContinue, CmdPause, CmdStop}, + PlaybackCommand{Type: CmdStop}) +} + +func (c *CommandQueue) Continue() { + c.filterCommandsAndAdd([]PlaybackCommandType{CmdContinue, CmdPause, CmdStop}, + PlaybackCommand{Type: CmdContinue}) +} + +func (c *CommandQueue) Pause() { + c.filterCommandsAndAdd([]PlaybackCommandType{CmdContinue, CmdPause, CmdStop}, + PlaybackCommand{Type: CmdPause}) +} + +func (c *CommandQueue) Volume(vol int) { + c.filterCommandsAndAdd([]PlaybackCommandType{CmdVolume}, + PlaybackCommand{Type: CmdVolume, Arg: vol}) +} + +func (c *CommandQueue) LoopMode(mode LoopMode) { + c.filterCommandsAndAdd([]PlaybackCommandType{CmdLoopMode}, + PlaybackCommand{Type: CmdLoopMode, Arg: mode}) +} + +func (c *CommandQueue) SeekSeconds(s float64) { + c.filterCommandsAndAdd([]PlaybackCommandType{CmdVolume}, + PlaybackCommand{Type: CmdSeekSeconds, Arg: s}) +} + +func (c *CommandQueue) filterCommandsAndAdd(excludeTypes []PlaybackCommandType, command PlaybackCommand) { + c.mutex.Lock() + defer c.mutex.Unlock() + + 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) +} + +func (c *CommandQueue) 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 + } +} From 0fffe9d8ed98135bd2dfe773354d9486847da917 Mon Sep 17 00:00:00 2001 From: Drew Weymouth Date: Wed, 28 Aug 2024 08:25:13 -0700 Subject: [PATCH 02/10] more playback commands --- backend/playbackcommands.go | 130 +++++++++++++++++++++++++++++------- backend/playbackengine.go | 12 ++-- 2 files changed, 111 insertions(+), 31 deletions(-) diff --git a/backend/playbackcommands.go b/backend/playbackcommands.go index 152a861..d0396da 100644 --- a/backend/playbackcommands.go +++ b/backend/playbackcommands.go @@ -3,24 +3,36 @@ package backend import ( "slices" "sync" + + "github.com/dweymouth/supersonic/backend/mediaprovider" ) -type PlaybackCommandType int +type playbackCommandType int const ( - CmdStop PlaybackCommandType = iota - CmdContinue - CmdPause - CmdPlayTrackAt - CmdSeekSeconds - CmdSeekFwdBackN - CmdVolume - CmdLoopMode + 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 + Type playbackCommandType Arg any + Arg2 any + Arg3 any } type CommandQueue struct { @@ -42,36 +54,84 @@ func (c *CommandQueue) C() <-chan PlaybackCommand { } func (c *CommandQueue) Stop() { - c.filterCommandsAndAdd([]PlaybackCommandType{CmdContinue, CmdPause, CmdStop}, - PlaybackCommand{Type: CmdStop}) + c.filterCommandsAndAdd([]playbackCommandType{cmdContinue, cmdPause, cmdStop}, + PlaybackCommand{Type: cmdStop}) } func (c *CommandQueue) Continue() { - c.filterCommandsAndAdd([]PlaybackCommandType{CmdContinue, CmdPause, CmdStop}, - PlaybackCommand{Type: CmdContinue}) + c.filterCommandsAndAdd([]playbackCommandType{cmdContinue, cmdPause, cmdStop}, + PlaybackCommand{Type: cmdContinue}) } func (c *CommandQueue) Pause() { - c.filterCommandsAndAdd([]PlaybackCommandType{CmdContinue, CmdPause, CmdStop}, - PlaybackCommand{Type: CmdPause}) + c.filterCommandsAndAdd([]playbackCommandType{cmdContinue, cmdPause, cmdStop}, + PlaybackCommand{Type: cmdPause}) } -func (c *CommandQueue) Volume(vol int) { - c.filterCommandsAndAdd([]PlaybackCommandType{CmdVolume}, - PlaybackCommand{Type: CmdVolume, Arg: vol}) +func (c *CommandQueue) StopAndClearPlayQueue() { + c.filterCommandsAndAdd([]playbackCommandType{cmdContinue, cmdPause, cmdStop, cmdStopAndClearPlayQueue}, + PlaybackCommand{Type: cmdStopAndClearPlayQueue}) } -func (c *CommandQueue) LoopMode(mode LoopMode) { - c.filterCommandsAndAdd([]PlaybackCommandType{CmdLoopMode}, - PlaybackCommand{Type: CmdLoopMode, Arg: mode}) +func (c *CommandQueue) SetVolume(vol int) { + c.filterCommandsAndAdd([]playbackCommandType{cmdVolume}, + PlaybackCommand{Type: cmdVolume, Arg: vol}) +} + +func (c *CommandQueue) SetLoopMode(mode LoopMode) { + c.filterCommandsAndAdd([]playbackCommandType{cmdLoopMode}, + PlaybackCommand{Type: cmdLoopMode, Arg: mode}) } func (c *CommandQueue) SeekSeconds(s float64) { - c.filterCommandsAndAdd([]PlaybackCommandType{CmdVolume}, - PlaybackCommand{Type: CmdSeekSeconds, Arg: s}) + c.filterCommandsAndAdd([]playbackCommandType{cmdSeekSeconds}, + PlaybackCommand{Type: cmdSeekSeconds, Arg: s}) } -func (c *CommandQueue) filterCommandsAndAdd(excludeTypes []PlaybackCommandType, command PlaybackCommand) { +func (c *CommandQueue) SeekNext() { + c.seekBackOrFwd(1) +} + +func (c *CommandQueue) SeekBackOrPrevious() { + c.seekBackOrFwd(-1) +} + +func (c *CommandQueue) UpdatePlayQueue(items []mediaprovider.MediaItem) { + c.filterCommandsAndAdd([]playbackCommandType{cmdUpdatePlayQueue}, + PlaybackCommand{Type: cmdUpdatePlayQueue, Arg: items}) +} + +func (c *CommandQueue) RemoveItemsFromQueue(idxs []int) { + c.mutex.Lock() + defer c.mutex.Unlock() + c.queue = append(c.queue, PlaybackCommand{ + Type: cmdRemoveTracksFromQueue, + Arg: idxs, + }) +} + +func (c *CommandQueue) LoadRadioStation(radio *mediaprovider.RadioStation, insertMode InsertQueueMode) { + c.mutex.Lock() + defer c.mutex.Unlock() + c.queue = append(c.queue, PlaybackCommand{ + Type: cmdLoadRadioStation, + Arg: radio, + Arg2: insertMode, + }) +} + +func (c *CommandQueue) LoadItems(items []mediaprovider.MediaItem, insertQueueMode InsertQueueMode, shuffle bool) { + c.mutex.Lock() + defer c.mutex.Unlock() + c.queue = append(c.queue, PlaybackCommand{ + Type: cmdLoadItems, + Arg: items, + Arg2: insertQueueMode, + Arg3: shuffle, + }) +} + +func (c *CommandQueue) filterCommandsAndAdd(excludeTypes []playbackCommandType, command PlaybackCommand) { c.mutex.Lock() defer c.mutex.Unlock() @@ -87,6 +147,26 @@ func (c *CommandQueue) filterCommandsAndAdd(excludeTypes []PlaybackCommandType, c.queue = append(c.queue, command) } +func (c *CommandQueue) seekBackOrFwd(direction int) { + c.mutex.Lock() + defer c.mutex.Unlock() + + j := 0 + n := 0 + for _, cmd := range c.queue { + if cmd.Type == cmdSeekFwdBackN { + n += cmd.Arg.(int) + } else { + c.queue[j] = cmd + j++ + } + } + c.queue = c.queue[:j] + c.queue = append(c.queue, PlaybackCommand{ + Type: cmdSeekFwdBackN, + Arg: n + direction}) +} + func (c *CommandQueue) chanWriter() { for { c.mutex.Lock() diff --git a/backend/playbackengine.go b/backend/playbackengine.go index a68f94d..0d69cc3 100644 --- a/backend/playbackengine.go +++ b/backend/playbackengine.go @@ -213,14 +213,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) } @@ -288,7 +288,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 +315,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 @@ -591,7 +591,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 +599,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() From 136bade8b49a4437550676eae6f5f116fa68d0e7 Mon Sep 17 00:00:00 2001 From: Drew Weymouth Date: Fri, 30 Aug 2024 08:20:02 -0700 Subject: [PATCH 03/10] hook up cmd queue to PlaybackManager --- backend/app.go | 4 +- backend/ipc/server.go | 47 ++++------ backend/mpris.go | 26 ++++-- backend/playbackcommands.go | 93 ++++++++++--------- backend/playbackmanager.go | 157 +++++++++++++++++++++----------- ui/bottompanel.go | 2 +- ui/controller/connectactions.go | 2 +- ui/mainwindow.go | 6 +- 8 files changed, 196 insertions(+), 141 deletions(-) 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/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 index d0396da..33cac58 100644 --- a/backend/playbackcommands.go +++ b/backend/playbackcommands.go @@ -28,113 +28,120 @@ const ( cmdLoadRadioStation // arg: *mediaprovider.RadioStation, arg2: InsertQueueMode ) -type PlaybackCommand struct { +type playbackCommand struct { Type playbackCommandType Arg any Arg2 any Arg3 any } -type CommandQueue struct { +type playbackCommandQueue struct { mutex sync.Mutex - queue []PlaybackCommand + queue []playbackCommand cmdAvailable *sync.Cond - nextChan chan (PlaybackCommand) + nextChan chan playbackCommand } -func NewCommandQueue() *CommandQueue { - c := &CommandQueue{} +func NewCommandQueue() *playbackCommandQueue { + c := &playbackCommandQueue{} + c.nextChan = make(chan playbackCommand) c.cmdAvailable = sync.NewCond(&c.mutex) go c.chanWriter() return c } -func (c *CommandQueue) C() <-chan PlaybackCommand { +func (c *playbackCommandQueue) C() <-chan playbackCommand { return c.nextChan } -func (c *CommandQueue) Stop() { +func (c *playbackCommandQueue) Stop() { c.filterCommandsAndAdd([]playbackCommandType{cmdContinue, cmdPause, cmdStop}, - PlaybackCommand{Type: cmdStop}) + playbackCommand{Type: cmdStop}) } -func (c *CommandQueue) Continue() { +func (c *playbackCommandQueue) Continue() { c.filterCommandsAndAdd([]playbackCommandType{cmdContinue, cmdPause, cmdStop}, - PlaybackCommand{Type: cmdContinue}) + playbackCommand{Type: cmdContinue}) } -func (c *CommandQueue) Pause() { +func (c *playbackCommandQueue) Pause() { c.filterCommandsAndAdd([]playbackCommandType{cmdContinue, cmdPause, cmdStop}, - PlaybackCommand{Type: cmdPause}) + playbackCommand{Type: cmdPause}) } -func (c *CommandQueue) StopAndClearPlayQueue() { +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}) + playbackCommand{Type: cmdStopAndClearPlayQueue}) } -func (c *CommandQueue) SetVolume(vol int) { +func (c *playbackCommandQueue) SetVolume(vol int) { c.filterCommandsAndAdd([]playbackCommandType{cmdVolume}, - PlaybackCommand{Type: cmdVolume, Arg: vol}) + playbackCommand{Type: cmdVolume, Arg: vol}) } -func (c *CommandQueue) SetLoopMode(mode LoopMode) { +func (c *playbackCommandQueue) SetLoopMode(mode LoopMode) { c.filterCommandsAndAdd([]playbackCommandType{cmdLoopMode}, - PlaybackCommand{Type: cmdLoopMode, Arg: mode}) + playbackCommand{Type: cmdLoopMode, Arg: mode}) } -func (c *CommandQueue) SeekSeconds(s float64) { +func (c *playbackCommandQueue) SeekSeconds(s float64) { c.filterCommandsAndAdd([]playbackCommandType{cmdSeekSeconds}, - PlaybackCommand{Type: cmdSeekSeconds, Arg: s}) + playbackCommand{Type: cmdSeekSeconds, Arg: s}) } -func (c *CommandQueue) SeekNext() { +func (c *playbackCommandQueue) SeekNext() { c.seekBackOrFwd(1) } -func (c *CommandQueue) SeekBackOrPrevious() { +func (c *playbackCommandQueue) SeekBackOrPrevious() { c.seekBackOrFwd(-1) } -func (c *CommandQueue) UpdatePlayQueue(items []mediaprovider.MediaItem) { +func (c *playbackCommandQueue) UpdatePlayQueue(items []mediaprovider.MediaItem) { c.filterCommandsAndAdd([]playbackCommandType{cmdUpdatePlayQueue}, - PlaybackCommand{Type: cmdUpdatePlayQueue, Arg: items}) + playbackCommand{Type: cmdUpdatePlayQueue, Arg: items}) } -func (c *CommandQueue) RemoveItemsFromQueue(idxs []int) { +func (c *playbackCommandQueue) RemoveItemsFromQueue(idxs []int) { c.mutex.Lock() - defer c.mutex.Unlock() - c.queue = append(c.queue, PlaybackCommand{ + c.queue = append(c.queue, playbackCommand{ Type: cmdRemoveTracksFromQueue, Arg: idxs, }) + c.mutex.Unlock() + c.cmdAvailable.Signal() } -func (c *CommandQueue) LoadRadioStation(radio *mediaprovider.RadioStation, insertMode InsertQueueMode) { +func (c *playbackCommandQueue) LoadRadioStation(radio *mediaprovider.RadioStation, insertMode InsertQueueMode) { c.mutex.Lock() - defer c.mutex.Unlock() - c.queue = append(c.queue, PlaybackCommand{ + c.queue = append(c.queue, playbackCommand{ Type: cmdLoadRadioStation, Arg: radio, Arg2: insertMode, }) + c.mutex.Unlock() + c.cmdAvailable.Signal() } -func (c *CommandQueue) LoadItems(items []mediaprovider.MediaItem, insertQueueMode InsertQueueMode, shuffle bool) { +func (c *playbackCommandQueue) LoadItems(items []mediaprovider.MediaItem, insertQueueMode InsertQueueMode, shuffle bool) { c.mutex.Lock() - defer c.mutex.Unlock() - c.queue = append(c.queue, PlaybackCommand{ + c.queue = append(c.queue, playbackCommand{ Type: cmdLoadItems, Arg: items, Arg2: insertQueueMode, Arg3: shuffle, }) + c.mutex.Unlock() + c.cmdAvailable.Signal() } -func (c *CommandQueue) filterCommandsAndAdd(excludeTypes []playbackCommandType, command PlaybackCommand) { +func (c *playbackCommandQueue) filterCommandsAndAdd(excludeTypes []playbackCommandType, command playbackCommand) { c.mutex.Lock() - defer c.mutex.Unlock() - j := 0 for _, cmd := range c.queue { if slices.Contains(excludeTypes, cmd.Type) { @@ -145,12 +152,12 @@ func (c *CommandQueue) filterCommandsAndAdd(excludeTypes []playbackCommandType, } c.queue = c.queue[:j] c.queue = append(c.queue, command) + c.mutex.Unlock() + c.cmdAvailable.Signal() } -func (c *CommandQueue) seekBackOrFwd(direction int) { +func (c *playbackCommandQueue) seekBackOrFwd(direction int) { c.mutex.Lock() - defer c.mutex.Unlock() - j := 0 n := 0 for _, cmd := range c.queue { @@ -162,12 +169,14 @@ func (c *CommandQueue) seekBackOrFwd(direction int) { } } c.queue = c.queue[:j] - c.queue = append(c.queue, PlaybackCommand{ + c.queue = append(c.queue, playbackCommand{ Type: cmdSeekFwdBackN, Arg: n + direction}) + c.mutex.Unlock() + c.cmdAvailable.Signal() } -func (c *CommandQueue) chanWriter() { +func (c *playbackCommandQueue) chanWriter() { for { c.mutex.Lock() for len(c.queue) == 0 { diff --git a/backend/playbackmanager.go b/backend/playbackmanager.go index e6edd7b..b95e357 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,79 @@ 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: + log.Println("TODO") + 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), + ) + } + } } - return errors.New("unreached - invalid player state") } diff --git a/ui/bottompanel.go b/ui/bottompanel.go index 60957ba..29d32e8 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 bb33ada..cf00f95 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() { From 3bad04a74aa379353cb3354b4425d6d7df0612f4 Mon Sep 17 00:00:00 2001 From: Drew Weymouth Date: Fri, 30 Aug 2024 16:48:44 -0700 Subject: [PATCH 04/10] update jukebox player to synchronous --- backend/player/jukebox/jukeboxplayer.go | 59 ++++++++++--------------- 1 file changed, 24 insertions(+), 35 deletions(-) diff --git a/backend/player/jukebox/jukeboxplayer.go b/backend/player/jukebox/jukeboxplayer.go index d3cca0e..a73d5f6 100644 --- a/backend/player/jukebox/jukeboxplayer.go +++ b/backend/player/jukebox/jukeboxplayer.go @@ -26,11 +26,10 @@ type JukeboxPlayer struct { } 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 } @@ -39,25 +38,21 @@ func (j *JukeboxPlayer) GetVolume() int { } 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 + if err := j.provider.JukeboxSeek(idx, 0); err != nil { + return err + } + j.curTrack = idx + return j.Continue() } 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.provider.JukeboxStart(); err != nil { + return err + } + j.state = playing return nil } @@ -65,12 +60,10 @@ 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 + } + j.state = paused return nil } @@ -78,12 +71,10 @@ 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 return nil } @@ -105,11 +96,9 @@ func (j *JukeboxPlayer) SeekNext() error { 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 + return err } func (j *JukeboxPlayer) IsSeeking() bool { From c2efb048e2035c4363bf6d6acdc37b86f4e3545e Mon Sep 17 00:00:00 2001 From: Drew Weymouth Date: Sat, 31 Aug 2024 09:09:12 -0700 Subject: [PATCH 05/10] more jukebox work --- backend/mediaprovider/mediaprovider.go | 9 ++- backend/mediaprovider/subsonic/jukebox.go | 4 +- backend/player/jukebox/jukeboxplayer.go | 85 ++++++++++++++++------- 3 files changed, 68 insertions(+), 30 deletions(-) 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/player/jukebox/jukeboxplayer.go b/backend/player/jukebox/jukeboxplayer.go index a73d5f6..52e7cb6 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" ) @@ -14,15 +16,16 @@ const ( type JukeboxPlayer struct { 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 { @@ -37,21 +40,14 @@ func (j *JukeboxPlayer) GetVolume() int { return j.volume } -func (j *JukeboxPlayer) PlayTrackAt(idx int) error { - if err := j.provider.JukeboxSeek(idx, 0); err != nil { - return err - } - j.curTrack = idx - return j.Continue() -} - func (j *JukeboxPlayer) Continue() error { if j.state == playing { return nil } - if err := j.provider.JukeboxStart(); err != nil { + if err := j.startAndUpdateTime(); err != nil { return err } + j.state = playing return nil } @@ -63,6 +59,7 @@ func (j *JukeboxPlayer) Pause() error { if err := j.provider.JukeboxStop(); err != nil { return err } + // TODO: calculate paused at time j.state = paused return nil } @@ -78,20 +75,37 @@ func (j *JukeboxPlayer) Stop() error { 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 { @@ -119,3 +133,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() { + +} From faf067ce7e2583921fffdc25022cb6d9926f6e8b Mon Sep 17 00:00:00 2001 From: Drew Weymouth Date: Sun, 1 Sep 2024 08:45:09 -0700 Subject: [PATCH 06/10] add shared callback impl between mpv and jukebox players --- backend/playbackengine.go | 1 - backend/player/jukebox/jukeboxplayer.go | 6 +++ backend/player/mpv/player.go | 68 ++++--------------------- backend/player/player.go | 65 +++++++++++++++++++++++ 4 files changed, 80 insertions(+), 60 deletions(-) diff --git a/backend/playbackengine.go b/backend/playbackengine.go index 0d69cc3..4b050dd 100644 --- a/backend/playbackengine.go +++ b/backend/playbackengine.go @@ -279,7 +279,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 { diff --git a/backend/player/jukebox/jukeboxplayer.go b/backend/player/jukebox/jukeboxplayer.go index 52e7cb6..3518beb 100644 --- a/backend/player/jukebox/jukeboxplayer.go +++ b/backend/player/jukebox/jukeboxplayer.go @@ -14,6 +14,8 @@ const ( ) type JukeboxPlayer struct { + player.BasePlayerCallbackImpl + provider mediaprovider.JukeboxProvider state int // stopped, playing, paused @@ -49,6 +51,7 @@ func (j *JukeboxPlayer) Continue() error { } j.state = playing + j.InvokeOnPlaying() return nil } @@ -61,6 +64,7 @@ func (j *JukeboxPlayer) Pause() error { } // TODO: calculate paused at time j.state = paused + j.InvokeOnPaused() return nil } @@ -72,6 +76,7 @@ func (j *JukeboxPlayer) Stop() error { return err } j.state = stopped + j.InvokeOnStopped() return nil } @@ -112,6 +117,7 @@ func (j *JukeboxPlayer) SeekSeconds(secs float64) error { j.seeking = true err := j.provider.JukeboxSeek(j.curTrack, int(secs)) j.seeking = false + j.InvokeOnSeek() return err } 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() + } +} From 00ffb8b3a103c1f7bf234527ede5da9ddd2f8516 Mon Sep 17 00:00:00 2001 From: Drew Weymouth Date: Wed, 11 Sep 2024 07:57:38 -0700 Subject: [PATCH 07/10] implement SeekFwdBackN --- backend/playbackengine.go | 30 ++++++++++++++++++++++++++++++ backend/playbackmanager.go | 2 +- 2 files changed, 31 insertions(+), 1 deletion(-) diff --git a/backend/playbackengine.go b/backend/playbackengine.go index 4b050dd..ad0eb0d 100644 --- a/backend/playbackengine.go +++ b/backend/playbackengine.go @@ -183,6 +183,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(len(p.playQueue)-1, 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 { @@ -667,3 +683,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 b95e357..751edd4 100644 --- a/backend/playbackmanager.go +++ b/backend/playbackmanager.go @@ -405,7 +405,7 @@ func (p *PlaybackManager) runCmdQueue(ctx context.Context) { case cmdSeekSeconds: p.engine.SeekSeconds(c.Arg.(float64)) case cmdSeekFwdBackN: - log.Println("TODO") + p.engine.SeekFwdBackN(c.Arg.(int)) case cmdVolume: p.engine.SetVolume(c.Arg.(int)) case cmdLoopMode: From 1fff7f648807d2b71c0c2297c1f3f69c78664418 Mon Sep 17 00:00:00 2001 From: Drew Weymouth Date: Wed, 11 Sep 2024 08:20:15 -0700 Subject: [PATCH 08/10] fix coalescing logic of multiple seekFwdBackN commands --- backend/playbackcommands.go | 55 +++++++++++++++++++++++++++++-------- 1 file changed, 43 insertions(+), 12 deletions(-) diff --git a/backend/playbackcommands.go b/backend/playbackcommands.go index 33cac58..3e9c5b9 100644 --- a/backend/playbackcommands.go +++ b/backend/playbackcommands.go @@ -35,6 +35,10 @@ type playbackCommand struct { 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 @@ -157,21 +161,48 @@ func (c *playbackCommandQueue) filterCommandsAndAdd(excludeTypes []playbackComma } 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() - j := 0 - n := 0 - for _, cmd := range c.queue { - if cmd.Type == cmdSeekFwdBackN { - n += cmd.Arg.(int) - } else { - c.queue[j] = cmd - j++ + 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 } } - c.queue = c.queue[:j] - c.queue = append(c.queue, playbackCommand{ - Type: cmdSeekFwdBackN, - Arg: n + direction}) + + 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() } From 795099945023317d49457aba16f4229e80e2d9f5 Mon Sep 17 00:00:00 2001 From: Drew Weymouth Date: Wed, 11 Sep 2024 11:34:14 -0700 Subject: [PATCH 09/10] fix seeking to next track repeatedly and quickly --- backend/playbackengine.go | 24 +++++++++++++++--------- 1 file changed, 15 insertions(+), 9 deletions(-) diff --git a/backend/playbackengine.go b/backend/playbackengine.go index ad0eb0d..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. @@ -195,7 +200,7 @@ func (p *playbackEngine) SeekFwdBackN(n int) error { if idx == lastIdx && n > 0 { return nil // already on last track, nothing to seek next to } - newIdx := minInt(len(p.playQueue)-1, maxInt(0, idx+n)) + newIdx := minInt(lastIdx, maxInt(0, idx+n)) return p.PlayTrackAt(newIdx) } @@ -448,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 From fd2d831fe6f8ea8edf1205876fda97fea6c6df11 Mon Sep 17 00:00:00 2001 From: Drew Weymouth Date: Wed, 11 Sep 2024 16:59:53 -0700 Subject: [PATCH 10/10] add missed handler for cmdLoadRadioStation --- backend/playbackmanager.go | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/backend/playbackmanager.go b/backend/playbackmanager.go index 751edd4..83c4dfa 100644 --- a/backend/playbackmanager.go +++ b/backend/playbackmanager.go @@ -422,6 +422,11 @@ func (p *PlaybackManager) runCmdQueue(ctx context.Context) { c.Arg2.(InsertQueueMode), c.Arg3.(bool), ) + case cmdLoadRadioStation: + p.engine.LoadRadioStation( + c.Arg.(*mediaprovider.RadioStation), + c.Arg2.(InsertQueueMode), + ) } } }