diff --git a/backend/app.go b/backend/app.go index d26bd00..fe9de0e 100644 --- a/backend/app.go +++ b/backend/app.go @@ -68,6 +68,7 @@ func StartupApp(appName, appVersionTag, configFile, latestReleaseURL string) (*A PreventClipping: a.Config.ReplayGain.PreventClipping, PreampGain: a.Config.ReplayGain.PreampGainDB, }) + a.Player.SetAudioExclusive(a.Config.LocalPlayback.AudioExclusive) a.ServerManager = NewServerManager(appName) a.PlaybackManager = NewPlaybackManager(a.bgrndCtx, a.ServerManager, a.Player, &a.Config.Scrobbling) @@ -100,7 +101,7 @@ func (a *App) initMPV() error { p := player.NewWithClientName(a.appName) c := a.Config.LocalPlayback c.InMemoryCacheSizeMB = clamp(c.InMemoryCacheSizeMB, 10, 500) - if err := p.Init(c.AudioExclusive, c.InMemoryCacheSizeMB); err != nil { + if err := p.Init(c.InMemoryCacheSizeMB); err != nil { return fmt.Errorf("failed to initialize mpv player: %s", err.Error()) } a.Player = p diff --git a/backend/playbackmanager.go b/backend/playbackmanager.go index 1db3131..1af7591 100644 --- a/backend/playbackmanager.go +++ b/backend/playbackmanager.go @@ -270,6 +270,14 @@ func (p *PlaybackManager) StopAndClearPlayQueue() { p.playQueue = nil } +func (p *PlaybackManager) SetReplayGainOptions(config ReplayGainConfig) { + p.player.SetReplayGainOptions(player.ReplayGainOptions{ + Mode: player.ReplayGainMode(config.Mode), + PreventClipping: config.PreventClipping, + PreampGain: config.PreampGainDB, + }) +} + // call BEFORE updating p.nowPlayingIdx func (p *PlaybackManager) checkScrobble(playDur time.Duration) { if !p.scrobbleCfg.Enabled || len(p.playQueue) == 0 || p.nowPlayingIdx < 0 { diff --git a/player/player.go b/player/player.go index 15ee9e6..87f6bf8 100644 --- a/player/player.go +++ b/player/player.go @@ -63,6 +63,7 @@ type Player struct { vol int replayGainOpts ReplayGainOptions haveRGainOpts bool + audioExclusive bool status Status seeking bool curPlaylistPos int64 @@ -96,7 +97,7 @@ func NewWithClientName(c string) *Player { // Initializes the Player and makes it ready for playback. // Most Player functions will return ErrUnitialized if called before Init. -func (p *Player) Init(audioExclusive bool, maxCacheMB int) error { +func (p *Player) Init(maxCacheMB int) error { if !p.initialized { m, err := CreateMPV() if err != nil { @@ -118,9 +119,7 @@ func (p *Player) Init(audioExclusive bool, maxCacheMB int) error { } m.SetOption("volume", MPVFormatInt64, p.vol) - if audioExclusive { - m.SetOptionString("audio-exclusive", "yes") - } + p.SetAudioExclusive(p.audioExclusive) if p.haveRGainOpts { p.SetReplayGainOptions(p.replayGainOpts) } @@ -259,7 +258,7 @@ func (p *Player) SetVolume(vol int) error { // Sets the ReplayGain options of the player. // Unlike most Player functions, SetReplayGainOptions can be called -// before Init, to set the initial volume of the player on startup. +// 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 @@ -281,6 +280,20 @@ func (p *Player) SetReplayGainOptions(options ReplayGainOptions) error { 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 diff --git a/ui/controller/controller.go b/ui/controller/controller.go index b2d71a7..4859b16 100644 --- a/ui/controller/controller.go +++ b/ui/controller/controller.go @@ -304,6 +304,24 @@ func (c *Controller) ShowAboutDialog() { pop.Show() } +func (c *Controller) ShowSettingsDialog() { + dlg := dialogs.NewSettingsDialog(c.App.Config) + dlg.OnReplayGainSettingsChanged = func() { + c.App.PlaybackManager.SetReplayGainOptions(c.App.Config.ReplayGain) + } + dlg.OnAudioExclusiveSettingChanged = func() { + c.App.Player.SetAudioExclusive(c.App.Config.LocalPlayback.AudioExclusive) + } + pop := widget.NewModalPopUp(dlg, c.MainWindow.Canvas()) + dlg.OnDismiss = func() { + pop.Hide() + c.doModalClosed() + } + c.ClosePopUpOnEscape(pop) + c.haveModal = true + pop.Show() +} + func (c *Controller) trySetPasswordAndConnectToServer(server *backend.ServerConfig, password string) error { if err := c.App.ServerManager.SetServerPassword(server, password); err != nil { log.Printf("error setting keyring credentials: %v", err) diff --git a/ui/dialogs/settingsdialog.go b/ui/dialogs/settingsdialog.go new file mode 100644 index 0000000..a1114a1 --- /dev/null +++ b/ui/dialogs/settingsdialog.go @@ -0,0 +1,217 @@ +package dialogs + +import ( + "math" + "strconv" + "supersonic/backend" + "supersonic/ui/layouts" + "supersonic/ui/widgets" + "unicode" + + "fyne.io/fyne/v2" + "fyne.io/fyne/v2/container" + "fyne.io/fyne/v2/layout" + "fyne.io/fyne/v2/widget" +) + +var boldStyle = widget.RichTextStyle{TextStyle: fyne.TextStyle{Bold: true}} + +type SettingsDialog struct { + widget.BaseWidget + + OnReplayGainSettingsChanged func() + OnAudioExclusiveSettingChanged func() + OnDismiss func() + + config *backend.Config + + content fyne.CanvasObject +} + +func NewSettingsDialog(config *backend.Config) *SettingsDialog { + s := &SettingsDialog{config: config} + s.ExtendBaseWidget(s) + + tabs := container.NewAppTabs( + s.createGeneralTab(), + s.createPlaybackTab(), + ) + s.content = container.NewVBox(tabs, widget.NewSeparator(), + container.NewHBox(layout.NewSpacer(), widget.NewButton("Close", func() { + if s.OnDismiss != nil { + s.OnDismiss() + } + }))) + + return s +} + +func (s *SettingsDialog) createGeneralTab() *container.TabItem { + twoDigitValidator := func(text string, r rune) bool { + return unicode.IsDigit(r) && len(text) < 2 + } + + percentEntry := widgets.NewTextRestrictedEntry(twoDigitValidator) + percentEntry.SetMinCharWidth(2) + percentEntry.OnChanged = func(str string) { + if i, err := strconv.Atoi(str); err == nil { + s.config.Scrobbling.ThresholdPercent = i + } + } + percentEntry.Text = strconv.Itoa(s.config.Scrobbling.ThresholdPercent) + if !s.config.Scrobbling.Enabled { + percentEntry.Disable() + } + + durationEntry := widgets.NewTextRestrictedEntry(twoDigitValidator) + durationEntry.SetMinCharWidth(2) + durationEntry.OnChanged = func(str string) { + if i, err := strconv.Atoi(str); err == nil { + s.config.Scrobbling.ThresholdTimeSeconds = i * 60 + } + } + if secs := s.config.Scrobbling.ThresholdTimeSeconds; secs >= 0 { + val := int(math.Round(float64(secs) / 60.)) + durationEntry.Text = strconv.Itoa(val) + } + if !s.config.Scrobbling.Enabled || s.config.Scrobbling.ThresholdTimeSeconds < 0 { + durationEntry.Disable() + } + + lastScrobbleText := durationEntry.Text + if lastScrobbleText == "" { + lastScrobbleText = "4" // default scrobble minutes + } + durationEnabled := widget.NewCheck("or when", func(checked bool) { + if !checked { + s.config.Scrobbling.ThresholdTimeSeconds = -1 + lastScrobbleText = durationEntry.Text + durationEntry.Text = "" + durationEntry.Disable() + } else { + durationEntry.Text = lastScrobbleText + durationEntry.Enable() + durationEntry.Refresh() + durationEntry.OnChanged(durationEntry.Text) + } + }) + durationEnabled.Checked = s.config.Scrobbling.ThresholdTimeSeconds >= 0 + if !s.config.Scrobbling.Enabled { + durationEnabled.Disable() + } + + scrobbleEnabled := widget.NewCheck("Send playback statistics to server", func(checked bool) { + s.config.Scrobbling.Enabled = checked + if !checked { + percentEntry.Disable() + durationEnabled.Disable() + durationEntry.Disable() + } else { + percentEntry.Enable() + durationEnabled.Enable() + if durationEnabled.Checked { + durationEntry.Enable() + } + } + }) + scrobbleEnabled.Checked = s.config.Scrobbling.Enabled + + return container.NewTabItem("General", container.NewVBox( + widget.NewRichText(&widget.TextSegment{Text: "Scrobbling", Style: boldStyle}), + scrobbleEnabled, + container.NewHBox( + widget.NewLabel("Scrobble when"), + percentEntry, + widget.NewLabel("percent of track is played"), + ), + container.NewHBox( + durationEnabled, + durationEntry, + widget.NewLabel("minutes of track have been played"), + ), + )) +} + +func (s *SettingsDialog) createPlaybackTab() *container.TabItem { + replayGainSelect := widget.NewSelect([]string{"None", "Album", "Track"}, nil) + replayGainSelect.OnChanged = func(_ string) { + switch replayGainSelect.SelectedIndex() { + case 0: + s.config.ReplayGain.Mode = backend.ReplayGainNone + case 1: + s.config.ReplayGain.Mode = backend.ReplayGainAlbum + case 2: + s.config.ReplayGain.Mode = backend.ReplayGainTrack + } + s.onReplayGainSettingsChanged() + } + + // set initially selected option + switch s.config.ReplayGain.Mode { + case backend.ReplayGainAlbum: + replayGainSelect.SetSelectedIndex(1) + case backend.ReplayGainTrack: + replayGainSelect.SetSelectedIndex(2) + default: + replayGainSelect.SetSelectedIndex(0) + } + + preampGain := widgets.NewTextRestrictedEntry(func(curText string, r rune) bool { + return (curText == "" && r == '-') || + (curText == "" && unicode.IsDigit(r)) || + ((curText == "-" || curText == "0") && unicode.IsDigit(r)) + }) + preampGain.SetMinCharWidth(2) + preampGain.OnChanged = func(text string) { + if f, err := strconv.ParseFloat(text, 64); err == nil { + s.config.ReplayGain.PreampGainDB = f + s.onReplayGainSettingsChanged() + } + } + initVal := math.Round(s.config.ReplayGain.PreampGainDB) + if initVal < -9 { + initVal = -9 + } else if initVal > 9 { + initVal = 9 + } + preampGain.Text = strconv.Itoa(int(initVal)) + + preventClipping := widget.NewCheck("", func(checked bool) { + s.config.ReplayGain.PreventClipping = checked + s.onReplayGainSettingsChanged() + }) + preventClipping.Checked = s.config.ReplayGain.PreventClipping + + audioExclusive := widget.NewCheck("Audio exclusive mode", func(checked bool) { + s.config.LocalPlayback.AudioExclusive = checked + s.onAudioExclusiveSettingsChanged() + }) + audioExclusive.Checked = s.config.LocalPlayback.AudioExclusive + + return container.NewTabItem("Playback", container.NewVBox( + widget.NewRichText(&widget.TextSegment{Text: "ReplayGain", Style: boldStyle}), + container.New(layout.NewFormLayout(), + widget.NewLabel("ReplayGain mode"), replayGainSelect, + widget.NewLabel("ReplayGain preamp"), container.NewHBox(preampGain, widget.NewLabel("dB")), + widget.NewLabel("Prevent clipping"), container.NewHBox(preventClipping, layout.NewSpacer()), + ), + container.New(&layouts.MaxPadLayout{PadLeft: 15, PadRight: 15}, widget.NewSeparator()), + container.NewHBox(audioExclusive, layout.NewSpacer()), + )) +} + +func (s *SettingsDialog) onReplayGainSettingsChanged() { + if s.OnReplayGainSettingsChanged != nil { + s.OnReplayGainSettingsChanged() + } +} + +func (s *SettingsDialog) onAudioExclusiveSettingsChanged() { + if s.OnAudioExclusiveSettingChanged != nil { + s.OnAudioExclusiveSettingChanged() + } +} + +func (s *SettingsDialog) CreateRenderer() fyne.WidgetRenderer { + return widget.NewSimpleRenderer(s.content) +} diff --git a/ui/mainwindow.go b/ui/mainwindow.go index aa5bc32..2995cc8 100644 --- a/ui/mainwindow.go +++ b/ui/mainwindow.go @@ -108,6 +108,7 @@ func NewMainWindow(fyneApp fyne.App, appName, appVersion string, app *backend.Ap } }() }) + m.BrowsingPane.AddSettingsMenuItem("Settings...", m.Controller.ShowSettingsDialog) m.BrowsingPane.AddSettingsMenuItem("About...", m.Controller.ShowAboutDialog) m.addNavigationButtons() m.BrowsingPane.DisableNavigationButtons() diff --git a/ui/widgets/textrestrictedentry.go b/ui/widgets/textrestrictedentry.go new file mode 100644 index 0000000..9f8c83a --- /dev/null +++ b/ui/widgets/textrestrictedentry.go @@ -0,0 +1,41 @@ +package widgets + +import ( + "strings" + + "fyne.io/fyne/v2" + "fyne.io/fyne/v2/theme" + "fyne.io/fyne/v2/widget" +) + +type TextRestrictedEntry struct { + widget.Entry + + charAllowed func(string, rune) bool + + minWidth float32 +} + +func NewTextRestrictedEntry(charAllowed func(curText string, r rune) bool) *TextRestrictedEntry { + e := &TextRestrictedEntry{charAllowed: charAllowed} + e.ExtendBaseWidget(e) + return e +} + +func (e *TextRestrictedEntry) TypedRune(r rune) { + if e.charAllowed == nil || e.charAllowed(e.Text, r) { + e.Entry.TypedRune(r) + } +} + +func (e *TextRestrictedEntry) SetMinCharWidth(numChars int) { + e.minWidth = theme.Padding()*2 + fyne.MeasureText(strings.Repeat("W", numChars), + fyne.CurrentApp().Settings().Theme().Size(theme.SizeNameText), e.TextStyle).Width +} + +func (e *TextRestrictedEntry) MinSize() fyne.Size { + if e.minWidth < 0.001 { + return e.Entry.MinSize() + } + return fyne.NewSize(e.minWidth, e.Entry.MinSize().Height) +}