diff --git a/backend/app.go b/backend/app.go index c8d785f..8d65409 100644 --- a/backend/app.go +++ b/backend/app.go @@ -185,6 +185,12 @@ func (a *App) setupMPV() error { }) a.Player.SetAudioExclusive(a.Config.LocalPlayback.AudioExclusive) + eq := &player.ISO15BandEqualizer{ + Disabled: !a.Config.LocalPlayback.EqualizerEnabled, + } + copy(eq.BandGains[:], a.Config.LocalPlayback.GraphicEqualizerBands) + a.Player.SetEqualizer(eq) + return nil } diff --git a/backend/config.go b/backend/config.go index c58a7bd..9850133 100644 --- a/backend/config.go +++ b/backend/config.go @@ -70,10 +70,12 @@ type TracksPageConfig struct { } type LocalPlaybackConfig struct { - AudioDeviceName string - AudioExclusive bool - InMemoryCacheSizeMB int - Volume int + AudioDeviceName string + AudioExclusive bool + InMemoryCacheSizeMB int + Volume int + EqualizerEnabled bool + GraphicEqualizerBands []float64 } type ScrobbleConfig struct { @@ -151,10 +153,12 @@ func DefaultConfig(appVersionTag string) *Config { }, LocalPlayback: LocalPlaybackConfig{ // "auto" is the name to pass to MPV for autoselecting the output device - AudioDeviceName: "auto", - AudioExclusive: false, - InMemoryCacheSizeMB: 30, - Volume: 100, + AudioDeviceName: "auto", + AudioExclusive: false, + InMemoryCacheSizeMB: 30, + Volume: 100, + EqualizerEnabled: false, + GraphicEqualizerBands: make([]float64, 15), }, Scrobbling: ScrobbleConfig{ Enabled: true, diff --git a/player/equalizer.go b/player/equalizer.go index 9b8117d..ee6852a 100644 --- a/player/equalizer.go +++ b/player/equalizer.go @@ -78,16 +78,23 @@ type EqualizerCurve []EqualizerBand func (e EqualizerCurve) String() string { var sb strings.Builder - for i, band := range e { - if i > 0 { - sb.WriteString(",") + first := true + for _, band := range e { + if s := band.String(); s != "" { + if !first { + sb.WriteString(",") + } + sb.WriteString(s) + first = false } - sb.WriteString(band.String()) } return sb.String() } func (e EqualizerBand) String() string { + if math.Abs(e.Gain) < 0.02 { + return "" + } return fmt.Sprintf("equalizer=f=%d:g=%0.2f:t=%s:w=%0.2f", e.Frequency, e.Gain, e.WidthType.String(), e.Width) } diff --git a/player/player.go b/player/player.go index 2447cbb..e8217bd 100644 --- a/player/player.go +++ b/player/player.go @@ -510,6 +510,10 @@ func (p *Player) SetEqualizer(eq Equalizer) error { return p.mpv.SetPropertyString("af", eq.Curve().String()) } +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) diff --git a/ui/controller/controller.go b/ui/controller/controller.go index 200de35..69a0f70 100644 --- a/ui/controller/controller.go +++ b/ui/controller/controller.go @@ -448,7 +448,8 @@ func (c *Controller) ShowSettingsDialog(themeUpdateCallbk func(), themeFiles map devs = []player.AudioDevice{{Name: "auto", Description: "Autoselect device"}} } - dlg := dialogs.NewSettingsDialog(c.App.Config, devs, themeFiles, c.MainWindow) + bands := c.App.Player.Equalizer().BandFrequencies() + dlg := dialogs.NewSettingsDialog(c.App.Config, devs, themeFiles, bands, c.MainWindow) dlg.OnReplayGainSettingsChanged = func() { c.App.PlaybackManager.SetReplayGainOptions(c.App.Config.ReplayGain) } @@ -459,6 +460,13 @@ func (c *Controller) ShowSettingsDialog(themeUpdateCallbk func(), themeFiles map c.App.Player.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.Disabled = !c.App.Config.LocalPlayback.EqualizerEnabled + copy(eq.BandGains[:], c.App.Config.LocalPlayback.GraphicEqualizerBands) + c.App.Player.SetEqualizer(eq) + } pop := widget.NewModalPopUp(dlg, c.MainWindow.Canvas()) dlg.OnDismiss = func() { pop.Hide() diff --git a/ui/dialogs/graphicequalizer.go b/ui/dialogs/graphicequalizer.go new file mode 100644 index 0000000..0d7d37c --- /dev/null +++ b/ui/dialogs/graphicequalizer.go @@ -0,0 +1,104 @@ +package dialogs + +import ( + "fyne.io/fyne/v2" + "fyne.io/fyne/v2/container" + "fyne.io/fyne/v2/layout" + "fyne.io/fyne/v2/theme" + "fyne.io/fyne/v2/widget" + "github.com/dweymouth/supersonic/ui/layouts" + myTheme "github.com/dweymouth/supersonic/ui/theme" + "github.com/dweymouth/supersonic/ui/util" +) + +type GraphicEqualizer struct { + widget.BaseWidget + + OnChanged func(band int, gain float64) + + container *fyne.Container +} + +func NewGraphicEqualizer(bandFreqs []string, bandGains []float64) *GraphicEqualizer { + g := &GraphicEqualizer{} + g.ExtendBaseWidget(g) + g.buildSliders(bandFreqs, bandGains) + + return g +} + +func (g *GraphicEqualizer) buildSliders(bands []string, bandGains []float64) { + rng := container.NewVBox( + newCaptionTextSizeLabel("+12", fyne.TextAlignTrailing), + layout.NewSpacer(), + newCaptionTextSizeLabel("0", fyne.TextAlignTrailing), + layout.NewSpacer(), + newCaptionTextSizeLabel("-12", fyne.TextAlignTrailing), + ) + bandSliders := container.New(layouts.NewGridLayoutWithColumnsAndPadding(16, -16)) + bandSliders.Add(container.NewBorder(nil, widget.NewLabel(""), nil, nil, rng)) + for i, band := range bands { + s := newEQSlider() + if i < len(bandGains) { + s.SetValue(bandGains[i]) + } + s.OnChanged = func(i int) func(float64) { + return func(f float64) { + if g.OnChanged != nil { + g.OnChanged(i, f) + } + } + }(i) + l := newCaptionTextSizeLabel(band, fyne.TextAlignCenter) + c := container.NewBorder(nil, l, nil, nil, s) + bandSliders.Add(c) + } + g.container = container.NewMax( + container.NewBorder(nil, widget.NewLabel(""), nil, nil, + container.NewBorder(nil, nil, util.NewHSpace(35), util.NewHSpace(5), + container.NewVBox( + layout.NewSpacer(), + myTheme.NewThemedRectangle(theme.ColorNameInputBackground), + layout.NewSpacer(), + ), + ), + ), + bandSliders, + ) +} + +func newCaptionTextSizeLabel(text string, alignment fyne.TextAlign) *widget.RichText { + l := widget.NewRichTextWithText(text) + ts := l.Segments[0].(*widget.TextSegment) + ts.Style.SizeName = theme.SizeNameCaptionText + ts.Style.Alignment = alignment + return l +} + +func (g *GraphicEqualizer) CreateRenderer() fyne.WidgetRenderer { + return widget.NewSimpleRenderer(g.container) +} + +type eqSlider struct { + widget.Slider + tappedAt int64 +} + +func newEQSlider() *eqSlider { + s := &eqSlider{ + Slider: widget.Slider{ + Orientation: widget.Vertical, + Min: -12, + Max: 12, + Step: 0.1, + }, + } + s.ExtendBaseWidget(s) + return s +} + +// We implement our own double tapping so that the Tapped behavior +// can be triggered instantly. +func (s *eqSlider) DoubleTapped(e *fyne.PointEvent) { + s.SetValue(0) +} diff --git a/ui/dialogs/settingsdialog.go b/ui/dialogs/settingsdialog.go index b1020a4..cab5f76 100644 --- a/ui/dialogs/settingsdialog.go +++ b/ui/dialogs/settingsdialog.go @@ -6,6 +6,7 @@ import ( "os" "strconv" "strings" + "time" "unicode" "github.com/dweymouth/supersonic/backend" @@ -35,6 +36,7 @@ type SettingsDialog struct { OnAudioDeviceSettingChanged func() OnThemeSettingChanged func() OnDismiss func() + OnEqualizerSettingsChanged func() config *backend.Config audioDevices []player.AudioDevice @@ -45,13 +47,20 @@ type SettingsDialog struct { } // TODO: having this depend on the player package for the AudioDevice type is kinda gross. Refactor. -func NewSettingsDialog(config *backend.Config, audioDeviceList []player.AudioDevice, themeFileList map[string]string, window fyne.Window) *SettingsDialog { +func NewSettingsDialog( + config *backend.Config, + audioDeviceList []player.AudioDevice, + themeFileList map[string]string, + equalizerBands []string, + window fyne.Window, +) *SettingsDialog { s := &SettingsDialog{config: config, audioDevices: audioDeviceList, themeFiles: themeFileList} s.ExtendBaseWidget(s) tabs := container.NewAppTabs( s.createGeneralTab(), s.createPlaybackTab(), + s.createEqualizerTab(equalizerBands), s.createExperimentalTab(window), ) // workaround issue where inactivated tabs don't fully update when theme setting is changed @@ -320,6 +329,28 @@ func (s *SettingsDialog) createPlaybackTab() *container.TabItem { )) } +func (s *SettingsDialog) createEqualizerTab(eqBands []string) *container.TabItem { + enabled := widget.NewCheck("Enabled", func(b bool) { + s.config.LocalPlayback.EqualizerEnabled = b + if s.OnEqualizerSettingsChanged != nil { + s.OnEqualizerSettingsChanged() + } + }) + enabled.Checked = s.config.LocalPlayback.EqualizerEnabled + geq := NewGraphicEqualizer(eqBands, s.config.LocalPlayback.GraphicEqualizerBands) // TODO: This should probably be an argument? + debouncer := util.NewDebouncer(200*time.Millisecond, func() { + if s.OnEqualizerSettingsChanged != nil { + s.OnEqualizerSettingsChanged() + } + }) + geq.OnChanged = func(b int, g float64) { + s.config.LocalPlayback.GraphicEqualizerBands[b] = g + debouncer() + } + cont := container.NewBorder(container.NewHBox(enabled), nil, nil, nil, geq) + return container.NewTabItem("Equalizer", cont) +} + 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. " + diff --git a/ui/layouts/gridlayoutcustompadding.go b/ui/layouts/gridlayoutcustompadding.go new file mode 100644 index 0000000..19f13e0 --- /dev/null +++ b/ui/layouts/gridlayoutcustompadding.go @@ -0,0 +1,157 @@ +package layouts + +// Forked from fyne.io/fyne/v2/layout/gridlayout.go + +import ( + "math" + + "fyne.io/fyne/v2" + "fyne.io/fyne/v2/theme" +) + +// Declare conformity with Layout interface +var _ fyne.Layout = (*gridLayout)(nil) + +type gridLayout struct { + Cols int + Padding float32 + vertical, adapt bool +} + +// NewAdaptiveGridLayout returns a new grid layout which uses columns when horizontal but rows when vertical. +func NewAdaptiveGridLayout(rowcols int) fyne.Layout { + return &gridLayout{Cols: rowcols, adapt: true} +} + +// NewGridLayout returns a grid layout arranged in a specified number of columns. +// The number of rows will depend on how many children are in the container that uses this layout. +func NewGridLayout(cols int) fyne.Layout { + return NewGridLayoutWithColumns(cols) +} + +// NewGridLayoutWithColumns returns a new grid layout that specifies a column count and wrap to new rows when needed. +func NewGridLayoutWithColumns(cols int) fyne.Layout { + return &gridLayout{Cols: cols} +} + +// NewGridLayoutWithRows returns a new grid layout that specifies a row count that creates new rows as required. +func NewGridLayoutWithRows(rows int) fyne.Layout { + return &gridLayout{Cols: rows, vertical: true} +} + +func NewGridLayoutWithColumnsAndPadding(cols int, padding float32) fyne.Layout { + return &gridLayout{Cols: cols, Padding: padding} +} + +func (g *gridLayout) horizontal() bool { + if g.adapt { + return fyne.IsHorizontal(fyne.CurrentDevice().Orientation()) + } + + return !g.vertical +} + +func (g *gridLayout) countRows(objects []fyne.CanvasObject) int { + if g.Cols < 1 { + g.Cols = 1 + } + count := 0 + for _, child := range objects { + if child.Visible() { + count++ + } + } + + return int(math.Ceil(float64(count) / float64(g.Cols))) +} + +// Get the leading (top or left) edge of a grid cell. +// size is the ideal cell size and the offset is which col or row its on. +func getLeading(size float64, padding float32, offset int) float32 { + ret := (size + float64(padding)) * float64(offset) + + return float32(ret) +} + +// Get the trailing (bottom or right) edge of a grid cell. +// size is the ideal cell size and the offset is which col or row its on. +func getTrailing(size float64, padding float32, offset int) float32 { + return getLeading(size, padding, offset+1) - padding +} + +// Layout is called to pack all child objects into a specified size. +// For a GridLayout this will pack objects into a table format with the number +// of columns specified in our constructor. +func (g *gridLayout) Layout(objects []fyne.CanvasObject, size fyne.Size) { + rows := g.countRows(objects) + + padding := theme.Padding() + g.Padding + padWidth := float32(g.Cols-1) * padding + padHeight := float32(rows-1) * padding + cellWidth := float64(size.Width-padWidth) / float64(g.Cols) + cellHeight := float64(size.Height-padHeight) / float64(rows) + + if !g.horizontal() { + padWidth, padHeight = padHeight, padWidth + cellWidth = float64(size.Width-padWidth) / float64(rows) + cellHeight = float64(size.Height-padHeight) / float64(g.Cols) + } + + row, col := 0, 0 + i := 0 + for _, child := range objects { + if !child.Visible() { + continue + } + + x1 := getLeading(cellWidth, padding, col) + y1 := getLeading(cellHeight, padding, row) + x2 := getTrailing(cellWidth, padding, col) + y2 := getTrailing(cellHeight, padding, row) + + child.Move(fyne.NewPos(x1, y1)) + child.Resize(fyne.NewSize(x2-x1, y2-y1)) + + if g.horizontal() { + if (i+1)%g.Cols == 0 { + row++ + col = 0 + } else { + col++ + } + } else { + if (i+1)%g.Cols == 0 { + col++ + row = 0 + } else { + row++ + } + } + i++ + } +} + +// MinSize finds the smallest size that satisfies all the child objects. +// For a GridLayout this is the size of the largest child object multiplied by +// the required number of columns and rows, with appropriate padding between +// children. +func (g *gridLayout) MinSize(objects []fyne.CanvasObject) fyne.Size { + rows := g.countRows(objects) + minSize := fyne.NewSize(0, 0) + for _, child := range objects { + if !child.Visible() { + continue + } + + minSize = minSize.Max(child.MinSize()) + } + + padding := theme.Padding() + g.Padding + if g.horizontal() { + minContentSize := fyne.NewSize(minSize.Width*float32(g.Cols), minSize.Height*float32(rows)) + return minContentSize.Add(fyne.NewSize(padding*fyne.Max(float32(g.Cols-1), 0), padding*fyne.Max(float32(rows-1), 0))) + } + + minContentSize := fyne.NewSize(minSize.Width*float32(rows), minSize.Height*float32(g.Cols)) + return minContentSize.Add(fyne.NewSize(padding*fyne.Max(float32(rows-1), 0), padding*fyne.Max(float32(g.Cols-1), 0))) +}