Files
supersonic/ui/dialogs/settingsdialog.go
T

395 lines
12 KiB
Go

package dialogs
import (
"errors"
"math"
"os"
"strconv"
"strings"
"unicode"
"github.com/dweymouth/supersonic/backend"
"github.com/dweymouth/supersonic/player"
"github.com/dweymouth/supersonic/ui/layouts"
myTheme "github.com/dweymouth/supersonic/ui/theme"
"github.com/dweymouth/supersonic/ui/util"
"github.com/dweymouth/supersonic/ui/widgets"
"fyne.io/fyne/v2"
"fyne.io/fyne/v2/container"
"fyne.io/fyne/v2/data/binding"
"fyne.io/fyne/v2/dialog"
"fyne.io/fyne/v2/layout"
"fyne.io/fyne/v2/storage"
"fyne.io/fyne/v2/theme"
"fyne.io/fyne/v2/widget"
)
var boldStyle = widget.RichTextStyle{TextStyle: fyne.TextStyle{Bold: true}}
type SettingsDialog struct {
widget.BaseWidget
OnReplayGainSettingsChanged func()
OnAudioExclusiveSettingChanged func()
OnAudioDeviceSettingChanged func()
OnThemeSettingChanged func()
OnDismiss func()
config *backend.Config
audioDevices []player.AudioDevice
promptText *widget.RichText
content fyne.CanvasObject
}
// TODO: having this depend on the player package for the AudioDevice type is kinda gross. Refactor.
func NewSettingsDialog(config *backend.Config, audioDeviceList []player.AudioDevice, window fyne.Window) *SettingsDialog {
s := &SettingsDialog{config: config, audioDevices: audioDeviceList}
s.ExtendBaseWidget(s)
tabs := container.NewAppTabs(
s.createGeneralTab(),
s.createPlaybackTab(),
s.createExperimentalTab(window),
)
// workaround issue where inactivated tabs don't fully update when theme setting is changed
tabs.OnSelected = func(ti *container.TabItem) {
ti.Content.Refresh()
}
s.promptText = widget.NewRichTextWithText("")
s.content = container.NewVBox(tabs, widget.NewSeparator(),
container.NewHBox(s.promptText, layout.NewSpacer(), widget.NewButton("Close", func() {
if s.OnDismiss != nil {
s.OnDismiss()
}
})))
return s
}
func (s *SettingsDialog) createGeneralTab() *container.TabItem {
themeSelect := widget.NewSelect([]string{
string(myTheme.AppearanceDark),
string(myTheme.AppearanceLight),
string(myTheme.AppearanceAuto)}, nil)
themeSelect.OnChanged = func(_ string) {
s.config.Theme.Appearance = themeSelect.Options[themeSelect.SelectedIndex()]
if s.OnThemeSettingChanged != nil {
s.OnThemeSettingChanged()
}
}
themeSelect.SetSelected(s.config.Theme.Appearance)
if themeSelect.Selected == "" {
themeSelect.SetSelectedIndex(0)
}
startupPage := widget.NewSelect(backend.SupportedStartupPages, func(choice string) {
s.config.Application.StartupPage = choice
})
startupPage.SetSelected(s.config.Application.StartupPage)
if startupPage.Selected == "" {
startupPage.SetSelectedIndex(0)
}
closeToTray := widget.NewCheckWithData("Close to system tray",
binding.BindBool(&s.config.Application.CloseToSystemTray))
if !s.config.Application.EnableSystemTray {
closeToTray.Disable()
}
systemTrayEnable := widget.NewCheck("Enable system tray", func(val bool) {
s.config.Application.EnableSystemTray = val
// TODO: see https://github.com/fyne-io/fyne/issues/3788
// Once Fyne supports removing/hiding an existing system tray menu,
// the restart required prompt can be removed and this dialog
// can expose a callback for the Controller to show/hide the system tray menu.
s.setRestartRequired()
if val {
closeToTray.Enable()
} else {
closeToTray.Disable()
}
})
systemTrayEnable.Checked = s.config.Application.EnableSystemTray
// Scrobble settings
twoDigitValidator := func(text, selText string, r rune) bool {
return unicode.IsDigit(r) && len(text)-len(selText) < 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(
container.New(layout.NewFormLayout(),
widget.NewLabel("Appearance"), container.NewGridWithColumns(2, themeSelect),
widget.NewLabel("Startup page"), container.NewGridWithColumns(2, startupPage),
),
container.NewHBox(systemTrayEnable, closeToTray),
s.newSectionSeparator(),
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 {
deviceList := make([]string, len(s.audioDevices))
var selIndex int
for i, dev := range s.audioDevices {
deviceList[i] = dev.Description
if dev.Name == s.config.LocalPlayback.AudioDeviceName {
selIndex = i
}
}
deviceSelect := widget.NewSelect(deviceList, nil)
deviceSelect.SetSelectedIndex(selIndex)
deviceSelect.OnChanged = func(_ string) {
dev := s.audioDevices[deviceSelect.SelectedIndex()]
s.config.LocalPlayback.AudioDeviceName = dev.Name
if s.OnAudioDeviceSettingChanged != nil {
s.OnAudioDeviceSettingChanged()
}
}
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(
container.New(&layouts.MaxPadLayout{PadTop: 5},
container.New(layout.NewFormLayout(),
widget.NewLabel("Audio device"), container.NewBorder(nil, nil, nil, util.NewHSpace(70), deviceSelect),
layout.NewSpacer(), container.NewHBox(audioExclusive, layout.NewSpacer()),
)),
s.newSectionSeparator(),
widget.NewRichText(&widget.TextSegment{Text: "ReplayGain", Style: boldStyle}),
container.New(layout.NewFormLayout(),
widget.NewLabel("ReplayGain mode"), container.NewGridWithColumns(2, replayGainSelect),
widget.NewLabel("ReplayGain preamp"), container.NewHBox(preampGain, widget.NewLabel("dB")),
widget.NewLabel("Prevent clipping"), container.NewHBox(preventClipping, layout.NewSpacer()),
),
))
}
func (s *SettingsDialog) createExperimentalTab(window fyne.Window) *container.TabItem {
warningLabel := widget.NewLabel("WARNING: these settings are experimental and may " +
"make the application buggy or increase system resource use. " +
"They may be removed in future versions.")
warningLabel.Wrapping = fyne.TextWrapWord
normalFontEntry := widget.NewEntry()
normalFontEntry.SetPlaceHolder("path to .ttf or empty to use default")
normalFontEntry.Text = s.config.Application.FontNormalTTF
normalFontEntry.Validator = s.ttfPathValidator
normalFontEntry.OnChanged = func(path string) {
if normalFontEntry.Validate() == nil {
s.setRestartRequired()
s.config.Application.FontNormalTTF = path
}
}
normalFontBrowse := widget.NewButtonWithIcon("", theme.FolderOpenIcon(), func() {
s.doChooseTTFFile(window, normalFontEntry)
})
boldFontEntry := widget.NewEntry()
boldFontEntry.SetPlaceHolder("path to .ttf or empty to use default")
boldFontEntry.Text = s.config.Application.FontBoldTTF
boldFontEntry.Validator = s.ttfPathValidator
boldFontEntry.OnChanged = func(path string) {
if boldFontEntry.Validate() == nil {
s.setRestartRequired()
s.config.Application.FontBoldTTF = path
}
}
boldFontBrowse := widget.NewButtonWithIcon("", theme.FolderOpenIcon(), func() {
s.doChooseTTFFile(window, boldFontEntry)
})
return container.NewTabItem("Experimental", container.NewVBox(
warningLabel,
s.newSectionSeparator(),
widget.NewRichText(&widget.TextSegment{Text: "Application Font", Style: boldStyle}),
container.New(layout.NewFormLayout(),
widget.NewLabel("Normal font"), container.NewBorder(nil, nil, nil, normalFontBrowse, normalFontEntry),
widget.NewLabel("Bold font"), container.NewBorder(nil, nil, nil, boldFontBrowse, boldFontEntry),
),
))
}
func (s *SettingsDialog) doChooseTTFFile(window fyne.Window, entry *widget.Entry) {
callback := func(urirc fyne.URIReadCloser, err error) {
if err == nil && urirc != nil {
entry.SetText(urirc.URI().Path())
}
}
dlg := dialog.NewFileOpen(callback, window)
dlg.SetFilter(&storage.ExtensionFileFilter{Extensions: []string{".ttf"}})
dlg.Show()
}
func (s *SettingsDialog) ttfPathValidator(path string) error {
if path == "" {
return nil
}
if !strings.HasSuffix(path, ".ttf") {
return errors.New("only .ttf fonts supported")
}
_, err := os.Stat(path)
return err
}
func (s *SettingsDialog) setRestartRequired() {
ts := s.promptText.Segments[0].(*widget.TextSegment)
if ts.Text != "" {
return
}
ts.Text = "Restart required"
ts.Style.ColorName = theme.ColorNameError
s.promptText.Refresh()
}
func (s *SettingsDialog) newSectionSeparator() fyne.CanvasObject {
return container.New(&layouts.MaxPadLayout{PadLeft: 15, PadRight: 15}, widget.NewSeparator())
}
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)
}