diff --git a/backend/mpris.go b/backend/mpris.go index 084a295..39bd08c 100644 --- a/backend/mpris.go +++ b/backend/mpris.go @@ -207,12 +207,12 @@ func (m *MPRISHandler) PlaybackStatus() (types.PlaybackStatus, error) { } func (m *MPRISHandler) LoopStatus() (types.LoopStatus, error) { - switch m.pm.LoopMode() { - case LoopModeAll: + switch m.pm.GetLoopMode() { + case player.LoopAll: return types.LoopStatusPlaylist, nil - case LoopModeOne: + case player.LoopOne: return types.LoopStatusTrack, nil - case LoopModeNone: + case player.LoopNone: return types.LoopStatusNone, nil } return "", errors.New("unknown loop status") @@ -221,11 +221,11 @@ func (m *MPRISHandler) LoopStatus() (types.LoopStatus, error) { func (m *MPRISHandler) SetLoopStatus(status types.LoopStatus) error { switch status { case types.LoopStatusPlaylist: - return m.pm.SetLoopMode(LoopModeAll) + return m.pm.SetLoopMode(player.LoopAll) case types.LoopStatusTrack: - return m.pm.SetLoopMode(LoopModeOne) + return m.pm.SetLoopMode(player.LoopOne) case types.LoopStatusNone: - return m.pm.SetLoopMode(LoopModeNone) + return m.pm.SetLoopMode(player.LoopNone) } return errors.New("unknown loop status") } diff --git a/backend/playbackmanager.go b/backend/playbackmanager.go index d9a7d9e..8e6d1a8 100644 --- a/backend/playbackmanager.go +++ b/backend/playbackmanager.go @@ -2,14 +2,12 @@ package backend import ( "context" - "fmt" "log" "time" "github.com/dweymouth/supersonic/backend/mediaprovider" "github.com/dweymouth/supersonic/backend/util" "github.com/dweymouth/supersonic/player" - "github.com/dweymouth/supersonic/player/mpv" "github.com/dweymouth/supersonic/sharedutil" ) @@ -20,14 +18,6 @@ var ( ReplayGainAuto = "Auto" ) -type LoopMode int - -const ( - LoopModeNone LoopMode = LoopMode(player.LoopNone) - LoopModeAll LoopMode = LoopMode(player.LoopAll) - LoopModeOne LoopMode = LoopMode(player.LoopOne) -) - // A high-level Subsonic-aware playback backend. // Manages loading tracks into the Player queue, // sending callbacks on play time updates and track changes. @@ -36,7 +26,7 @@ type PlaybackManager struct { cancelPollPos context.CancelFunc pollingTick *time.Ticker sm *ServerManager - player *mpv.Player + player player.BasePlayer playTimeStopwatch util.Stopwatch curTrackTime float64 @@ -44,7 +34,7 @@ type PlaybackManager struct { callbacksDisabled bool playQueue []*mediaprovider.Track - nowPlayingIdx int64 + nowPlayingIdx int // to pass to onSongChange listeners; clear once listeners have been called lastScrobbled *mediaprovider.Track @@ -55,7 +45,7 @@ type PlaybackManager struct { // registered callbacks onSongChange []func(nowPlaying, justScrobbledIfAny *mediaprovider.Track) onPlayTimeUpdate []func(float64, float64) - onLoopModeChange []func(LoopMode) + onLoopModeChange []func(player.LoopMode) onVolumeChange []func(int) onSeek []func() onPaused []func() @@ -66,7 +56,7 @@ type PlaybackManager struct { func NewPlaybackManager( ctx context.Context, s *ServerManager, - p *mpv.Player, + p player.BasePlayer, scrobbleCfg *ScrobbleConfig, transcodeCfg *TranscodingConfig, ) *PlaybackManager { @@ -79,8 +69,8 @@ func NewPlaybackManager( scrobbleCfg: scrobbleCfg, transcodeCfg: transcodeCfg, } - p.OnTrackChange(func(tracknum int64) { - if tracknum >= int64(len(pm.playQueue)) { + p.OnTrackChange(func(tracknum int) { + if tracknum >= len(pm.playQueue) { return } pm.checkScrobble() // scrobble the previous song if needed @@ -165,7 +155,7 @@ func (p *PlaybackManager) OnPlayTimeUpdate(cb func(float64, float64)) { } // Registers a callback that is notified whenever the loop mode changes. -func (p *PlaybackManager) OnLoopModeChange(cb func(LoopMode)) { +func (p *PlaybackManager) OnLoopModeChange(cb func(player.LoopMode)) { p.onLoopModeChange = append(p.onLoopModeChange, cb) } @@ -223,11 +213,18 @@ func (p *PlaybackManager) LoadTracks(tracks []*mediaprovider.Track, appendToQueu util.ShuffleSlice(nums) } for _, i := range nums { - url, err := p.sm.Server.GetStreamURL(tracks[i].ID, p.transcodeCfg.ForceRawFile) - if err != nil { - return err + + if urlP, ok := p.player.(player.URLPlayer); ok { + url, err := p.sm.Server.GetStreamURL(tracks[i].ID, p.transcodeCfg.ForceRawFile) + if err != nil { + return err + } + urlP.AppendFile(url) + } else if trP, ok := p.player.(player.TrackIDPlayer); ok { + trP.AppendTrack(tracks[i].ID) + } else { + panic("unsupported player type") } - p.player.AppendFile(url) // ensure a deep copy of the track info so that we can maintain our own state // (tracking play count increases, favorite, and rating) without messing up // other views' track models @@ -245,7 +242,7 @@ func (p *PlaybackManager) PlayAlbum(albumID string, firstTrack int, shuffle bool p.SetReplayGainMode(player.ReplayGainAlbum) } if firstTrack <= 0 { - return p.player.PlayFromBeginning() + return p.player.PlayTrackAt(0) } return p.player.PlayTrackAt(firstTrack) } @@ -258,7 +255,7 @@ func (p *PlaybackManager) PlayPlaylist(playlistID string, firstTrack int, shuffl p.SetReplayGainMode(player.ReplayGainTrack) } if firstTrack <= 0 { - return p.player.PlayFromBeginning() + return p.player.PlayTrackAt(0) } return p.player.PlayTrackAt(firstTrack) } @@ -276,7 +273,7 @@ func (p *PlaybackManager) PlayTrack(trackID string) error { } func (p *PlaybackManager) PlayFromBeginning() error { - return p.player.PlayFromBeginning() + return p.player.PlayTrackAt(0) } func (p *PlaybackManager) PlayTrackAt(idx int) error { @@ -374,6 +371,12 @@ func (p *PlaybackManager) StopAndClearPlayQueue() { } func (p *PlaybackManager) SetReplayGainOptions(config ReplayGainConfig) { + rGainPlayer, ok := p.player.(player.ReplayGainPlayer) + if !ok { + log.Println("Error: player doesn't support ReplayGain") + return + } + p.replayGainCfg = config mode := player.ReplayGainNone switch config.Mode { @@ -388,7 +391,7 @@ func (p *PlaybackManager) SetReplayGainOptions(config ReplayGainConfig) { if config.Mode == ReplayGainAuto { mode = player.ReplayGainTrack } - p.player.SetReplayGainOptions(player.ReplayGainOptions{ + rGainPlayer.SetReplayGainOptions(player.ReplayGainOptions{ Mode: mode, PreventClipping: config.PreventClipping, PreampGain: config.PreampGainDB, @@ -396,7 +399,12 @@ func (p *PlaybackManager) SetReplayGainOptions(config ReplayGainConfig) { } func (p *PlaybackManager) SetReplayGainMode(mode player.ReplayGainMode) { - p.player.SetReplayGainOptions(player.ReplayGainOptions{ + rGainPlayer, ok := p.player.(player.ReplayGainPlayer) + if !ok { + log.Println("Error: player doesn't support ReplayGain") + return + } + rGainPlayer.SetReplayGainOptions(player.ReplayGainOptions{ PreventClipping: p.replayGainCfg.PreventClipping, PreampGain: p.replayGainCfg.PreampGainDB, Mode: mode, @@ -406,18 +414,29 @@ func (p *PlaybackManager) SetReplayGainMode(mode player.ReplayGainMode) { // Changes the loop mode of the player to the next one. // Useful for toggling UI elements, to change modes without knowing the current player mode. func (p *PlaybackManager) SetNextLoopMode() error { - if err := p.player.SetNextLoopMode(); err != nil { - return err + var err error + switch p.GetLoopMode() { + case player.LoopNone: + err = p.SetLoopMode(player.LoopAll) + case player.LoopAll: + err = p.SetLoopMode(player.LoopOne) + case player.LoopOne: + err = p.SetLoopMode(player.LoopNone) + default: + return nil } + if err != nil { + return err + } for _, cb := range p.onLoopModeChange { - cb(LoopMode(p.player.GetLoopMode())) + cb(p.player.GetLoopMode()) } return nil } -func (p *PlaybackManager) SetLoopMode(loopMode LoopMode) error { +func (p *PlaybackManager) SetLoopMode(loopMode player.LoopMode) error { if err := p.player.SetLoopMode(player.LoopMode(loopMode)); err != nil { return err } @@ -429,8 +448,8 @@ func (p *PlaybackManager) SetLoopMode(loopMode LoopMode) error { return nil } -func (p *PlaybackManager) LoopMode() LoopMode { - return LoopMode(p.player.GetLoopMode()) +func (p *PlaybackManager) GetLoopMode() player.LoopMode { + return p.player.GetLoopMode() } func (p *PlaybackManager) PlayerStatus() player.Status { @@ -457,12 +476,15 @@ func (p *PlaybackManager) SeekNext() error { } func (p *PlaybackManager) SeekBackOrPrevious() error { - return p.player.SeekBackOrPrevious() + if p.player.GetStatus().TimePos > 3 { + return p.player.SeekSeconds(0) + } + return p.player.SeekPrevious() } // Seek to given absolute position in the current track by seconds. func (p *PlaybackManager) SeekSeconds(sec float64) error { - return p.player.Seek(fmt.Sprintf("%0.2f", sec), mpv.SeekAbsolute) + return p.player.SeekSeconds(sec) } // Seek to a fractional position in the current track [0..1] @@ -472,7 +494,8 @@ func (p *PlaybackManager) SeekFraction(fraction float64) error { } else if fraction > 1 { fraction = 1 } - return p.player.Seek(fmt.Sprintf("%0.1f", fraction*100), mpv.SeekAbsolutePercent) + target := p.curTrackTime * fraction + return p.player.SeekSeconds(target) } func (p *PlaybackManager) Stop() error { diff --git a/player/mpv/player.go b/player/mpv/player.go index 7775906..0703976 100644 --- a/player/mpv/player.go +++ b/player/mpv/player.go @@ -15,16 +15,6 @@ import ( // Error returned by many Player functions if called before the player has not been initialized. var ErrUnitialized error = errors.New("mpv player uninitialized") -// Argument to Seek function (SeekAbsolute, SeekRelative, SeekAbsolutePercent, SeekRelativePercent). -type SeekMode int - -const ( - SeekAbsolute SeekMode = iota - SeekRelative - SeekAbsolutePercent - SeekRelativePercent -) - // Information about a specific audio device. // Returned by ListAudioDevices. type AudioDevice struct { @@ -56,6 +46,8 @@ type MediaInfo struct { Bitrate int } +var _ player.URLPlayer = (*Player)(nil) + // Player encapsulates the mpv instance and provides functions // to control it and to check its status. type Player struct { @@ -80,7 +72,7 @@ type Player struct { onStopped []func() onPlaying []func() onSeek []func() - onTrackChange []func(int64) + onTrackChange []func(int) } // Returns a new player. @@ -203,30 +195,25 @@ func (p *Player) ClearPlayQueue() error { // Seeks within the currently playing track. // See MPV seek command documentation for more details. -func (p *Player) Seek(target string, mode SeekMode) error { +func (p *Player) SeekSeconds(secs float64) error { if !p.initialized { return ErrUnitialized } + target := fmt.Sprintf("%0.1f", secs) p.seeking = true - err := p.mpv.Command([]string{"seek", target, mode.String()}) + err := p.mpv.Command([]string{"seek", target, "absolute"}) return err } -// Seeks to the beginning of the current track if: -// - The current track is the first track in the play queue, or -// - The current time is more than 3 seconds past the beginning of the track. -// -// Else seeks to the beginning of the previous track. -func (p *Player) SeekBackOrPrevious() error { +// Seeks to the beginning of the previous track, +// or if no previous track, seeks to the beginning of the current track. +func (p *Player) SeekPrevious() error { if !p.initialized { return ErrUnitialized } - if pos, err := p.getInt64Property("time-pos"); err == nil && pos > 3 { - return p.Seek("0", SeekAbsolutePercent) - } if pos, err := p.getInt64Property("playlist-pos"); err == nil && pos == 0 { - return p.Seek("0", SeekAbsolutePercent) + return p.SeekSeconds(0) } return p.mpv.Command([]string{"playlist-prev"}) } @@ -445,21 +432,6 @@ func (p *Player) SetLoopMode(mode player.LoopMode) error { return nil } -// Change the loop mode of the player to the next one. -// Useful for toggling UI elements, to change modes without knowing the current player mode. -func (p *Player) SetNextLoopMode() error { - switch p.loopMode { - case player.LoopNone: - return p.SetLoopMode(player.LoopAll) - case player.LoopAll: - return p.SetLoopMode(player.LoopOne) - case player.LoopOne: - return p.SetLoopMode(player.LoopNone) - default: - return nil - } -} - // Get the current status of the player. func (p *Player) GetStatus() player.Status { if !p.initialized { @@ -475,7 +447,7 @@ func (p *Player) GetStatus() player.Status { p.status.Duration = dur.(float64) } if playpos, err := p.getInt64Property("playlist-pos"); err == nil { - p.status.PlaylistPos = playpos + p.status.PlaylistPos = int(playpos) } return p.status } @@ -586,7 +558,7 @@ func (p *Player) OnSeek(cb func()) { // 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(int64)) { +func (p *Player) OnTrackChange(cb func(int)) { p.onTrackChange = append(p.onTrackChange, cb) } @@ -657,7 +629,7 @@ func (p *Player) eventHandler(ctx context.Context) { if pos, err := p.getInt64Property("playlist-pos"); err == nil { p.curPlaylistPos = pos for _, cb := range p.onTrackChange { - cb(pos) + cb(int(pos)) } } case mpv.EVENT_IDLE: @@ -668,17 +640,3 @@ func (p *Player) eventHandler(ctx context.Context) { } } } - -func (s SeekMode) String() string { - switch s { - case SeekAbsolute: - return "absolute" - case SeekRelative: - return "relative" - case SeekAbsolutePercent: - return "absolute-percent" - case SeekRelativePercent: - return "relative-percent" - } - return "UNKNOWN_SEEK_MODE" -} diff --git a/player/player.go b/player/player.go index f7594c7..a33a2eb 100644 --- a/player/player.go +++ b/player/player.go @@ -1,5 +1,52 @@ package player +type URLPlayer interface { + BasePlayer + AppendFile(url string) error + PlayFile(url string) error +} + +type TrackIDPlayer interface { + BasePlayer + AppendTrack(trackID string) error + PlayTrack(trackID string) error +} + +type BasePlayer interface { + // Transport + PlayTrackAt(idx int) error + Continue() error + Pause() error + PlayPause() error + Stop() error + SeekPrevious() error + SeekNext() error + SeekSeconds(secs float64) error + IsSeeking() bool + + SetVolume(int) error + GetVolume() int + + GetStatus() Status + + ClearPlayQueue() error + RemoveTrackAt(idx int) error + + SetLoopMode(LoopMode) error + GetLoopMode() LoopMode + + // Event API + OnPaused(func()) + OnStopped(func()) + OnPlaying(func()) + OnSeek(func()) + OnTrackChange(func(int)) +} + +type ReplayGainPlayer interface { + SetReplayGainOptions(ReplayGainOptions) error +} + // The playback state (Stopped, Paused, or Playing). type State int @@ -15,7 +62,7 @@ type Status struct { State State TimePos float64 Duration float64 - PlaylistPos int64 + PlaylistPos int } // The playback loop mode (LoopNone, LoopAll, LoopOne). diff --git a/ui/mainwindow.go b/ui/mainwindow.go index a410d86..32af290 100644 --- a/ui/mainwindow.go +++ b/ui/mainwindow.go @@ -162,13 +162,13 @@ func (m *MainWindow) SetupSystemTrayMenu(appName string, fyneApp fyne.App) { if desk, ok := fyneApp.(desktop.App); ok { menu := fyne.NewMenu(appName, fyne.NewMenuItem("Play/Pause", func() { - _ = m.App.Player.PlayPause() + _ = m.App.PlaybackManager.PlayPause() }), fyne.NewMenuItem("Previous", func() { - _ = m.App.Player.SeekBackOrPrevious() + _ = m.App.PlaybackManager.SeekBackOrPrevious() }), fyne.NewMenuItem("Next", func() { - _ = m.App.Player.SeekNext() + _ = m.App.PlaybackManager.SeekNext() }), fyne.NewMenuItemSeparator(), fyne.NewMenuItem("Volume +10%", func() { @@ -298,7 +298,7 @@ func (m *MainWindow) addShortcuts() { case fyne.KeyEscape: m.Controller.CloseEscapablePopUp() case fyne.KeySpace: - m.App.Player.PlayPause() + m.App.PlaybackManager.PlayPause() } }) } diff --git a/ui/widgets/auxcontrols.go b/ui/widgets/auxcontrols.go index 518a7d1..88102da 100644 --- a/ui/widgets/auxcontrols.go +++ b/ui/widgets/auxcontrols.go @@ -7,7 +7,7 @@ import ( "fyne.io/fyne/v2/theme" "fyne.io/fyne/v2/widget" - "github.com/dweymouth/supersonic/backend" + "github.com/dweymouth/supersonic/player" myTheme "github.com/dweymouth/supersonic/ui/theme" "github.com/dweymouth/supersonic/ui/util" ) @@ -67,15 +67,15 @@ func (a *AuxControls) OnChangeLoopMode(f func()) { a.loop.OnTapped = f } -func (a *AuxControls) SetLoopMode(mode backend.LoopMode) { +func (a *AuxControls) SetLoopMode(mode player.LoopMode) { switch mode { - case backend.LoopModeAll: + case player.LoopAll: a.loop.Importance = widget.HighImportance a.loop.Icon = myTheme.RepeatIcon - case backend.LoopModeOne: + case player.LoopOne: a.loop.Importance = widget.HighImportance a.loop.Icon = myTheme.RepeatOneIcon - case backend.LoopModeNone: + case player.LoopNone: a.loop.Importance = widget.MediumImportance a.loop.Icon = myTheme.RepeatIcon }