diff --git a/backend/app.go b/backend/app.go index ebc3450..e795188 100644 --- a/backend/app.go +++ b/backend/app.go @@ -12,6 +12,7 @@ import ( "github.com/dweymouth/supersonic/backend/util" "github.com/dweymouth/supersonic/player" + "github.com/dweymouth/supersonic/player/mpv" "github.com/dweymouth/supersonic/sharedutil" "github.com/fsnotify/fsnotify" "github.com/google/uuid" @@ -36,7 +37,7 @@ type App struct { ServerManager *ServerManager ImageManager *ImageManager PlaybackManager *PlaybackManager - Player *player.Player + LocalPlayer *mpv.Player UpdateChecker UpdateChecker MPRISHandler *MPRISHandler @@ -107,7 +108,7 @@ func StartupApp(appName, displayAppName, appVersionTag, configFile, latestReleas } a.ServerManager = NewServerManager(appName, a.Config) - a.PlaybackManager = NewPlaybackManager(a.bgrndCtx, a.ServerManager, a.Player, &a.Config.Scrobbling, &a.Config.Transcoding) + a.PlaybackManager = NewPlaybackManager(a.bgrndCtx, a.ServerManager, a.LocalPlayer, &a.Config.Scrobbling, &a.Config.Transcoding) a.ImageManager = NewImageManager(a.bgrndCtx, a.ServerManager, configdir.LocalCache(a.appName)) a.Config.Application.MaxImageCacheSizeMB = clamp(a.Config.Application.MaxImageCacheSizeMB, 1, 500) a.ImageManager.SetMaxOnDiskCacheSizeBytes(int64(a.Config.Application.MaxImageCacheSizeMB) * 1_048_576) @@ -117,7 +118,7 @@ func StartupApp(appName, displayAppName, appVersionTag, configFile, latestReleas // OS media center integrations a.setupMPRIS(displayAppName) - InitMPMediaHandler(a.Player, a.PlaybackManager, func(id string) (string, error) { + InitMPMediaHandler(a.PlaybackManager, func(id string) (string, error) { a.ImageManager.GetCoverThumbnail(id) // ensure image is cached locally return a.ImageManager.GetCoverArtUrl(id) }) @@ -194,21 +195,21 @@ func (a *App) callOnReactivate() { } func (a *App) initMPV() error { - p := player.NewWithClientName(a.appName) + p := mpv.NewWithClientName(a.appName) c := a.Config.LocalPlayback c.InMemoryCacheSizeMB = clamp(c.InMemoryCacheSizeMB, 10, 500) if err := p.Init(c.InMemoryCacheSizeMB); err != nil { return fmt.Errorf("failed to initialize mpv player: %s", err.Error()) } - a.Player = p + a.LocalPlayer = p return nil } func (a *App) setupMPV() error { a.Config.LocalPlayback.Volume = clamp(a.Config.LocalPlayback.Volume, 0, 100) - a.Player.SetVolume(a.Config.LocalPlayback.Volume) + a.LocalPlayer.SetVolume(a.Config.LocalPlayback.Volume) - devs, err := a.Player.ListAudioDevices() + devs, err := a.LocalPlayer.ListAudioDevices() if err != nil { return err } @@ -228,35 +229,41 @@ func (a *App) setupMPV() error { // (e.g. a USB audio device that is currently unplugged) desiredDevice = "auto" } - a.Player.SetAudioDevice(desiredDevice) + a.LocalPlayer.SetAudioDevice(desiredDevice) rgainOpts := []string{ReplayGainNone, ReplayGainAlbum, ReplayGainTrack, ReplayGainAuto} if !sharedutil.SliceContains(rgainOpts, a.Config.ReplayGain.Mode) { a.Config.ReplayGain.Mode = ReplayGainNone } - mode := player.ReplayGainMode(a.Config.ReplayGain.Mode) - if a.Config.ReplayGain.Mode == ReplayGainAuto { + mode := player.ReplayGainNone + switch a.Config.ReplayGain.Mode { + case ReplayGainAlbum: + mode = player.ReplayGainAlbum + case ReplayGainTrack: + mode = player.ReplayGainTrack + case ReplayGainAuto: mode = player.ReplayGainTrack } - a.Player.SetReplayGainOptions(player.ReplayGainOptions{ + + a.LocalPlayer.SetReplayGainOptions(player.ReplayGainOptions{ Mode: mode, PreventClipping: a.Config.ReplayGain.PreventClipping, PreampGain: a.Config.ReplayGain.PreampGainDB, }) - a.Player.SetAudioExclusive(a.Config.LocalPlayback.AudioExclusive) + a.LocalPlayer.SetAudioExclusive(a.Config.LocalPlayback.AudioExclusive) - eq := &player.ISO15BandEqualizer{ + eq := &mpv.ISO15BandEqualizer{ EQPreamp: a.Config.LocalPlayback.EqualizerPreamp, Disabled: !a.Config.LocalPlayback.EqualizerEnabled, } copy(eq.BandGains[:], a.Config.LocalPlayback.GraphicEqualizerBands) - a.Player.SetEqualizer(eq) + a.LocalPlayer.SetEqualizer(eq) return nil } func (a *App) setupMPRIS(mprisAppName string) { - a.MPRISHandler = NewMPRISHandler(mprisAppName, a.Player, a.PlaybackManager) + a.MPRISHandler = NewMPRISHandler(mprisAppName, a.PlaybackManager) a.MPRISHandler.ArtURLLookup = a.ImageManager.GetCoverArtUrl a.MPRISHandler.OnRaise = func() error { a.callOnReactivate(); return nil } a.MPRISHandler.OnQuit = func() error { @@ -293,10 +300,10 @@ func (a *App) DeleteServerCacheDir(serverID uuid.UUID) error { func (a *App) Shutdown() { a.MPRISHandler.Shutdown() a.PlaybackManager.DisableCallbacks() - a.Player.Stop() // will trigger scrobble check - a.Config.LocalPlayback.Volume = a.Player.GetVolume() + a.PlaybackManager.Stop() // will trigger scrobble check + a.Config.LocalPlayback.Volume = a.LocalPlayer.GetVolume() a.cancel() - a.Player.Destroy() + a.LocalPlayer.Destroy() a.Config.WriteConfigFile(a.configPath()) os.RemoveAll(configdir.LocalConfig(a.appName, sessionDir)) } diff --git a/backend/mediaprovider/mediaprovider.go b/backend/mediaprovider/mediaprovider.go index 0068df3..23cc1f8 100644 --- a/backend/mediaprovider/mediaprovider.go +++ b/backend/mediaprovider/mediaprovider.go @@ -148,6 +148,25 @@ type SupportsRating interface { SetRating(params RatingFavoriteParameters, rating int) error } +type JukeboxProvider interface { + JukeboxStart() error + 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) +} + +type JukeboxStatus struct { + Volume int + CurrentTrack int + Playing bool + PositionSeconds float64 +} + func genresMatch(filterGenres, albumGenres []string) bool { for _, g1 := range filterGenres { for _, g2 := range albumGenres { diff --git a/backend/mediaprovider/subsonic/jukebox.go b/backend/mediaprovider/subsonic/jukebox.go new file mode 100644 index 0000000..de95d93 --- /dev/null +++ b/backend/mediaprovider/subsonic/jukebox.go @@ -0,0 +1,67 @@ +package subsonic + +import ( + "strconv" + + "github.com/dweymouth/supersonic/backend/mediaprovider" +) + +var _ mediaprovider.JukeboxProvider = (*subsonicMediaProvider)(nil) + +func (s *subsonicMediaProvider) JukeboxStart() error { + _, err := s.client.JukeboxControl("start", nil) + return err +} + +func (s *subsonicMediaProvider) JukeboxStop() error { + _, err := s.client.JukeboxControl("stop", nil) + return err +} + +func (s *subsonicMediaProvider) JukeboxClear() error { + _, err := s.client.JukeboxControl("clear", nil) + return err +} + +func (s *subsonicMediaProvider) JukeboxSetVolume(vol int) error { + _, err := s.client.JukeboxControl("setGain", + map[string]string{"gain": strconv.Itoa(vol)}) + return err +} + +func (s *subsonicMediaProvider) JukeboxSeek(idx, seconds int) error { + _, err := s.client.JukeboxControl("skip", + map[string]string{"index": strconv.Itoa(idx), "offset": strconv.Itoa(seconds)}) + return err +} + +func (s *subsonicMediaProvider) JukeboxRemove(idx int) error { + _, err := s.client.JukeboxControl("remove", + map[string]string{"index": strconv.Itoa(idx)}) + return err +} + +func (s *subsonicMediaProvider) JukeboxSet(trackID string) error { + _, err := s.client.JukeboxControl("set", + map[string]string{"id": trackID}) + return err +} + +func (s *subsonicMediaProvider) JukeboxAdd(trackID string) error { + _, err := s.client.JukeboxControl("add", + map[string]string{"id": trackID}) + return err +} + +func (s *subsonicMediaProvider) JukeboxGetStatus() (*mediaprovider.JukeboxStatus, error) { + stat, err := s.client.JukeboxControl("status", nil) + if err != nil { + return nil, err + } + return &mediaprovider.JukeboxStatus{ + Volume: int(stat.Gain * 100), + CurrentTrack: stat.CurrentIndex, + Playing: stat.Playing, + PositionSeconds: float64(stat.Position), + }, nil +} diff --git a/backend/mpmedia_mac.go b/backend/mpmedia_mac.go index 2ea3308..9e59056 100644 --- a/backend/mpmedia_mac.go +++ b/backend/mpmedia_mac.go @@ -16,13 +16,11 @@ import ( ) import ( - "fmt" "log" "strings" "unsafe" "github.com/dweymouth/supersonic/backend/mediaprovider" - "github.com/dweymouth/supersonic/player" ) // os_remote_command_callback is called by Objective-C when incoming OS media commands are received. @@ -51,7 +49,6 @@ func os_remote_command_callback(command C.Command, value C.double) { // MPMediaHandler is the handler for MacOS media controls and system events. type MPMediaHandler struct { - player *player.Player playbackManager *PlaybackManager artURLLookup func(string) (string, error) } @@ -62,9 +59,8 @@ var mpMediaEventRecipient *MPMediaHandler // NewMPMediaHandler creates a new MPMediaHandler instances and sets it as the current recipient // for incoming system events. -func InitMPMediaHandler(player *player.Player, playbackManager *PlaybackManager, artURLLookup func(trackID string) (string, error)) error { +func InitMPMediaHandler(playbackManager *PlaybackManager, artURLLookup func(trackID string) (string, error)) error { mp := &MPMediaHandler{ - player: player, playbackManager: playbackManager, artURLLookup: artURLLookup, } @@ -78,22 +74,22 @@ func InitMPMediaHandler(player *player.Player, playbackManager *PlaybackManager, go mp.updateMetadata(track) }) - mp.player.OnStopped(func() { + mp.playbackManager.OnStopped(func() { C.set_os_playback_state_stopped() }) - mp.player.OnSeek(func() { - C.update_os_now_playing_info_position(C.double(mp.player.GetStatus().TimePos)) + mp.playbackManager.OnSeek(func() { + C.update_os_now_playing_info_position(C.double(mp.playbackManager.PlayerStatus().TimePos)) }) - mp.player.OnPlaying(func() { + mp.playbackManager.OnPlaying(func() { C.set_os_playback_state_playing() - C.update_os_now_playing_info_position(C.double(mp.player.GetStatus().TimePos)) + C.update_os_now_playing_info_position(C.double(mp.playbackManager.PlayerStatus().TimePos)) }) - mp.player.OnPaused(func() { + mp.playbackManager.OnPaused(func() { C.set_os_playback_state_paused() - C.update_os_now_playing_info_position(C.double(mp.player.GetStatus().TimePos)) + C.update_os_now_playing_info_position(C.double(mp.playbackManager.PlayerStatus().TimePos)) }) return nil @@ -132,60 +128,56 @@ func (mp *MPMediaHandler) updateMetadata(track *mediaprovider.Track) { // MPMediaHandler instance received OS command 'pause' func (mp *MPMediaHandler) OnCommandPause() { - if mp == nil || mp.player == nil { + if mp == nil || mp.playbackManager == nil { return } - mp.player.Pause() + mp.playbackManager.Pause() } // MPMediaHandler instance received OS command 'play' func (mp *MPMediaHandler) OnCommandPlay() { - if mp == nil || mp.player == nil { + if mp == nil || mp.playbackManager == nil { return } - mp.player.Continue() + mp.playbackManager.Continue() } // MPMediaHandler instance received OS command 'stop' func (mp *MPMediaHandler) OnCommandStop() { - if mp == nil || mp.player == nil { + if mp == nil || mp.playbackManager == nil { return } - mp.player.Stop() + mp.playbackManager.Stop() } // MPMediaHandler instance received OS command 'toggle' func (mp *MPMediaHandler) OnCommandTogglePlayPause() { - if mp == nil || mp.player == nil { + if mp == nil || mp.playbackManager == nil { return } - if mp.player.GetStatus().State == player.Playing { - mp.OnCommandPause() - } else { - mp.OnCommandPlay() - } + mp.playbackManager.PlayPause() } // MPMediaHandler instance received OS command 'next track' func (mp *MPMediaHandler) OnCommandNextTrack() { - if mp == nil || mp.player == nil { + if mp == nil || mp.playbackManager == nil { return } - mp.player.SeekNext() + mp.playbackManager.SeekNext() } // MPMediaHandler instance received OS command 'previous track' func (mp *MPMediaHandler) OnCommandPreviousTrack() { - if mp == nil || mp.player == nil { + if mp == nil || mp.playbackManager == nil { return } - mp.player.SeekBackOrPrevious() + mp.playbackManager.SeekBackOrPrevious() } // MPMediaHandler instance received OS command to 'seek' func (mp *MPMediaHandler) OnCommandSeek(positionSeconds float64) { - if mp == nil || mp.player == nil { + if mp == nil || mp.playbackManager == nil { return } - mp.player.Seek(fmt.Sprintf("%0.2f", positionSeconds), player.SeekAbsolute) + mp.playbackManager.SeekSeconds(positionSeconds) } diff --git a/backend/mpmedia_other.go b/backend/mpmedia_other.go index 0f1ba84..44e0634 100644 --- a/backend/mpmedia_other.go +++ b/backend/mpmedia_other.go @@ -4,11 +4,9 @@ package backend import ( "errors" - - "github.com/dweymouth/supersonic/player" ) -func InitMPMediaHandler(player *player.Player, playbackManager *PlaybackManager, artURLLookup func(trackID string) (string, error)) error { +func InitMPMediaHandler(playbackManager *PlaybackManager, artURLLookup func(trackID string) (string, error)) error { // MPMediaHandler only supports macOS. return errors.New("unsupported platform") } diff --git a/backend/mpris.go b/backend/mpris.go index d1b6b3c..e60809c 100644 --- a/backend/mpris.go +++ b/backend/mpris.go @@ -3,7 +3,6 @@ package backend import ( "encoding/base32" "errors" - "fmt" "strconv" "github.com/dweymouth/supersonic/backend/mediaprovider" @@ -43,24 +42,23 @@ type MPRISHandler struct { connErr error playerName string curTrackPath string // empty for no track - p *player.Player pm *PlaybackManager s *server.Server evt *events.EventHandler } -func NewMPRISHandler(playerName string, p *player.Player, pm *PlaybackManager) *MPRISHandler { - m := &MPRISHandler{playerName: playerName, p: p, pm: pm, connErr: errors.New("not started")} +func NewMPRISHandler(playerName string, pm *PlaybackManager) *MPRISHandler { + m := &MPRISHandler{playerName: playerName, pm: pm, connErr: errors.New("not started")} m.s = server.NewServer(playerName, m, m) m.evt = events.NewEventHandler(m.s) - m.p.OnSeek(func() { + pm.OnSeek(func() { if m.connErr == nil { - pos := secondsToMicroseconds(m.p.GetStatus().TimePos) + pos := secondsToMicroseconds(pm.PlayerStatus().TimePos) m.evt.Player.OnSeek(pos) } }) - m.pm.OnSongChange(func(tr, _ *mediaprovider.Track) { + pm.OnSongChange(func(tr, _ *mediaprovider.Track) { if m.connErr == nil { m.evt.Player.OnTitle() } @@ -70,7 +68,7 @@ func NewMPRISHandler(playerName string, p *player.Player, pm *PlaybackManager) * m.curTrackPath = dbusTrackIDPrefix + encodeTrackId(tr.ID) } }) - m.pm.OnVolumeChange(func(vol int) { + pm.OnVolumeChange(func(vol int) { if m.connErr == nil { m.evt.Player.OnVolume() } @@ -80,9 +78,9 @@ func NewMPRISHandler(playerName string, p *player.Player, pm *PlaybackManager) * m.evt.Player.OnPlayPause() } } - m.p.OnStopped(emitPlayStatus) - m.p.OnPlaying(emitPlayStatus) - m.p.OnPaused(emitPlayStatus) + m.pm.OnStopped(emitPlayStatus) + m.pm.OnPlaying(emitPlayStatus) + m.pm.OnPaused(emitPlayStatus) return m } @@ -147,45 +145,47 @@ func (m *MPRISHandler) SupportedMimeTypes() ([]string, error) { // OrgMprisMediaPlayer2PlayerAdapter implementation func (m *MPRISHandler) Next() error { - return m.p.SeekNext() + return m.pm.SeekNext() } func (m *MPRISHandler) Previous() error { - return m.p.SeekBackOrPrevious() + return m.pm.SeekBackOrPrevious() } func (m *MPRISHandler) Pause() error { - if m.p.GetStatus().State == player.Playing { - return m.p.PlayPause() + if m.pm.PlayerStatus().State == player.Playing { + return m.pm.PlayPause() } return nil } func (m *MPRISHandler) PlayPause() error { - return m.p.PlayPause() + return m.pm.PlayPause() } func (m *MPRISHandler) Stop() error { - return m.p.Stop() + return m.pm.Stop() } func (m *MPRISHandler) Play() error { - switch m.p.GetStatus().State { + switch m.pm.PlayerStatus().State { case player.Paused: - return m.p.PlayPause() + return m.pm.PlayPause() case player.Stopped: - return m.p.PlayFromBeginning() + return m.pm.PlayFromBeginning() } return nil } func (m *MPRISHandler) Seek(offset types.Microseconds) error { - return m.p.Seek(fmt.Sprintf("%0.2f", microsecondsToSeconds(offset)), player.SeekRelative) + // MPRIS seek command is relative to current position + pos := m.pm.PlayerStatus().TimePos + microsecondsToSeconds(offset) + return m.pm.SeekSeconds(pos) } func (m *MPRISHandler) SetPosition(trackId string, position types.Microseconds) error { if m.curTrackPath == trackId { - return m.p.Seek(fmt.Sprintf("%0.2f", microsecondsToSeconds(position)), player.SeekAbsolute) + return m.pm.SeekSeconds(microsecondsToSeconds(position)) } return nil } @@ -195,7 +195,7 @@ func (m *MPRISHandler) OpenUri(uri string) error { } func (m *MPRISHandler) PlaybackStatus() (types.PlaybackStatus, error) { - switch m.p.GetStatus().State { + switch m.pm.PlayerStatus().State { case player.Playing: return types.PlaybackStatusPlaying, nil case player.Paused: @@ -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 LoopAll: return types.LoopStatusPlaylist, nil - case LoopModeOne: + case LoopOne: return types.LoopStatusTrack, nil - case LoopModeNone: + case LoopNone: return types.LoopStatusNone, nil } return "", errors.New("unknown loop status") @@ -221,13 +221,15 @@ 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) + m.pm.SetLoopMode(LoopAll) case types.LoopStatusTrack: - return m.pm.SetLoopMode(LoopModeOne) + m.pm.SetLoopMode(LoopOne) case types.LoopStatusNone: - return m.pm.SetLoopMode(LoopModeNone) + m.pm.SetLoopMode(LoopNone) + default: + return errors.New("unknown loop status") } - return errors.New("unknown loop status") + return nil } func (m *MPRISHandler) Rate() (float64, error) { @@ -243,7 +245,7 @@ func (m *MPRISHandler) Metadata() (types.Metadata, error) { if m.curTrackPath != "" { trackObjPath = m.curTrackPath } - status := m.p.GetStatus() + status := m.pm.PlayerStatus() var tr mediaprovider.Track if np := m.pm.NowPlaying(); np != nil && status.State != player.Stopped { tr = *np @@ -271,7 +273,7 @@ func (m *MPRISHandler) Metadata() (types.Metadata, error) { } func (m *MPRISHandler) Volume() (float64, error) { - return float64(m.p.GetVolume()) / 100, nil + return float64(m.pm.Volume()) / 100, nil } func (m *MPRISHandler) SetVolume(v float64) error { @@ -279,7 +281,7 @@ func (m *MPRISHandler) SetVolume(v float64) error { } func (m *MPRISHandler) Position() (int64, error) { - return int64(secondsToMicroseconds(m.p.GetStatus().TimePos)), nil + return int64(secondsToMicroseconds(m.pm.PlayerStatus().TimePos)), nil } func (m *MPRISHandler) MinimumRate() (float64, error) { @@ -326,4 +328,3 @@ func encodeTrackId(id string) string { data := []byte(id) return base32.StdEncoding.WithPadding('0').EncodeToString(data) } - diff --git a/backend/playbackmanager.go b/backend/playbackmanager.go index 7522681..e9b1813 100644 --- a/backend/playbackmanager.go +++ b/backend/playbackmanager.go @@ -2,6 +2,7 @@ package backend import ( "context" + "errors" "log" "math/rand" "time" @@ -12,30 +13,29 @@ import ( "github.com/dweymouth/supersonic/sharedutil" ) -const ( - ReplayGainNone = string(player.ReplayGainNone) - ReplayGainAlbum = string(player.ReplayGainAlbum) - ReplayGainTrack = string(player.ReplayGainTrack) +var ( + ReplayGainNone = player.ReplayGainNone.String() + ReplayGainAlbum = player.ReplayGainAlbum.String() + ReplayGainTrack = player.ReplayGainTrack.String() ReplayGainAuto = "Auto" ) +// The playback loop mode (LoopNone, LoopAll, LoopOne). type LoopMode int const ( - LoopModeNone LoopMode = LoopMode(player.LoopNone) - LoopModeAll LoopMode = LoopMode(player.LoopAll) - LoopModeOne LoopMode = LoopMode(player.LoopOne) + LoopNone LoopMode = iota + LoopAll + LoopOne ) -// A high-level Subsonic-aware playback backend. -// Manages loading tracks into the Player queue, -// sending callbacks on play time updates and track changes. +// A high-level MediaProvider-aware playback engine, serves as an +// intermediary between the frontend and various Player backends. type PlaybackManager struct { ctx context.Context cancelPollPos context.CancelFunc - pollingTick *time.Ticker sm *ServerManager - player *player.Player + player player.BasePlayer playTimeStopwatch util.Stopwatch curTrackTime float64 @@ -43,7 +43,8 @@ type PlaybackManager struct { callbacksDisabled bool playQueue []*mediaprovider.Track - nowPlayingIdx int64 + nowPlayingIdx int + loopMode LoopMode // to pass to onSongChange listeners; clear once listeners have been called lastScrobbled *mediaprovider.Track @@ -51,59 +52,50 @@ type PlaybackManager struct { transcodeCfg *TranscodingConfig replayGainCfg ReplayGainConfig + // registered callbacks onSongChange []func(nowPlaying, justScrobbledIfAny *mediaprovider.Track) onPlayTimeUpdate []func(float64, float64) onLoopModeChange []func(LoopMode) onVolumeChange []func(int) + onSeek []func() + onPaused []func() + onStopped []func() + onPlaying []func() + onPlayerChange []func() } func NewPlaybackManager( ctx context.Context, s *ServerManager, - p *player.Player, + p player.BasePlayer, scrobbleCfg *ScrobbleConfig, transcodeCfg *TranscodingConfig, ) *PlaybackManager { // clamp to 99% to avoid any possible rounding issues scrobbleCfg.ThresholdPercent = clamp(scrobbleCfg.ThresholdPercent, 0, 99) pm := &PlaybackManager{ - ctx: ctx, - sm: s, - player: p, - scrobbleCfg: scrobbleCfg, - transcodeCfg: transcodeCfg, + ctx: ctx, + sm: s, + player: p, + scrobbleCfg: scrobbleCfg, + transcodeCfg: transcodeCfg, + nowPlayingIdx: -1, } - p.OnTrackChange(func(tracknum int64) { - if tracknum >= int64(len(pm.playQueue)) { - return - } - pm.checkScrobble() // scrobble the previous song if needed - if pm.player.GetStatus().State == player.Playing { - pm.playTimeStopwatch.Start() - } - pm.nowPlayingIdx = tracknum - pm.curTrackTime = float64(pm.playQueue[pm.nowPlayingIdx].Duration) - pm.sendNowPlayingScrobble() // Must come before invokeOnChangeCallbacks b/c track may immediately be scrobbled - pm.invokeOnSongChangeCallbacks() - pm.doUpdateTimePos() - }) + p.OnTrackChange(pm.handleOnTrackChange) p.OnSeek(func() { pm.doUpdateTimePos() + pm.invokeNoArgCallbacks(pm.onSeek) }) - p.OnStopped(func() { - pm.playTimeStopwatch.Stop() - pm.checkScrobble() - pm.stopPollTimePos() - pm.doUpdateTimePos() - pm.invokeOnSongChangeCallbacks() - }) + p.OnStopped(pm.handleOnStopped) p.OnPaused(func() { pm.playTimeStopwatch.Stop() pm.stopPollTimePos() + pm.invokeNoArgCallbacks(pm.onPaused) }) p.OnPlaying(func() { pm.playTimeStopwatch.Start() pm.startPollTimePos() + pm.invokeNoArgCallbacks(pm.onPlaying) }) s.OnLogout(func() { @@ -113,6 +105,14 @@ func NewPlaybackManager( return pm } +func (p *PlaybackManager) CurrentPlayer() player.BasePlayer { + return p.player +} + +func (p *PlaybackManager) OnPlayerChange(cb func()) { + p.onPlayerChange = append(p.onPlayerChange, cb) +} + func (p *PlaybackManager) IsSeeking() bool { return p.player.IsSeeking() } @@ -155,6 +155,26 @@ func (p *PlaybackManager) OnVolumeChange(cb func(int)) { p.onVolumeChange = append(p.onVolumeChange, cb) } +// Registers a callback that is notified whenever the player has been seeked. +func (p *PlaybackManager) OnSeek(cb func()) { + p.onSeek = append(p.onSeek, cb) +} + +// Registers a callback that is notified whenever the player has been paused. +func (p *PlaybackManager) OnPaused(cb func()) { + p.onPaused = append(p.onPaused, cb) +} + +// Registers a callback that is notified whenever the player is stopped. +func (p *PlaybackManager) OnStopped(cb func()) { + p.onStopped = append(p.onStopped, cb) +} + +// Registers a callback that is notified whenever the player begins playing. +func (p *PlaybackManager) OnPlaying(cb func()) { + p.onPlaying = append(p.onPlaying, cb) +} + // Loads the specified album into the play queue. func (p *PlaybackManager) LoadAlbum(albumID string, appendToQueue bool, shuffle bool) error { album, err := p.sm.Server.GetAlbum(albumID) @@ -173,28 +193,29 @@ func (p *PlaybackManager) LoadPlaylist(playlistID string, appendToQueue bool, sh return p.LoadTracks(playlist.Tracks, appendToQueue, shuffle) } +// Load tracks into the play queue. +// If replacing the current queue (!appendToQueue), playback will be stopped. func (p *PlaybackManager) LoadTracks(tracks []*mediaprovider.Track, appendToQueue, shuffle bool) error { if !appendToQueue { p.player.Stop() - p.nowPlayingIdx = 0 + p.nowPlayingIdx = -1 p.playQueue = nil } nums := util.Range(len(tracks)) if shuffle { rand.Shuffle(len(nums), func(i, j int) { nums[i], nums[j] = nums[j], nums[i] }) } + needToSetNext := appendToQueue && len(tracks) > 0 && p.nowPlayingIdx == len(p.playQueue)-1 for _, i := range nums { - url, err := p.sm.Server.GetStreamURL(tracks[i].ID, p.transcodeCfg.ForceRawFile) - if err != nil { - return err - } - p.player.AppendFile(url) // ensure a deep copy of the track info so that we can maintain our own state // (tracking play count increases, favorite, and rating) without messing up // other views' track models tr := *tracks[i] p.playQueue = append(p.playQueue, &tr) } + if needToSetNext { + p.setNextTrack(p.nowPlayingIdx + 1) + } return nil } @@ -205,10 +226,7 @@ func (p *PlaybackManager) PlayAlbum(albumID string, firstTrack int, shuffle bool if p.replayGainCfg.Mode == ReplayGainAuto { p.SetReplayGainMode(player.ReplayGainAlbum) } - if firstTrack <= 0 { - return p.player.PlayFromBeginning() - } - return p.player.PlayTrackAt(firstTrack) + return p.PlayTrackAt(firstTrack) } func (p *PlaybackManager) PlayPlaylist(playlistID string, firstTrack int, shuffle bool) error { @@ -218,10 +236,7 @@ func (p *PlaybackManager) PlayPlaylist(playlistID string, firstTrack int, shuffl if p.replayGainCfg.Mode == ReplayGainAuto { p.SetReplayGainMode(player.ReplayGainTrack) } - if firstTrack <= 0 { - return p.player.PlayFromBeginning() - } - return p.player.PlayTrackAt(firstTrack) + return p.PlayTrackAt(firstTrack) } func (p *PlaybackManager) PlayTrack(trackID string) error { @@ -237,28 +252,32 @@ func (p *PlaybackManager) PlayTrack(trackID string) error { } func (p *PlaybackManager) PlayFromBeginning() error { - return p.player.PlayFromBeginning() + return p.PlayTrackAt(0) } func (p *PlaybackManager) PlayTrackAt(idx int) error { - return p.player.PlayTrackAt(idx) + if idx < 0 || idx >= len(p.playQueue) { + return errors.New("track index out of range") + } + p.nowPlayingIdx = idx - 1 + return p.setTrack(idx, false) } func (p *PlaybackManager) PlayRandomSongs(genreName string) { - if songs, err := p.sm.Server.GetRandomTracks(genreName, 100); err != nil { - log.Printf("error getting random songs: %s", err.Error()) - } else { - p.LoadTracks(songs, false, false) - if p.replayGainCfg.Mode == ReplayGainAuto { - p.SetReplayGainMode(player.ReplayGainTrack) - } - p.PlayFromBeginning() - } + p.fetchAndPlayTracks(func() ([]*mediaprovider.Track, error) { + return p.sm.Server.GetRandomTracks(genreName, 100) + }) } func (p *PlaybackManager) PlaySimilarSongs(id string) { - if songs, err := p.sm.Server.GetSimilarTracks(id, 100); err != nil { - log.Printf("error getting similar songs: %s", err.Error()) + p.fetchAndPlayTracks(func() ([]*mediaprovider.Track, error) { + return p.sm.Server.GetSimilarTracks(id, 100) + }) +} + +func (p *PlaybackManager) fetchAndPlayTracks(fetchFn func() ([]*mediaprovider.Track, error)) { + if songs, err := fetchFn(); err != nil { + log.Printf("error fetching tracks: %s", err.Error()) } else { p.LoadTracks(songs, false, false) if p.replayGainCfg.Mode == ReplayGainAuto { @@ -295,23 +314,23 @@ func (p *PlaybackManager) OnTrackRatingChanged(id string, rating int) { func (p *PlaybackManager) RemoveTracksFromQueue(trackIDs []string) { newQueue := make([]*mediaprovider.Track, 0, len(p.playQueue)-len(trackIDs)) - rmCount := 0 idSet := sharedutil.ToSet(trackIDs) isPlayingTrackRemoved := false + isNextPlayingTrackremoved := false + nowPlaying := p.NowPlayingIndex() + newNowPlaying := nowPlaying for i, tr := range p.playQueue { if _, ok := idSet[tr.ID]; ok { - // removing this track - if i == p.NowPlayingIndex() { + if i < nowPlaying { + // if removing a track earlier than the currently playing one (if any), + // decrement new now playing index by one to account for new position in queue + newNowPlaying-- + } else if i == nowPlaying { isPlayingTrackRemoved = true // If we are removing the currently playing track, we need to scrobble it p.checkScrobble() - } - if err := p.player.RemoveTrackAt(i - rmCount); err == nil { - rmCount++ - } else { - log.Printf("error removing track: %v", err.Error()) - // did not remove this track - newQueue = append(newQueue, tr) + } else if nowPlaying >= 0 && i == nowPlaying+1 { + isNextPlayingTrackremoved = true } } else { // not removing this track @@ -319,28 +338,54 @@ func (p *PlaybackManager) RemoveTracksFromQueue(trackIDs []string) { } } p.playQueue = newQueue - p.nowPlayingIdx = p.player.GetStatus().PlaylistPos - // fire on song change callbacks in case the playing track was removed + p.nowPlayingIdx = newNowPlaying if isPlayingTrackRemoved { - p.invokeOnSongChangeCallbacks() + if newNowPlaying == len(newQueue) { + // we had been playing the last track, and removed it + p.Stop() + } else { + p.nowPlayingIdx -= 1 // will be incremented in newtrack callback from player + p.setTrack(newNowPlaying, false) + } + // setNextTrack and onSongChange callbacks will be handled + // when we receive new track event from player + } else if isNextPlayingTrackremoved { + if newNowPlaying < len(newQueue)-1 { + p.setNextTrack(p.nowPlayingIdx + 1) + } else { + // no next track to play + p.setNextTrack(-1) + } } } // Stop playback and clear the play queue. func (p *PlaybackManager) StopAndClearPlayQueue() { p.player.Stop() - p.player.ClearPlayQueue() p.doUpdateTimePos() p.playQueue = nil + p.nowPlayingIdx = -1 } func (p *PlaybackManager) SetReplayGainOptions(config ReplayGainConfig) { - p.replayGainCfg = config - mode := player.ReplayGainMode(config.Mode) - if config.Mode == ReplayGainAuto { - mode = player.ReplayGainTrack + rGainPlayer, ok := p.player.(player.ReplayGainPlayer) + if !ok { + log.Println("Error: player doesn't support ReplayGain") + return } - p.player.SetReplayGainOptions(player.ReplayGainOptions{ + + p.replayGainCfg = config + mode := player.ReplayGainNone + switch config.Mode { + case ReplayGainAuto: + mode = player.ReplayGainTrack + case ReplayGainTrack: + mode = player.ReplayGainTrack + case ReplayGainAlbum: + mode = player.ReplayGainAlbum + } + + rGainPlayer.SetReplayGainOptions(player.ReplayGainOptions{ Mode: mode, PreventClipping: config.PreventClipping, PreampGain: config.PreampGainDB, @@ -348,7 +393,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, @@ -357,32 +407,33 @@ 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 - } +func (p *PlaybackManager) SetNextLoopMode() { + switch p.loopMode { + case LoopNone: + p.SetLoopMode(LoopAll) + case LoopAll: + p.SetLoopMode(LoopOne) + case LoopOne: + p.SetLoopMode(LoopNone) - for _, cb := range p.onLoopModeChange { - cb(LoopMode(p.player.GetLoopMode())) } - - return nil } -func (p *PlaybackManager) SetLoopMode(loopMode LoopMode) error { - if err := p.player.SetLoopMode(player.LoopMode(loopMode)); err != nil { - return err - } +func (p *PlaybackManager) SetLoopMode(loopMode LoopMode) { + p.loopMode = loopMode + p.setNextTrackBasedOnLoopMode(true) for _, cb := range p.onLoopModeChange { cb(loopMode) } - - return nil } -func (p *PlaybackManager) LoopMode() LoopMode { - return LoopMode(p.player.GetLoopMode()) +func (p *PlaybackManager) GetLoopMode() LoopMode { + return p.loopMode +} + +func (p *PlaybackManager) PlayerStatus() player.Status { + return p.player.GetStatus() } func (p *PlaybackManager) SetVolume(vol int) error { @@ -400,6 +451,143 @@ func (p *PlaybackManager) Volume() int { return p.player.GetVolume() } +func (p *PlaybackManager) SeekNext() error { + if p.CurrentPlayer().GetStatus().State == player.Stopped { + return nil + } + return p.PlayTrackAt(p.nowPlayingIdx + 1) +} + +func (p *PlaybackManager) SeekBackOrPrevious() error { + if p.nowPlayingIdx == 0 || p.player.GetStatus().TimePos > 3 { + return p.player.SeekSeconds(0) + } + return p.PlayTrackAt(p.nowPlayingIdx - 1) +} + +// Seek to given absolute position in the current track by seconds. +func (p *PlaybackManager) SeekSeconds(sec float64) error { + return p.player.SeekSeconds(sec) +} + +// Seek to a fractional position in the current track [0..1] +func (p *PlaybackManager) SeekFraction(fraction float64) error { + if fraction < 0 { + fraction = 0 + } else if fraction > 1 { + fraction = 1 + } + target := p.curTrackTime * fraction + return p.player.SeekSeconds(target) +} + +func (p *PlaybackManager) Stop() error { + return p.player.Stop() +} + +func (p *PlaybackManager) Pause() error { + return p.player.Pause() +} + +func (p *PlaybackManager) Continue() error { + if p.player.GetStatus().State == player.Stopped { + return p.PlayFromBeginning() + } + return p.player.Continue() +} + +func (p *PlaybackManager) PlayPause() error { + switch p.player.GetStatus().State { + case player.Playing: + return p.player.Pause() + case player.Paused: + return p.player.Continue() + case player.Stopped: + return p.PlayFromBeginning() + } + return errors.New("unreached - invalid player state") +} + +func (p *PlaybackManager) handleOnTrackChange() { + p.checkScrobble() // scrobble the previous song if needed + if p.player.GetStatus().State == player.Playing { + p.playTimeStopwatch.Start() + } + if p.loopMode != LoopOne { + p.nowPlayingIdx++ + if p.loopMode == LoopAll && p.nowPlayingIdx == len(p.playQueue) { + p.nowPlayingIdx = 0 // wrapped around + } + } + p.curTrackTime = float64(p.playQueue[p.nowPlayingIdx].Duration) + p.sendNowPlayingScrobble() // Must come before invokeOnChangeCallbacks b/c track may immediately be scrobbled + p.invokeOnSongChangeCallbacks() + p.doUpdateTimePos() + p.setNextTrackBasedOnLoopMode(false) +} + +func (p *PlaybackManager) handleOnStopped() { + p.playTimeStopwatch.Stop() + p.checkScrobble() + p.stopPollTimePos() + p.doUpdateTimePos() + p.invokeOnSongChangeCallbacks() + p.invokeNoArgCallbacks(p.onStopped) + p.nowPlayingIdx = -1 +} + +func (p *PlaybackManager) setNextTrackBasedOnLoopMode(onLoopModeChange bool) { + switch p.loopMode { + case LoopNone: + if p.nowPlayingIdx < len(p.playQueue)-1 { + p.setNextTrack(p.nowPlayingIdx + 1) + } else if onLoopModeChange { + // prev was LoopOne - need to erase next track + p.setNextTrack(-1) + } + case LoopOne: + p.setNextTrack(p.nowPlayingIdx) + case LoopAll: + if p.nowPlayingIdx >= len(p.playQueue)-1 { + p.setNextTrack(0) + } else if !onLoopModeChange { + // if onloopmodechange, prev mode was LoopNone and next track is already set + p.setNextTrack(p.nowPlayingIdx + 1) + } + } +} + +func (p *PlaybackManager) setTrack(idx int, next bool) error { + if urlP, ok := p.player.(player.URLPlayer); ok { + url := "" + if idx >= 0 { + var err error + url, err = p.sm.Server.GetStreamURL(p.playQueue[idx].ID, p.transcodeCfg.ForceRawFile) + if err != nil { + return err + } + } + if next { + return urlP.SetNextFile(url) + } + return urlP.PlayFile(url) + } else if trP, ok := p.player.(player.TrackPlayer); ok { + var track *mediaprovider.Track + if idx >= 0 { + track = p.playQueue[idx] + } + if next { + return trP.SetNextTrack(track) + } + return trP.PlayTrack(track) + } + panic("Unsupported player type") +} + +func (p *PlaybackManager) setNextTrack(idx int) error { + return p.setTrack(idx, true) +} + // call BEFORE updating p.nowPlayingIdx func (p *PlaybackManager) checkScrobble() { if !p.scrobbleCfg.Enabled || len(p.playQueue) == 0 || p.nowPlayingIdx < 0 { @@ -450,26 +638,40 @@ func (p *PlaybackManager) invokeOnSongChangeCallbacks() { p.lastScrobbled = nil } +func (pm *PlaybackManager) invokeNoArgCallbacks(cbs []func()) { + if pm.callbacksDisabled { + return + } + for _, cb := range cbs { + cb() + } +} + func (p *PlaybackManager) startPollTimePos() { ctx, cancel := context.WithCancel(p.ctx) p.cancelPollPos = cancel - p.pollingTick = time.NewTicker(250 * time.Millisecond) + pollingTick := time.NewTicker(250 * time.Millisecond) - // TODO: fix occasional nil pointer dereference on app quit go func() { for { select { case <-ctx.Done(): - p.pollingTick.Stop() - p.pollingTick = nil + pollingTick.Stop() return - case <-p.pollingTick.C: + case <-pollingTick.C: p.doUpdateTimePos() } } }() } +func (p *PlaybackManager) stopPollTimePos() { + if p.cancelPollPos != nil { + p.cancelPollPos() + p.cancelPollPos = nil + } +} + func (p *PlaybackManager) doUpdateTimePos() { if p.callbacksDisabled { return @@ -482,13 +684,3 @@ func (p *PlaybackManager) doUpdateTimePos() { cb(s.TimePos, s.Duration) } } - -func (p *PlaybackManager) stopPollTimePos() { - if p.cancelPollPos != nil { - p.cancelPollPos() - p.cancelPollPos = nil - } - if p.pollingTick != nil { - p.pollingTick.Stop() - } -} diff --git a/go.mod b/go.mod index 369c2a1..46633a3 100644 --- a/go.mod +++ b/go.mod @@ -8,7 +8,7 @@ require ( github.com/deluan/sanitize v0.0.0-20230310221930-6e18967d9fc1 github.com/dweymouth/go-jellyfin v0.0.0-20231116161116-e800860bdacc github.com/dweymouth/go-mpv v0.0.0-20230406003141-7f1858e503ee - github.com/dweymouth/go-subsonic v0.0.0-20231216234924-3f62122dc53a + github.com/dweymouth/go-subsonic v0.0.0-20231217175944-9b48c9ffc002 github.com/fsnotify/fsnotify v1.6.0 github.com/godbus/dbus/v5 v5.1.0 github.com/google/uuid v1.3.0 diff --git a/go.sum b/go.sum index 97ad075..fd340a1 100644 --- a/go.sum +++ b/go.sum @@ -75,8 +75,8 @@ github.com/dweymouth/go-jellyfin v0.0.0-20231116161116-e800860bdacc h1:wJy4U12Ys github.com/dweymouth/go-jellyfin v0.0.0-20231116161116-e800860bdacc/go.mod h1:BMwS4vdjEYf1gmjPGSKCzWP/I6YlI6fkefJ9nsjBjaU= github.com/dweymouth/go-mpv v0.0.0-20230406003141-7f1858e503ee h1:ZGyJ6wp7CAfT31BugypcF/TPKEy2RrGR9JFq1JOjOpY= github.com/dweymouth/go-mpv v0.0.0-20230406003141-7f1858e503ee/go.mod h1:Ov0ieN90M7i+0k3OxhA/g1dozGs+UcPHDsMKqPgRDk0= -github.com/dweymouth/go-subsonic v0.0.0-20231216234924-3f62122dc53a h1:UJcwloSIzRpUOIwRE207JF2SHoTmOg7vJWzfQWpztSk= -github.com/dweymouth/go-subsonic v0.0.0-20231216234924-3f62122dc53a/go.mod h1:OWtcumdQsan8uM6wmx6PqKhldaCthH10CQ+vb+94kzo= +github.com/dweymouth/go-subsonic v0.0.0-20231217175944-9b48c9ffc002 h1:DhWQZJObkCUSFmHu/eEzHRqy0R33DcR266nPAvZJE34= +github.com/dweymouth/go-subsonic v0.0.0-20231217175944-9b48c9ffc002/go.mod h1:OWtcumdQsan8uM6wmx6PqKhldaCthH10CQ+vb+94kzo= github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98= diff --git a/player/jukebox/jukeboxplayer.go b/player/jukebox/jukeboxplayer.go new file mode 100644 index 0000000..5525cc9 --- /dev/null +++ b/player/jukebox/jukeboxplayer.go @@ -0,0 +1,132 @@ +package jukebox + +import ( + "github.com/dweymouth/supersonic/backend/mediaprovider" + "github.com/dweymouth/supersonic/player" +) + +const ( + stopped = 0 + playing = 1 + paused = 2 +) + +type JukeboxPlayer struct { + provider mediaprovider.JukeboxProvider + + state int // stopped, playing, paused + volume int + seeking bool + numTracks int + + curTrack int + curTrackDuration float64 + startTrackTime float64 + startedAtUnixSecs float64 +} + +func (j *JukeboxPlayer) SetVolume(vol int) error { + go func() { + if err := j.provider.JukeboxSetVolume(vol); err == nil { + j.volume = vol + } + }() + return nil +} + +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 + }() + return nil +} + +func (j *JukeboxPlayer) Pause() error { + if j.state != playing { + return nil + } + go func() { + if err := j.provider.JukeboxStop(); err != nil { + return + } + j.state = paused + }() + return nil +} + +func (j *JukeboxPlayer) Stop() error { + if j.state == stopped { + return nil + } + go func() { + if err := j.provider.JukeboxStop(); err != nil { + return + } + j.state = stopped + }() + return nil +} + +func (j *JukeboxPlayer) SeekPrevious() error { + track := j.curTrack + if track > 0 { + track = j.curTrack - 1 + } + return j.PlayTrackAt(track) +} + +func (j *JukeboxPlayer) SeekNext() error { + track := j.curTrack + if track >= j.numTracks { + return nil + } + return j.PlayTrackAt(track + 1) +} + +func (j *JukeboxPlayer) SeekSeconds(secs float64) error { + j.seeking = true + go func() { + j.provider.JukeboxSeek(j.curTrack, int(secs)) + j.seeking = false + }() + return nil +} + +func (j *JukeboxPlayer) IsSeeking() bool { + return j.seeking +} + +func (j *JukeboxPlayer) GetStatus() player.Status { + state := player.Stopped + if j.state == playing { + state = player.Playing + } else if j.state == paused { + state = player.Paused + } + + // TODO - the rest + + return player.Status{ + State: state, + } +} diff --git a/player/equalizer.go b/player/mpv/equalizer.go similarity index 99% rename from player/equalizer.go rename to player/mpv/equalizer.go index 1e81e5d..e3a1eaa 100644 --- a/player/equalizer.go +++ b/player/mpv/equalizer.go @@ -1,4 +1,4 @@ -package player +package mpv // Equalizer implementations based on the ffmpeg 'equalizer' filter diff --git a/player/mpv/player.go b/player/mpv/player.go new file mode 100644 index 0000000..a42a027 --- /dev/null +++ b/player/mpv/player.go @@ -0,0 +1,513 @@ +package mpv + +import ( + "context" + "errors" + "fmt" + "math" + "strconv" + + "github.com/dweymouth/go-mpv" + "github.com/dweymouth/supersonic/player" +) + +// Error returned by many Player functions if called before the player has not been initialized. +var ErrUnitialized error = errors.New("mpv player uninitialized") + +// Information about a specific audio device. +// Returned by ListAudioDevices. +type AudioDevice struct { + // The name of the audio device. + // This is the string to pass to SetAudioDevice. + Name string + + // The description of the audio device. + // This is the friendly string that should be used in UIs. + Description string +} + +// Media information about the currently playing media. +type MediaInfo struct { + // The sample format as string. This uses the same names as used in other places of mpv. + // NOTE: this is the format that the decoder outputs, NOT necessarily the format of the file. + Format string + + // Audio samplerate. + Samplerate int + + // The number of channels. + ChannelCount int + + // The audio codec. + Codec string + + // The average bit rate in bits per second. + 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 { + mpv *mpv.Mpv + initialized bool + vol int + replayGainOpts player.ReplayGainOptions + haveRGainOpts bool + audioExclusive bool + status player.Status + seeking bool + curPlaylistPos int64 + lenPlaylist int64 + prePausedState player.State + clientName string + equalizer Equalizer + + bgCancel context.CancelFunc + + // callbacks + onPaused []func() + onStopped []func() + onPlaying []func() + onSeek []func() + onTrackChange []func() +} + +// Returns a new player. +// Must call Init on the player before it is ready for playback. +func New() *Player { + return NewWithClientName("") +} + +// Same as New, but sets the application name that mpv +// reports to the system audio API. +func NewWithClientName(c string) *Player { + return &Player{ + vol: -1, // use 100 in Init + clientName: c, + } +} + +// Initializes the Player and makes it ready for playback. +// Most Player functions will return ErrUnitialized if called before Init. +func (p *Player) Init(maxCacheMB int) error { + if !p.initialized { + m := mpv.Create() + + m.SetOptionString("idle", "yes") + m.SetOptionString("video", "no") + m.SetOptionString("audio-display", "no") + m.SetOptionString("gapless-audio", "weak") + m.SetOptionString("prefetch-playlist", "yes") + m.SetOptionString("force-seekable", "yes") + m.SetOptionString("terminal", "no") + + // limit in-memory cache size + m.SetOptionString("demuxer-max-bytes", fmt.Sprintf("%dMiB", maxCacheMB)) + + if p.vol < 0 { + p.vol = 100 + } + m.SetOption("volume", mpv.FORMAT_INT64, p.vol) + + p.SetAudioExclusive(p.audioExclusive) + if p.haveRGainOpts { + p.SetReplayGainOptions(p.replayGainOpts) + } + + if p.clientName != "" { + m.SetOptionString("audio-client-name", p.clientName) + } + + if err := m.Initialize(); err != nil { + return fmt.Errorf("error initializing mpv: %s", err.Error()) + } + p.mpv = m + } + ctx, cancel := context.WithCancel(context.Background()) + go p.eventHandler(ctx) + p.bgCancel = cancel + p.initialized = true + return nil +} + +// Plays the specified file, clearing the previous play queue, if any. +func (p *Player) PlayFile(url string) error { + if !p.initialized { + return ErrUnitialized + } + err := p.mpv.Command([]string{"loadfile", url, "replace"}) + if err == nil { + p.setState(player.Playing) + p.lenPlaylist = 1 + } + return err +} + +// Stops playback and clears the play queue. +func (p *Player) Stop() error { + if !p.initialized { + return ErrUnitialized + } + var err error + if p.status.State == player.Stopped { + err = p.mpv.Command([]string{"playlist-clear"}) + } else { + if err = p.mpv.Command([]string{"stop"}); err == nil { + // if player was paused, stop command actually doesn't clear pause state + err = p.setPaused(false) + } + } + if err == nil { + p.lenPlaylist = 0 + p.setState(player.Stopped) + } + return err +} + +func (p *Player) SetNextFile(url string) error { + if p.lenPlaylist > p.curPlaylistPos+1 { + if err := p.mpv.Command([]string{"playlist-remove", strconv.Itoa(int(p.curPlaylistPos) + 1)}); err != nil { + return err + } + p.lenPlaylist-- + } + if url == "" { + return nil + } + + err := p.mpv.Command([]string{"loadfile", url, "append"}) + if err == nil { + p.lenPlaylist++ + } + return err +} + +// Seeks within the currently playing track. +// See MPV seek command documentation for more details. +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, "absolute"}) + return err +} + +// Sets the volume of the player (0-100). +// Unlike most Player functions, SetVolume can be called before Init, +// to set the initial volume of the player on startup. +func (p *Player) SetVolume(vol int) error { + if vol > 100 { + vol = 100 + } else if vol < 0 { + vol = 0 + } + if p.initialized { + err := p.mpv.SetProperty("volume", mpv.FORMAT_INT64, vol) + if err == nil { + p.vol = vol + } + return err + } + p.vol = vol + return nil +} + +// Sets the ReplayGain options of the player. +// Unlike most Player functions, SetReplayGainOptions can be called +// before Init, to set the initial replaygain options of the player on startup. +func (p *Player) SetReplayGainOptions(options player.ReplayGainOptions) error { + p.replayGainOpts = options + p.haveRGainOpts = true + mode := "no" + switch options.Mode { + case player.ReplayGainAlbum: + mode = "album" + case player.ReplayGainTrack: + mode = "track" + } + + if p.initialized { + if err := p.mpv.SetPropertyString("replaygain", mode); err != nil { + return err + } + if err := p.mpv.SetProperty("replaygain-preamp", mpv.FORMAT_DOUBLE, options.PreampGain); err != nil { + return err + } + clip := "yes" + if options.PreventClipping { + clip = "no" + } + if err := p.mpv.SetPropertyString("replaygain-clip", clip); err != nil { + return err + } + } + return nil +} + +// Sets the audio exclusive option of the player. +// Unlike most Player functions, SetAudioExclusive can be called +// before Init, to set the initial option of the player on startup. +func (p *Player) SetAudioExclusive(tf bool) { + p.audioExclusive = tf + if p.initialized { + val := "no" + if tf { + val = "yes" + } + p.mpv.SetOptionString("audio-exclusive", val) + } +} + +// Gets the current volume of the player. +func (p *Player) GetVolume() int { + return p.vol +} + +// sets paused status and ensures that audio exlusive is false while paused +// (releases audio device to other players) +func (p *Player) setPaused(paused bool) error { + if !paused && p.audioExclusive { + if err := p.mpv.SetOptionString("audio-exclusive", "yes"); err != nil { + return err + } + } + err := p.mpv.SetProperty("pause", mpv.FORMAT_FLAG, paused) + if err == nil && paused && p.audioExclusive { + err = p.mpv.SetOptionString("audio-exclusive", "no") + } + return err +} + +// Pause playback and update the player state +func (p *Player) Pause() error { + if p.status.State != player.Playing { + return nil + } + err := p.setPaused(true) + if err == nil { + p.prePausedState = p.status.State + p.setState(player.Paused) + } + return err +} + +// Continue playback and update the player state +func (p *Player) Continue() error { + if p.status.State == player.Paused { + err := p.setPaused(false) + if err == nil { + p.setState(p.prePausedState) + } + return err + } + + return nil +} + +// Get the current status of the player. +func (p *Player) GetStatus() player.Status { + if !p.initialized { + return p.status + } + + pos, _ := p.mpv.GetProperty("playback-time", mpv.FORMAT_DOUBLE) + dur, _ := p.mpv.GetProperty("duration", mpv.FORMAT_DOUBLE) + if pos != nil { + p.status.TimePos = pos.(float64) + } + if dur != nil { + p.status.Duration = dur.(float64) + } + return p.status +} + +// List available audio devices. +func (p *Player) ListAudioDevices() ([]AudioDevice, error) { + n, err := p.mpv.GetProperty("audio-device-list", mpv.FORMAT_NODE) + if err != nil { + return nil, err + } + nodeArr := n.(*mpv.Node).Data.([]*mpv.Node) + + devices := make([]AudioDevice, len(nodeArr)) + for i, node := range nodeArr { + dev := node.Data.(map[string]*mpv.Node) + name := dev["name"].Data.(string) + desc := dev["description"].Data.(string) + devices[i] = AudioDevice{Name: name, Description: desc} + } + return devices, nil +} + +func (p *Player) SetAudioDevice(deviceName string) error { + return p.mpv.SetPropertyString("audio-device", deviceName) +} + +func (p *Player) SetEqualizer(eq Equalizer) error { + p.equalizer = eq + if eq == nil || !eq.IsEnabled() { + return p.mpv.SetPropertyString("af", "") + } + af := "" + if math.Abs(eq.Preamp()) > 0.01 { + af = fmt.Sprintf("volume=volume=%0.1fdB", eq.Preamp()) + } + eqAF := eq.Curve().String() + if af == "" { + af = eqAF + } else if eqAF != "" { + af = fmt.Sprintf("%s,%s", af, eqAF) + } + return p.mpv.SetPropertyString("af", af) +} + +func (p *Player) Equalizer() Equalizer { + return p.equalizer +} + +func (p *Player) GetMediaInfo() (MediaInfo, error) { + var info MediaInfo + n, err := p.mpv.GetProperty("audio-params", mpv.FORMAT_NODE) + if err != nil { + return info, err + } + nodeMap := n.(*mpv.Node).Data.(map[string]*mpv.Node) + info.Format = nodeMap["format"].Data.(string) + info.Samplerate = int(nodeMap["samplerate"].Data.(int64)) + info.ChannelCount = int(nodeMap["channel-count"].Data.(int64)) + + br, err := p.mpv.GetProperty("audio-bitrate", mpv.FORMAT_INT64) + if err == nil { + info.Bitrate = int(br.(int64)) + } + codec, err := p.mpv.GetProperty("track-list/0/codec", mpv.FORMAT_STRING) + if err == nil { + info.Codec = codec.(string) + } + + return info, nil +} + +func (p *Player) getInt64Property(propName string) (int64, error) { + playpos, err := p.mpv.GetProperty(propName, mpv.FORMAT_INT64) + if err != nil { + return -1, err + } + if playpos != nil { + return playpos.(int64), nil + } + return -1, errors.New("mpv did not report playlist pos") +} + +// Returns true if a seek is currently in progress. +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 { + p.bgCancel() + } + if p.initialized { + p.mpv.Command([]string{"stop"}) + p.mpv.TerminateDestroy() + p.initialized = false + } +} + +// sets the state and invokes callbacks, if triggered +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() + } + }() + case s == player.Paused && p.status.State != player.Paused: + defer func() { + for _, cb := range p.onPaused { + cb() + } + }() + case s == player.Stopped && p.status.State != player.Stopped: + defer func() { + for _, cb := range p.onStopped { + cb() + } + }() + } + p.status.State = s +} + +func (p *Player) eventHandler(ctx context.Context) { + for { + select { + case <-ctx.Done(): + return + default: + e := p.mpv.WaitEvent(1 /*timeout seconds*/) + if e.Event_Id != mpv.EVENT_NONE { + //log.Printf("mpv event: %+v\n", e) + } + switch e.Event_Id { + case mpv.EVENT_PLAYBACK_RESTART: + if p.seeking { + p.seeking = false + } + case mpv.EVENT_SEEK: + for _, cb := range p.onSeek { + cb() + } + 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() + } + case mpv.EVENT_IDLE: + p.status.Duration = 0 + p.status.TimePos = 0 + p.setState(player.Stopped) + } + } + } +} diff --git a/player/player.go b/player/player.go index b9086ed..5ab77e1 100644 --- a/player/player.go +++ b/player/player.go @@ -1,18 +1,43 @@ package player -import ( - "context" - "errors" - "fmt" - "log" - "math" - "strconv" +import "github.com/dweymouth/supersonic/backend/mediaprovider" - "github.com/dweymouth/go-mpv" -) +type URLPlayer interface { + BasePlayer + PlayFile(url string) error + SetNextFile(url string) error +} -// Error returned by many Player functions if called before the player has not been initialized. -var ErrUnitialized error = errors.New("mpv player uninitialized") +type TrackPlayer interface { + BasePlayer + PlayTrack(track *mediaprovider.Track) error + SetNextTrack(track *mediaprovider.Track) error +} + +type BasePlayer interface { + Continue() error + Pause() error + Stop() error + + SeekSeconds(secs float64) error + IsSeeking() bool + + SetVolume(int) error + GetVolume() int + + GetStatus() Status + + // Event API + OnPaused(func()) + OnStopped(func()) + OnPlaying(func()) + OnSeek(func()) + OnTrackChange(func()) +} + +type ReplayGainPlayer interface { + SetReplayGainOptions(ReplayGainOptions) error +} // The playback state (Stopped, Paused, or Playing). type State int @@ -26,29 +51,17 @@ const ( // The current status of the player. // Includes playback state, current time, total track time, and playlist position. type Status struct { - State State - TimePos float64 - Duration float64 - PlaylistPos int64 + State State + TimePos float64 + Duration float64 } -// Argument to Seek function (SeekAbsolute, SeekRelative, SeekAbsolutePercent, SeekRelativePercent). -type SeekMode int +type ReplayGainMode int const ( - SeekAbsolute SeekMode = iota - SeekRelative - SeekAbsolutePercent - SeekRelativePercent -) - -// One of "no", "track", or "album" -type ReplayGainMode string - -const ( - ReplayGainNone ReplayGainMode = "no" - ReplayGainTrack ReplayGainMode = "track" - ReplayGainAlbum ReplayGainMode = "album" + ReplayGainNone ReplayGainMode = iota + ReplayGainTrack + ReplayGainAlbum ) // Replay Gain options (argument to SetReplayGainOptions). @@ -59,673 +72,13 @@ type ReplayGainOptions struct { // Fallback gain intentionally omitted } -// The playback loop mode (LoopNone, LoopAll, LoopOne). -type LoopMode int - -const ( - LoopNone LoopMode = iota - LoopAll - LoopOne -) - -// Information about a specific audio device. -// Returned by ListAudioDevices. -type AudioDevice struct { - // The name of the audio device. - // This is the string to pass to SetAudioDevice. - Name string - - // The description of the audio device. - // This is the friendly string that should be used in UIs. - Description string -} - -// Media information about the currently playing media. -type MediaInfo struct { - // The sample format as string. This uses the same names as used in other places of mpv. - // NOTE: this is the format that the decoder outputs, NOT necessarily the format of the file. - Format string - - // Audio samplerate. - Samplerate int - - // The number of channels. - ChannelCount int - - // The audio codec. - Codec string - - // The average bit rate in bits per second. - Bitrate int -} - -// Player encapsulates the mpv instance and provides functions -// to control it and to check its status. -type Player struct { - mpv *mpv.Mpv - initialized bool - vol int - replayGainOpts ReplayGainOptions - haveRGainOpts bool - audioExclusive bool - status Status - loopMode LoopMode - seeking bool - curPlaylistPos int64 - prePausedState State - clientName string - equalizer Equalizer - - bgCancel context.CancelFunc - - // callbacks - onPaused []func() - onStopped []func() - onPlaying []func() - onSeek []func() - onTrackChange []func(int64) -} - -// Returns a new player. -// Must call Init on the player before it is ready for playback. -func New() *Player { - return NewWithClientName("") -} - -// Same as New, but sets the application name that mpv -// reports to the system audio API. -func NewWithClientName(c string) *Player { - return &Player{ - vol: -1, // use 100 in Init - clientName: c, - } -} - -// Initializes the Player and makes it ready for playback. -// Most Player functions will return ErrUnitialized if called before Init. -func (p *Player) Init(maxCacheMB int) error { - if !p.initialized { - m := mpv.Create() - - m.SetOptionString("idle", "yes") - m.SetOptionString("video", "no") - m.SetOptionString("audio-display", "no") - m.SetOptionString("gapless-audio", "weak") - m.SetOptionString("prefetch-playlist", "yes") - m.SetOptionString("force-seekable", "yes") - m.SetOptionString("terminal", "no") - - // limit in-memory cache size - m.SetOptionString("demuxer-max-bytes", fmt.Sprintf("%dMiB", maxCacheMB)) - - if p.vol < 0 { - p.vol = 100 - } - m.SetOption("volume", mpv.FORMAT_INT64, p.vol) - - p.SetAudioExclusive(p.audioExclusive) - if p.haveRGainOpts { - p.SetReplayGainOptions(p.replayGainOpts) - } - - if p.clientName != "" { - m.SetOptionString("audio-client-name", p.clientName) - } - - if err := m.Initialize(); err != nil { - return fmt.Errorf("error initializing mpv: %s", err.Error()) - } - p.mpv = m - } - ctx, cancel := context.WithCancel(context.Background()) - go p.eventHandler(ctx) - p.bgCancel = cancel - p.initialized = true - return nil -} - -// Appends the given file to the play queue. -// Note that the Player API does not provide methods to read -// the play queue. Clients are expected to maintain their own play queue model. -func (p *Player) AppendFile(url string) error { - log.Printf("Adding playback URL: %s", url) - if !p.initialized { - return ErrUnitialized - } - return p.mpv.Command([]string{"loadfile", url, "append"}) -} - -// Plays the specified file, clearing the previous play queue, if any. -func (p *Player) PlayFile(url string) error { - log.Printf("Adding playback URL: %s", url) - if !p.initialized { - return ErrUnitialized - } - err := p.mpv.Command([]string{"loadfile", url, "replace"}) - if err == nil { - p.setState(Playing) - } - return err -} - -// Removes the item at the given index from the internal playqueue. -func (p *Player) RemoveTrackAt(idx int) error { - if !p.initialized { - return ErrUnitialized - } - return p.mpv.Command([]string{"playlist-remove", strconv.Itoa(idx)}) -} - -// Stops playback and clears the play queue. -func (p *Player) Stop() error { - if !p.initialized { - return ErrUnitialized - } - var err error - if p.status.State == Stopped { - err = p.mpv.Command([]string{"playlist-clear"}) - } else { - if err = p.mpv.Command([]string{"stop"}); err == nil { - // if player was paused, stop command actually doesn't clear pause state - err = p.setPaused(false) - } - } - if err == nil { - p.setState(Stopped) - } - return err -} - -// Clears the play queue, except for the currently playing file. -func (p *Player) ClearPlayQueue() error { - if !p.initialized { - return ErrUnitialized - } - return p.mpv.Command([]string{"playlist-clear"}) -} - -// Seeks within the currently playing track. -// See MPV seek command documentation for more details. -func (p *Player) Seek(target string, mode SeekMode) error { - if !p.initialized { - return ErrUnitialized - } - p.seeking = true - err := p.mpv.Command([]string{"seek", target, mode.String()}) - 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 { - 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.mpv.Command([]string{"playlist-prev"}) -} - -// Seeks to the next track in the play queue, if any. -func (p *Player) SeekNext() error { - if !p.initialized { - return ErrUnitialized - } - return p.mpv.Command([]string{"playlist-next"}) -} - -// Sets the volume of the player (0-100). -// Unlike most Player functions, SetVolume can be called before Init, -// to set the initial volume of the player on startup. -func (p *Player) SetVolume(vol int) error { - if vol > 100 { - vol = 100 - } else if vol < 0 { - vol = 0 - } - if p.initialized { - err := p.mpv.SetProperty("volume", mpv.FORMAT_INT64, vol) - if err == nil { - p.vol = vol - } - return err - } - p.vol = vol - return nil -} - -// Sets the ReplayGain options of the player. -// Unlike most Player functions, SetReplayGainOptions can be called -// before Init, to set the initial replaygain options of the player on startup. -func (p *Player) SetReplayGainOptions(options ReplayGainOptions) error { - p.replayGainOpts = options - p.haveRGainOpts = true - if p.initialized { - if err := p.mpv.SetPropertyString("replaygain", string(options.Mode)); err != nil { - return err - } - if err := p.mpv.SetProperty("replaygain-preamp", mpv.FORMAT_DOUBLE, options.PreampGain); err != nil { - return err - } - clip := "yes" - if options.PreventClipping { - clip = "no" - } - if err := p.mpv.SetPropertyString("replaygain-clip", clip); err != nil { - return err - } - } - return nil -} - -// Sets the audio exclusive option of the player. -// Unlike most Player functions, SetAudioExclusive can be called -// before Init, to set the initial option of the player on startup. -func (p *Player) SetAudioExclusive(tf bool) { - p.audioExclusive = tf - if p.initialized { - val := "no" - if tf { - val = "yes" - } - p.mpv.SetOptionString("audio-exclusive", val) - } -} - -// Gets the current volume of the player. -func (p *Player) GetVolume() int { - return p.vol -} - -// sets paused status and ensures that audio exlusive is false while paused -// (releases audio device to other players) -func (p *Player) setPaused(paused bool) error { - if !paused && p.audioExclusive { - if err := p.mpv.SetOptionString("audio-exclusive", "yes"); err != nil { - return err - } - } - err := p.mpv.SetProperty("pause", mpv.FORMAT_FLAG, paused) - if err == nil && paused && p.audioExclusive { - err = p.mpv.SetOptionString("audio-exclusive", "no") - } - return err -} - -// Start playback from the first track in the play queue. -func (p *Player) PlayFromBeginning() error { - return p.PlayTrackAt(0) -} - -// Start playback from the specified track index in the play queue. -func (p *Player) PlayTrackAt(idx int) error { - // check if we have anything to play - if c, err := p.getInt64Property("playlist-count"); err == nil && c <= int64(idx) { - return nil - } - err := p.mpv.Command([]string{"playlist-play-index", strconv.Itoa(idx)}) - if p.GetStatus().State == Paused { - err = p.setPaused(false) - } - if err == nil { - p.setState(Playing) - } - return err -} - -// Begins playback if there is anything in the play queue and player is stopped or paused. -// If player is playing, pauses playback. -func (p *Player) PlayPause() error { - if !p.initialized { - return ErrUnitialized - } - - switch p.status.State { - case Stopped: - // check if we have anything to play - if c, err := p.getInt64Property("playlist-count"); err == nil && c > 0 { - err := p.mpv.Command([]string{"playlist-play-index", "0"}) - if err == nil { - p.setState(Playing) - } - return err - } - return nil - case Playing: - return p.Pause() - case Paused: - return p.Continue() +func (r ReplayGainMode) String() string { + switch r { + case ReplayGainTrack: + return "track" + case ReplayGainAlbum: + return "album" default: - return errors.New("Unknown player state") - } -} - -// Pause playback and update the player state -func (p *Player) Pause() error { - if p.status.State != Playing { - return nil - } - err := p.setPaused(true) - if err == nil { - p.prePausedState = p.status.State - p.setState(Paused) - } - return err -} - -// Continue playback and update the player state -func (p *Player) Continue() error { - if p.status.State == Paused { - err := p.setPaused(false) - if err == nil { - p.setState(p.prePausedState) - } - return err - } else if p.status.State == Stopped { - return p.PlayFromBeginning() - } - - return nil -} - -// Get the loop mode of the player. -func (p *Player) GetLoopMode() LoopMode { - return p.loopMode -} - -// Set the loop mode of the player. -func (p *Player) SetLoopMode(mode LoopMode) error { - if !p.initialized { - return ErrUnitialized - } - - // Return early if player is already in specified mode - if mode == p.loopMode { - return nil - } - - switch mode { - case LoopNone: - if err := p.mpv.SetOptionString("loop-playlist", "no"); err != nil { - return err - } - if err := p.mpv.SetOptionString("loop-file", "no"); err != nil { - return err - } - case LoopAll: - if err := p.mpv.SetOptionString("loop-playlist", "inf"); err != nil { - return err - } - if err := p.mpv.SetOptionString("loop-file", "no"); err != nil { - return err - } - case LoopOne: - if err := p.mpv.SetOptionString("loop-playlist", "no"); err != nil { - return err - } - if err := p.mpv.SetOptionString("loop-file", "inf"); err != nil { - return err - } - } - p.loopMode = mode - - 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 LoopNone: - return p.SetLoopMode(LoopAll) - case LoopAll: - return p.SetLoopMode(LoopOne) - case LoopOne: - return p.SetLoopMode(LoopNone) - default: - return nil - } -} - -// Get the current status of the player. -func (p *Player) GetStatus() Status { - if !p.initialized { - return p.status - } - - pos, _ := p.mpv.GetProperty("playback-time", mpv.FORMAT_DOUBLE) - dur, _ := p.mpv.GetProperty("duration", mpv.FORMAT_DOUBLE) - if pos != nil { - p.status.TimePos = pos.(float64) - } - if dur != nil { - p.status.Duration = dur.(float64) - } - if playpos, err := p.getInt64Property("playlist-pos"); err == nil { - p.status.PlaylistPos = playpos - } - return p.status -} - -// List available audio devices. -func (p *Player) ListAudioDevices() ([]AudioDevice, error) { - n, err := p.mpv.GetProperty("audio-device-list", mpv.FORMAT_NODE) - if err != nil { - return nil, err - } - nodeArr := n.(*mpv.Node).Data.([]*mpv.Node) - - devices := make([]AudioDevice, len(nodeArr)) - for i, node := range nodeArr { - dev := node.Data.(map[string]*mpv.Node) - name := dev["name"].Data.(string) - desc := dev["description"].Data.(string) - devices[i] = AudioDevice{Name: name, Description: desc} - } - return devices, nil -} - -func (p *Player) SetAudioDevice(deviceName string) error { - return p.mpv.SetPropertyString("audio-device", deviceName) -} - -func (p *Player) SetEqualizer(eq Equalizer) error { - p.equalizer = eq - if eq == nil || !eq.IsEnabled() { - return p.mpv.SetPropertyString("af", "") - } - af := "" - if math.Abs(eq.Preamp()) > 0.01 { - af = fmt.Sprintf("volume=volume=%0.1fdB", eq.Preamp()) - } - eqAF := eq.Curve().String() - if af == "" { - af = eqAF - } else if eqAF != "" { - af = fmt.Sprintf("%s,%s", af, eqAF) - } - return p.mpv.SetPropertyString("af", af) -} - -func (p *Player) Equalizer() Equalizer { - return p.equalizer -} - -func (p *Player) GetMediaInfo() (MediaInfo, error) { - var info MediaInfo - n, err := p.mpv.GetProperty("audio-params", mpv.FORMAT_NODE) - if err != nil { - return info, err - } - nodeMap := n.(*mpv.Node).Data.(map[string]*mpv.Node) - info.Format = nodeMap["format"].Data.(string) - info.Samplerate = int(nodeMap["samplerate"].Data.(int64)) - info.ChannelCount = int(nodeMap["channel-count"].Data.(int64)) - - br, err := p.mpv.GetProperty("audio-bitrate", mpv.FORMAT_INT64) - if err == nil { - info.Bitrate = int(br.(int64)) - } - codec, err := p.mpv.GetProperty("track-list/0/codec", mpv.FORMAT_STRING) - if err == nil { - info.Codec = codec.(string) - } - - return info, nil -} - -func (p *Player) getInt64Property(propName string) (int64, error) { - playpos, err := p.mpv.GetProperty(propName, mpv.FORMAT_INT64) - if err != nil { - return -1, err - } - if playpos != nil { - return playpos.(int64), nil - } - return -1, errors.New("mpv did not report playlist pos") -} - -// Returns true if a seek is currently in progress. -func (p *Player) IsSeeking() bool { - return p.seeking && p.status.State == 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(int64)) { - p.onTrackChange = append(p.onTrackChange, cb) -} - -// Destroy the player. -func (p *Player) Destroy() { - if p.bgCancel != nil { - p.bgCancel() - } - if p.initialized { - p.mpv.Command([]string{"stop"}) - p.mpv.TerminateDestroy() - p.initialized = false - } -} - -// sets the state and invokes callbacks, if triggered -func (p *Player) setState(s State) { - switch { - case s == Playing && p.status.State != Playing: - defer func() { - for _, cb := range p.onPlaying { - cb() - } - }() - case s == Paused && p.status.State != Paused: - defer func() { - for _, cb := range p.onPaused { - cb() - } - }() - case s == Stopped && p.status.State != Stopped: - defer func() { - for _, cb := range p.onStopped { - cb() - } - }() - } - p.status.State = s -} - -func (p *Player) eventHandler(ctx context.Context) { - for { - select { - case <-ctx.Done(): - return - default: - e := p.mpv.WaitEvent(1 /*timeout seconds*/) - if e.Event_Id != mpv.EVENT_NONE { - //log.Printf("mpv event: %+v\n", e) - } - switch e.Event_Id { - case mpv.EVENT_PLAYBACK_RESTART: - if p.seeking { - p.seeking = false - } - case mpv.EVENT_SEEK: - for _, cb := range p.onSeek { - cb() - } - case mpv.EVENT_FILE_LOADED: - if p.status.State == Paused { - // seek while paused switches to a new file - // mpv does not fire seek event in this case - for _, cb := range p.onSeek { - cb() - } - } - if pos, err := p.getInt64Property("playlist-pos"); err == nil { - p.curPlaylistPos = pos - for _, cb := range p.onTrackChange { - cb(pos) - } - } - case mpv.EVENT_IDLE: - p.status.Duration = 0 - p.status.TimePos = 0 - p.setState(Stopped) - } - } - } -} - -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" -} - -func (l LoopMode) String() string { - switch l { - case LoopNone: return "no" - case LoopAll: - return "all" - case LoopOne: - return "one" } - return "UNKNOWN_LOOP_MODE" } diff --git a/sharedutil/sharedutil.go b/sharedutil/sharedutil.go index 8710813..d05a909 100644 --- a/sharedutil/sharedutil.go +++ b/sharedutil/sharedutil.go @@ -78,10 +78,10 @@ func Reversed[T any](ts []T) []T { return new } -func ToSet[T comparable](ts []T) map[T]interface{} { - set := make(map[T]interface{}, len(ts)) +func ToSet[T comparable](ts []T) map[T]struct{} { + set := make(map[T]struct{}, len(ts)) for _, t := range ts { - set[t] = nil + set[t] = struct{}{} } return set } diff --git a/ui/bottompanel.go b/ui/bottompanel.go index 107afb0..e80f504 100644 --- a/ui/bottompanel.go +++ b/ui/bottompanel.go @@ -1,14 +1,12 @@ package ui import ( - "fmt" "image" "log" "time" "github.com/dweymouth/supersonic/backend" "github.com/dweymouth/supersonic/backend/mediaprovider" - "github.com/dweymouth/supersonic/player" "github.com/dweymouth/supersonic/ui/controller" "github.com/dweymouth/supersonic/ui/layouts" "github.com/dweymouth/supersonic/ui/widgets" @@ -21,8 +19,7 @@ import ( type BottomPanel struct { widget.BaseWidget - ImageManager *backend.ImageManager - playbackManager *backend.PlaybackManager + ImageManager *backend.ImageManager NowPlaying *widgets.NowPlayingCard Controls *widgets.PlayerControls @@ -34,24 +31,24 @@ type BottomPanel struct { var _ fyne.Widget = (*BottomPanel)(nil) -func NewBottomPanel(p *player.Player, pm *backend.PlaybackManager, contr *controller.Controller) *BottomPanel { - bp := &BottomPanel{playbackManager: pm} +func NewBottomPanel(pm *backend.PlaybackManager, contr *controller.Controller) *BottomPanel { + bp := &BottomPanel{} bp.ExtendBaseWidget(bp) - bp.playbackManager.OnSongChange(bp.onSongChange) - bp.playbackManager.OnPlayTimeUpdate(func(cur, total float64) { - if !bp.playbackManager.IsSeeking() { + pm.OnSongChange(bp.onSongChange) + pm.OnPlayTimeUpdate(func(cur, total float64) { + if !pm.IsSeeking() { bp.Controls.UpdatePlayTime(cur, total) } }) - p.OnPaused(func() { + pm.OnPaused(func() { bp.Controls.SetPlaying(false) }) - p.OnPlaying(func() { + pm.OnPlaying(func() { bp.Controls.SetPlaying(true) }) - p.OnStopped(func() { + pm.OnStopped(func() { bp.Controls.SetPlaying(false) }) @@ -65,45 +62,45 @@ func NewBottomPanel(p *player.Player, pm *backend.PlaybackManager, contr *contro } } bp.NowPlaying.OnSetFavorite = func(fav bool) { - contr.SetTrackFavorites([]string{bp.playbackManager.NowPlaying().ID}, fav) + contr.SetTrackFavorites([]string{pm.NowPlaying().ID}, fav) } bp.NowPlaying.OnSetRating = func(rating int) { - contr.SetTrackRatings([]string{bp.playbackManager.NowPlaying().ID}, rating) + contr.SetTrackRatings([]string{pm.NowPlaying().ID}, rating) } bp.NowPlaying.OnAddToPlaylist = func() { - contr.DoAddTracksToPlaylistWorkflow([]string{bp.playbackManager.NowPlaying().ID}) + contr.DoAddTracksToPlaylistWorkflow([]string{pm.NowPlaying().ID}) } bp.NowPlaying.OnAlbumNameTapped = func() { - contr.NavigateTo(controller.AlbumRoute(bp.playbackManager.NowPlaying().AlbumID)) + contr.NavigateTo(controller.AlbumRoute(pm.NowPlaying().AlbumID)) } bp.NowPlaying.OnArtistNameTapped = func(artistID string) { contr.NavigateTo(controller.ArtistRoute(artistID)) } bp.NowPlaying.OnTrackNameTapped = func() { - contr.NavigateTo(controller.NowPlayingRoute(bp.playbackManager.NowPlaying().ID)) + contr.NavigateTo(controller.NowPlayingRoute(pm.NowPlaying().ID)) } bp.Controls = widgets.NewPlayerControls() bp.Controls.OnPlayPause(func() { - p.PlayPause() + pm.PlayPause() }) bp.Controls.OnSeekNext(func() { - p.SeekNext() + pm.SeekNext() }) bp.Controls.OnSeekPrevious(func() { - p.SeekBackOrPrevious() + pm.SeekBackOrPrevious() }) bp.Controls.OnSeek(func(f float64) { - p.Seek(fmt.Sprintf("%d", int(f*100)), player.SeekAbsolutePercent) + pm.SeekFraction(f) }) - bp.AuxControls = widgets.NewAuxControls(p.GetVolume()) + bp.AuxControls = widgets.NewAuxControls(pm.Volume()) pm.OnLoopModeChange(bp.AuxControls.SetLoopMode) pm.OnVolumeChange(bp.AuxControls.VolumeControl.SetVolume) bp.AuxControls.VolumeControl.OnSetVolume = func(v int) { - _ = bp.playbackManager.SetVolume(v) + _ = pm.SetVolume(v) } bp.AuxControls.OnChangeLoopMode(func() { - bp.playbackManager.SetNextLoopMode() + pm.SetNextLoopMode() }) bp.container = container.New(layouts.NewLeftMiddleRightLayout(500), diff --git a/ui/browsing/nowplayingpage.go b/ui/browsing/nowplayingpage.go index 5884c5f..4e1ad59 100644 --- a/ui/browsing/nowplayingpage.go +++ b/ui/browsing/nowplayingpage.go @@ -8,6 +8,7 @@ import ( "github.com/dweymouth/supersonic/backend" "github.com/dweymouth/supersonic/backend/mediaprovider" "github.com/dweymouth/supersonic/player" + "github.com/dweymouth/supersonic/player/mpv" "github.com/dweymouth/supersonic/sharedutil" "github.com/dweymouth/supersonic/ui/controller" "github.com/dweymouth/supersonic/ui/layouts" @@ -41,7 +42,6 @@ type nowPlayingPageState struct { pool *util.WidgetPool conf *backend.NowPlayingPageConfig pm *backend.PlaybackManager - p *player.Player canRate bool } @@ -51,17 +51,16 @@ func NewNowPlayingPage( pool *util.WidgetPool, conf *backend.NowPlayingPageConfig, pm *backend.PlaybackManager, - p *player.Player, // TODO: once other player backends are supported (eg uPnP), refactor canRate bool, ) *NowPlayingPage { a := &NowPlayingPage{nowPlayingPageState: nowPlayingPageState{ - contr: contr, pool: pool, conf: conf, pm: pm, p: p, canRate: canRate, + contr: contr, pool: pool, conf: conf, pm: pm, canRate: canRate, }} a.ExtendBaseWidget(a) - p.OnPaused(a.formatStatusLine) - p.OnPlaying(a.formatStatusLine) - p.OnStopped(a.formatStatusLine) + pm.OnPaused(a.formatStatusLine) + pm.OnPlaying(a.formatStatusLine) + pm.OnStopped(a.formatStatusLine) if t := a.pool.Obtain(util.WidgetTypeTracklist); t != nil { a.tracklist = t.(*widgets.Tracklist) @@ -135,7 +134,8 @@ func (a *NowPlayingPage) OnPlayTimeUpdate(_, _ float64) { } func (a *NowPlayingPage) formatStatusLine() { - playerStats := a.p.GetStatus() + curPlayer := a.pm.CurrentPlayer() + playerStats := curPlayer.GetStatus() lastStatus := a.statusLabel.Text state := "Stopped" switch playerStats.State { @@ -160,32 +160,40 @@ func (a *NowPlayingPage) formatStatusLine() { status := fmt.Sprintf("%s (%d/%d)%s", state, trackNum, len(a.queue), statusSuffix) - if state == "Stopped" { - a.statusLabel.Text = fmt.Sprintf("%s | Total time: %s", status, util.SecondsToTimeString(a.totalTime)) - } else { - audioInfo, err := a.p.GetMediaInfo() - if err != nil { - log.Printf("error getting playback status: %s", err.Error()) - } - codec := audioInfo.Codec - if len(codec) <= 4 && !strings.EqualFold(codec, "opus") { - codec = strings.ToUpper(codec) // FLAC, MP3, AAC, etc - } - - // Note: bit depth intentionally omitted since MPV reports the decoded bit depth - // i.e. 24 bit files get reported as 32 bit. Also b/c bit depth isn't meaningful for lossy. - a.statusLabel.Text = fmt.Sprintf("%s · %s %g kHz, %d kbps | Total time: %s", - status, - codec, - float64(audioInfo.Samplerate)/1000, - audioInfo.Bitrate/1000, - util.SecondsToTimeString(a.totalTime)) + mediaInfo := "" + if state != "Stopped" { + mediaInfo = a.formatMediaInfoStr(curPlayer) } + if mediaInfo != "" { + mediaInfo = " · " + mediaInfo + } + + a.statusLabel.Text = fmt.Sprintf("%s%s | Total time: %s", status, mediaInfo, util.SecondsToTimeString(a.totalTime)) if lastStatus != a.statusLabel.Text { a.statusLabel.Refresh() } } +func (a *NowPlayingPage) formatMediaInfoStr(player player.BasePlayer) string { + mpv, ok := player.(*mpv.Player) + if !ok { + return "" + } + audioInfo, err := mpv.GetMediaInfo() + if err != nil { + log.Printf("error getting playback status: %s", err.Error()) + return "" + } + codec := audioInfo.Codec + if len(codec) <= 4 && !strings.EqualFold(codec, "opus") { + codec = strings.ToUpper(codec) // FLAC, MP3, AAC, etc + } + + // Note: bit depth intentionally omitted since MPV reports the decoded bit depth + // i.e. 24 bit files get reported as 32 bit. Also b/c bit depth isn't meaningful for lossy. + return fmt.Sprintf("%s %g kHz, %d kbps", codec, float64(audioInfo.Samplerate)/1000, audioInfo.Bitrate/1000) +} + func (a *NowPlayingPage) Reload() { a.load("") } @@ -216,5 +224,5 @@ func (a *NowPlayingPage) load(highlightedTrackID string) { } func (s *nowPlayingPageState) Restore() Page { - return NewNowPlayingPage("", s.contr, s.pool, s.conf, s.pm, s.p, s.canRate) + return NewNowPlayingPage("", s.contr, s.pool, s.conf, s.pm, s.canRate) } diff --git a/ui/browsing/router.go b/ui/browsing/router.go index dc2a220..4c5b418 100644 --- a/ui/browsing/router.go +++ b/ui/browsing/router.go @@ -47,7 +47,7 @@ func (r Router) CreatePage(rte controller.Route) Page { return NewGenresPage(r.Controller, r.App.ServerManager.Server) case controller.NowPlaying: _, canRate := r.App.ServerManager.Server.(mediaprovider.SupportsRating) - return NewNowPlayingPage(rte.Arg, r.Controller, r.widgetPool, &r.App.Config.NowPlayingPage, r.App.PlaybackManager, r.App.Player, canRate) + return NewNowPlayingPage(rte.Arg, r.Controller, r.widgetPool, &r.App.Config.NowPlayingPage, r.App.PlaybackManager, canRate) case controller.Playlist: return NewPlaylistPage(rte.Arg, &r.App.Config.PlaylistPage, r.widgetPool, r.Controller, r.App.ServerManager, r.App.PlaybackManager, r.App.ImageManager) case controller.Playlists: diff --git a/ui/controller/controller.go b/ui/controller/controller.go index c2adb4b..10e45a7 100644 --- a/ui/controller/controller.go +++ b/ui/controller/controller.go @@ -13,6 +13,7 @@ import ( "github.com/dweymouth/supersonic/backend" "github.com/dweymouth/supersonic/backend/mediaprovider" "github.com/dweymouth/supersonic/player" + "github.com/dweymouth/supersonic/player/mpv" "github.com/dweymouth/supersonic/sharedutil" "github.com/dweymouth/supersonic/ui/dialogs" "github.com/dweymouth/supersonic/ui/util" @@ -471,31 +472,39 @@ func (c *Controller) ShowAboutDialog() { } func (c *Controller) ShowSettingsDialog(themeUpdateCallbk func(), themeFiles map[string]string) { - devs, err := c.App.Player.ListAudioDevices() + devs, err := c.App.LocalPlayer.ListAudioDevices() if err != nil { log.Printf("error listing audio devices: %v", err) - devs = []player.AudioDevice{{Name: "auto", Description: "Autoselect device"}} + devs = []mpv.AudioDevice{{Name: "auto", Description: "Autoselect device"}} } - bands := c.App.Player.Equalizer().BandFrequencies() - dlg := dialogs.NewSettingsDialog(c.App.Config, devs, themeFiles, bands, c.App.ServerManager.Server.ClientDecidesScrobble(), c.MainWindow) + curPlayer := c.App.PlaybackManager.CurrentPlayer() + _, isReplayGainPlayer := curPlayer.(player.ReplayGainPlayer) + _, isEqualizerPlayer := curPlayer.(*mpv.Player) + isLocalPlayer := isEqualizerPlayer + bands := c.App.LocalPlayer.Equalizer().BandFrequencies() + dlg := dialogs.NewSettingsDialog(c.App.Config, + devs, themeFiles, bands, + c.App.ServerManager.Server.ClientDecidesScrobble(), + isLocalPlayer, isReplayGainPlayer, isEqualizerPlayer, + c.MainWindow) dlg.OnReplayGainSettingsChanged = func() { c.App.PlaybackManager.SetReplayGainOptions(c.App.Config.ReplayGain) } dlg.OnAudioExclusiveSettingChanged = func() { - c.App.Player.SetAudioExclusive(c.App.Config.LocalPlayback.AudioExclusive) + c.App.LocalPlayer.SetAudioExclusive(c.App.Config.LocalPlayback.AudioExclusive) } dlg.OnAudioDeviceSettingChanged = func() { - c.App.Player.SetAudioDevice(c.App.Config.LocalPlayback.AudioDeviceName) + c.App.LocalPlayer.SetAudioDevice(c.App.Config.LocalPlayback.AudioDeviceName) } dlg.OnThemeSettingChanged = themeUpdateCallbk dlg.OnEqualizerSettingsChanged = func() { // currently we only have one equalizer type - eq := c.App.Player.Equalizer().(*player.ISO15BandEqualizer) + eq := c.App.LocalPlayer.Equalizer().(*mpv.ISO15BandEqualizer) eq.Disabled = !c.App.Config.LocalPlayback.EqualizerEnabled eq.EQPreamp = c.App.Config.LocalPlayback.EqualizerPreamp copy(eq.BandGains[:], c.App.Config.LocalPlayback.GraphicEqualizerBands) - c.App.Player.SetEqualizer(eq) + c.App.LocalPlayer.SetEqualizer(eq) } pop := widget.NewModalPopUp(dlg, c.MainWindow.Canvas()) dlg.OnDismiss = func() { diff --git a/ui/dialogs/settingsdialog.go b/ui/dialogs/settingsdialog.go index 91477e9..1c2a302 100644 --- a/ui/dialogs/settingsdialog.go +++ b/ui/dialogs/settingsdialog.go @@ -10,7 +10,7 @@ import ( "unicode" "github.com/dweymouth/supersonic/backend" - "github.com/dweymouth/supersonic/player" + "github.com/dweymouth/supersonic/player/mpv" "github.com/dweymouth/supersonic/ui/layouts" myTheme "github.com/dweymouth/supersonic/ui/theme" "github.com/dweymouth/supersonic/ui/util" @@ -37,7 +37,7 @@ type SettingsDialog struct { OnEqualizerSettingsChanged func() config *backend.Config - audioDevices []player.AudioDevice + audioDevices []mpv.AudioDevice themeFiles map[string]string // filename -> displayName promptText *widget.RichText @@ -46,24 +46,39 @@ type SettingsDialog struct { content fyne.CanvasObject } -// TODO: having this depend on the player package for the AudioDevice type is kinda gross. Refactor. +// TODO: having this depend on the mpv package for the AudioDevice type is kinda gross. Refactor. func NewSettingsDialog( config *backend.Config, - audioDeviceList []player.AudioDevice, + audioDeviceList []mpv.AudioDevice, themeFileList map[string]string, equalizerBands []string, clientDecidesScrobble bool, + isLocalPlayer bool, + isReplayGainPlayer bool, + isEqualizerPlayer bool, window fyne.Window, ) *SettingsDialog { s := &SettingsDialog{config: config, audioDevices: audioDeviceList, themeFiles: themeFileList, clientDecidesScrobble: clientDecidesScrobble} s.ExtendBaseWidget(s) - tabs := container.NewAppTabs( - s.createGeneralTab(), - s.createPlaybackTab(), - s.createEqualizerTab(equalizerBands), - s.createExperimentalTab(window), - ) + // TODO: Once Fyne supports disableable sliders, it's probably a nicer UX + // to create the equalizer tab but disable it if we are not using an equalizer player + var tabs *container.AppTabs + if isEqualizerPlayer { + tabs = container.NewAppTabs( + s.createGeneralTab(), + s.createPlaybackTab(isLocalPlayer, isReplayGainPlayer), + s.createEqualizerTab(equalizerBands), + s.createExperimentalTab(window), + ) + } else { + tabs = container.NewAppTabs( + s.createGeneralTab(), + s.createPlaybackTab(isLocalPlayer, isReplayGainPlayer), + s.createExperimentalTab(window), + ) + } + tabs.SelectIndex(s.getActiveTabNumFromConfig()) // workaround issue where inactivated tabs don't fully update when theme setting is changed tabs.OnSelected = func(ti *container.TabItem) { @@ -249,7 +264,7 @@ func (s *SettingsDialog) createGeneralTab() *container.TabItem { )) } -func (s *SettingsDialog) createPlaybackTab() *container.TabItem { +func (s *SettingsDialog) createPlaybackTab(isLocalPlayer, isReplayGainPlayer bool) *container.TabItem { disableTranscode := widget.NewCheckWithData("Disable server transcoding", binding.BindBool(&s.config.Transcoding.ForceRawFile)) deviceList := make([]string, len(s.audioDevices)) var selIndex int @@ -328,6 +343,16 @@ func (s *SettingsDialog) createPlaybackTab() *container.TabItem { }) audioExclusive.Checked = s.config.LocalPlayback.AudioExclusive + if !isLocalPlayer { + deviceSelect.Disable() + audioExclusive.Disable() + } + if !isReplayGainPlayer { + replayGainSelect.Disable() + preventClipping.Disable() + preampGain.Disable() + } + return container.NewTabItem("Playback", container.NewVBox( container.NewHBox(disableTranscode), container.New(&layouts.MaxPadLayout{PadTop: 5}, diff --git a/ui/mainwindow.go b/ui/mainwindow.go index 56156bd..32af290 100644 --- a/ui/mainwindow.go +++ b/ui/mainwindow.go @@ -79,7 +79,7 @@ func NewMainWindow(fyneApp fyne.App, appName, displayAppName, appVersion string, m.Controller.ReloadFunc = m.BrowsingPane.Reload m.Controller.CurPageFunc = m.BrowsingPane.CurrentPage - m.BottomPanel = NewBottomPanel(app.Player, app.PlaybackManager, m.Controller) + m.BottomPanel = NewBottomPanel(app.PlaybackManager, m.Controller) m.BottomPanel.ImageManager = app.ImageManager m.container = container.NewBorder(nil, m.BottomPanel, nil, nil, m.BrowsingPane) m.Window.SetContent(m.container) @@ -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..1f5d63b 100644 --- a/ui/widgets/auxcontrols.go +++ b/ui/widgets/auxcontrols.go @@ -69,13 +69,13 @@ func (a *AuxControls) OnChangeLoopMode(f func()) { func (a *AuxControls) SetLoopMode(mode backend.LoopMode) { switch mode { - case backend.LoopModeAll: + case backend.LoopAll: a.loop.Importance = widget.HighImportance a.loop.Icon = myTheme.RepeatIcon - case backend.LoopModeOne: + case backend.LoopOne: a.loop.Importance = widget.HighImportance a.loop.Icon = myTheme.RepeatOneIcon - case backend.LoopModeNone: + case backend.LoopNone: a.loop.Importance = widget.MediumImportance a.loop.Icon = myTheme.RepeatIcon }