Merge pull request #835 from M0Rf30/feat/equalizer-improvements

Add equalizer preset management and AutoEQ integration
This commit is contained in:
Drew Weymouth
2026-02-08 17:50:07 -08:00
committed by GitHub
35 changed files with 3609 additions and 168 deletions
+30 -6
View File
@@ -327,7 +327,11 @@ func (c *Controller) ShowSettingsDialog(themeUpdateCallbk func(), themeFiles map
devs, themeFiles, bands,
c.App.ServerManager.Server.ClientDecidesScrobble(),
isLocalPlayer, isReplayGainPlayer, isEqualizerPlayer, canSavePlayQueue,
c.MainWindow)
c.App.EQPresetManager,
c.MainWindow,
c.App.AutoEQManager,
c.App.ImageManager,
c.ToastProvider)
dlg.OnReplayGainSettingsChanged = func() {
c.App.PlaybackManager.SetReplayGainOptions(c.App.Config.ReplayGain)
}
@@ -342,11 +346,31 @@ func (c *Controller) ShowSettingsDialog(themeUpdateCallbk func(), themeFiles map
}
dlg.OnThemeSettingChanged = themeUpdateCallbk
dlg.OnEqualizerSettingsChanged = func() {
// currently we only have one equalizer type
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)
// Create the appropriate equalizer type based on config
var eq mpv.Equalizer
if c.App.Config.LocalPlayback.EqualizerType == "ISO10Band" {
eq10 := &mpv.ISO10BandEqualizer{
Disabled: !c.App.Config.LocalPlayback.EqualizerEnabled,
EQPreamp: c.App.Config.LocalPlayback.EqualizerPreamp,
}
// Copy up to 10 bands
numBands := min(len(c.App.Config.LocalPlayback.GraphicEqualizerBands), 10)
for i := 0; i < numBands; i++ {
eq10.BandGains[i] = c.App.Config.LocalPlayback.GraphicEqualizerBands[i]
}
eq = eq10
} else {
eq15 := &mpv.ISO15BandEqualizer{
Disabled: !c.App.Config.LocalPlayback.EqualizerEnabled,
EQPreamp: c.App.Config.LocalPlayback.EqualizerPreamp,
}
// Copy up to 15 bands
numBands := min(len(c.App.Config.LocalPlayback.GraphicEqualizerBands), 15)
for i := 0; i < numBands; i++ {
eq15.BandGains[i] = c.App.Config.LocalPlayback.GraphicEqualizerBands[i]
}
eq = eq15
}
c.App.LocalPlayer.SetEqualizer(eq)
}
dlg.OnPageNeedsRefresh = c.RefreshPageFunc
+147
View File
@@ -0,0 +1,147 @@
package dialogs
import (
"context"
"fmt"
"log"
"strings"
"fyne.io/fyne/v2"
"fyne.io/fyne/v2/lang"
"github.com/deluan/sanitize"
"github.com/dweymouth/supersonic/backend"
"github.com/dweymouth/supersonic/backend/mediaprovider"
"github.com/dweymouth/supersonic/sharedutil"
"github.com/dweymouth/supersonic/ui/theme"
"github.com/dweymouth/supersonic/ui/util"
)
// AutoEQBrowser allows users to browse and select AutoEQ headphone profiles
type AutoEQBrowser struct {
SearchDialog *SearchDialog
manager *backend.AutoEQManager
toastProvider ToastProvider
allProfileResults []*mediaprovider.SearchResult
OnProfileSelected func(*backend.AutoEQProfile)
}
func NewAutoEQBrowser(manager *backend.AutoEQManager, im util.ImageFetcher, toastProvider ToastProvider) *AutoEQBrowser {
ab := &AutoEQBrowser{
manager: manager,
toastProvider: toastProvider,
}
sd := NewSearchDialog(
im,
lang.L("Browse Headphone Profiles"),
lang.L("Cancel"),
ab.onSearched,
)
sd.PlaceholderText = lang.L("Search headphones...")
ab.SearchDialog = sd
return ab
}
func (ab *AutoEQBrowser) fetchAllProfiles() error {
ctx := context.Background()
profiles, err := ab.manager.FetchIndex(ctx)
if err != nil {
// Show empty results on error
ab.allProfileResults = []*mediaprovider.SearchResult{}
return fmt.Errorf("failed to fetch AutoEQ index: %w", err)
}
// Convert to SearchResult format for display
ab.allProfileResults = sharedutil.MapSlice(profiles, ab.profileToSearchResult)
return nil
}
func (ab *AutoEQBrowser) profileToSearchResult(profile backend.AutoEQProfileMetadata) *mediaprovider.SearchResult {
// Format secondary text as "type · source" (e.g., "over-ear · oratory1990")
subtitle := ""
if profile.Type != "" {
subtitle = profile.Type
}
if profile.Source != "" {
if subtitle != "" {
subtitle += " · "
}
subtitle += profile.Source
}
return &mediaprovider.SearchResult{
Name: profile.Name,
Icon: theme.HeadphonesIcon, // Use headphone icon for all profiles
ID: profile.Path, // Store path as ID for retrieval
Type: mediaprovider.ContentTypeOther, // Use "Other" content type for AutoEQ profiles
ArtistName: subtitle,
Size: 0, // Don't show track count
}
}
func (ab *AutoEQBrowser) onSearched(query string) []*mediaprovider.SearchResult {
if ab.allProfileResults == nil {
if err := ab.fetchAllProfiles(); err != nil {
log.Printf("Failed to load AutoEQ profiles: %v", err)
fyne.Do(func() {
ab.toastProvider.ShowErrorToast(lang.L("Error loading AutoEQ profiles"))
})
return []*mediaprovider.SearchResult{}
}
}
if query == "" {
return ab.allProfileResults
}
// Filter by name (case-insensitive, accent-insensitive)
return sharedutil.FilterSlice(ab.allProfileResults, func(result *mediaprovider.SearchResult) bool {
return strings.Contains(
sanitize.Accents(strings.ToLower(result.Name)),
sanitize.Accents(strings.ToLower(query)),
)
})
}
func (ab *AutoEQBrowser) SetOnDismiss(onDismiss func()) {
ab.SearchDialog.OnDismiss = onDismiss
}
func (ab *AutoEQBrowser) SetOnProfileSelected(callback func(*backend.AutoEQProfile)) {
ab.OnProfileSelected = callback
ab.SearchDialog.OnNavigateTo = func(_ mediaprovider.ContentType, profilePath string) {
go func() {
// Fetch the full profile data
profile, err := ab.manager.FetchProfile(context.Background(), profilePath)
fyne.Do(func() {
if err != nil {
log.Printf("Error loading AutoEQ profile: %v", err)
ab.toastProvider.ShowErrorToast(lang.L("Error loading AutoEQ profile"))
} else {
if ab.OnProfileSelected != nil {
ab.OnProfileSelected(profile)
}
}
})
}()
}
}
func (ab *AutoEQBrowser) MinSize() fyne.Size {
return ab.SearchDialog.MinSize()
}
func (ab *AutoEQBrowser) GetSearchEntry() fyne.Focusable {
return ab.SearchDialog.GetSearchEntry()
}
func (ab *AutoEQBrowser) Show() {
ab.SearchDialog.Show()
}
func (ab *AutoEQBrowser) Hide() {
ab.SearchDialog.Hide()
}
func (ab *AutoEQBrowser) Refresh() {
ab.SearchDialog.Refresh()
}
+537 -15
View File
@@ -2,13 +2,17 @@ package dialogs
import (
"fmt"
"math"
"fyne.io/fyne/v2"
"fyne.io/fyne/v2/container"
"fyne.io/fyne/v2/dialog"
"fyne.io/fyne/v2/lang"
"fyne.io/fyne/v2/layout"
"fyne.io/fyne/v2/theme"
"fyne.io/fyne/v2/widget"
ttwidget "github.com/dweymouth/fyne-tooltip/widget"
"github.com/dweymouth/supersonic/backend"
"github.com/dweymouth/supersonic/ui/layouts"
myTheme "github.com/dweymouth/supersonic/ui/theme"
"github.com/dweymouth/supersonic/ui/util"
@@ -17,43 +21,208 @@ import (
type GraphicEqualizer struct {
widget.BaseWidget
OnChanged func(band int, gain float64)
OnPreampChanged func(gain float64)
OnChanged func(band int, gain float64)
OnPreampChanged func(gain float64)
OnLoadAutoEQProfile func()
OnManualAdjustment func() // Called when user manually changes a slider
OnPresetSelected func(presetName string) // Called when user selects a preset
OnPresetDeleted func(presetName string) // Called when user deletes a preset
OnEQTypeChanged func(eqType string) // Called when EQ type is changed
bandSliders []*eqSlider
container *fyne.Container
bandSliders []*eqSlider
preampSlider *eqSlider
presetSelect *widget.Select
eqTypeSelect *widget.Select
autoEQBtn *widget.Button
profileLabel *widget.Label
container *fyne.Container
sliderArea *fyne.Container // Stores the slider area for dynamic rebuilding
topBar *fyne.Container // Stores the top bar
eqPresets []backend.EQPreset
presetManager *backend.EQPresetManager
parentWindow fyne.Window
isApplyingPreset bool // Flag to prevent clearing profile during preset application
currentEQType string // Current EQ type ("ISO10Band" or "ISO15Band")
isDirty bool // true when sliders modified since last preset load/save
loadedPreset *backend.EQPreset // currently loaded preset (nil if none)
saveBtn *ttwidget.Button // reference for enable/disable control
}
func NewGraphicEqualizer(preamp float64, bandFreqs []string, bandGains []float64) *GraphicEqualizer {
g := &GraphicEqualizer{}
func NewGraphicEqualizer(preamp float64, bandFreqs []string, bandGains []float64, eqType string, presetMgr *backend.EQPresetManager, parentWindow fyne.Window, activePresetName string) *GraphicEqualizer {
g := &GraphicEqualizer{
presetManager: presetMgr,
parentWindow: parentWindow,
currentEQType: eqType,
}
g.ExtendBaseWidget(g)
g.loadPresets()
g.buildSliders(preamp, bandFreqs, bandGains)
// Set the dropdown to the active preset if one exists
if activePresetName != "" {
g.setActivePreset(activePresetName)
// Populate loadedPreset and detect dirty state on dialog reopen
for i, p := range g.eqPresets {
if p.Name == activePresetName {
g.loadedPreset = &g.eqPresets[i]
g.isDirty = !g.matchesPreset(p)
break
}
}
}
g.updateSaveButtonState()
return g
}
func (g *GraphicEqualizer) loadPresets() {
presets, err := g.presetManager.LoadPresets()
if err != nil {
// Fallback to empty list if load fails
g.eqPresets = []backend.EQPreset{}
return
}
g.eqPresets = presets
}
func (g *GraphicEqualizer) buildSliders(preamp float64, bands []string, bandGains []float64) {
// Build preset selector
g.updatePresetSelect()
// Build EQ type selector
if g.eqTypeSelect == nil {
g.eqTypeSelect = widget.NewSelect([]string{"ISO 15-Band", "ISO 10-Band"}, func(selected string) {
// Convert display name to type
newType := "ISO15Band"
if selected == "ISO 10-Band" {
newType = "ISO10Band"
}
if newType != g.currentEQType {
g.currentEQType = newType
if g.OnEQTypeChanged != nil {
g.OnEQTypeChanged(newType)
}
}
})
}
// Set current selection
if g.currentEQType == "ISO10Band" {
g.eqTypeSelect.SetSelected("ISO 10-Band")
} else {
g.eqTypeSelect.SetSelected("ISO 15-Band")
}
// Reset button
resetBtn := widget.NewButton(lang.L("Reset"), func() {
// Find and apply the "Flat" preset
for _, p := range g.eqPresets {
if p.Name == "Flat" {
g.applyPreset(p)
g.presetSelect.SetSelected(p.Name)
if g.OnPresetSelected != nil {
g.OnPresetSelected(p.Name)
}
break
}
}
})
// Save button (overwrites current loaded preset)
g.saveBtn = ttwidget.NewButtonWithIcon("", myTheme.SaveIcon, func() {
g.saveCurrentPreset()
})
g.saveBtn.Disable() // starts disabled
g.saveBtn.SetToolTip(lang.L("Save"))
// Save As button (always enabled, opens name-entry dialog)
saveAsBtn := ttwidget.NewButtonWithIcon("", myTheme.SaveAsIcon, func() {
g.showSaveAsDialog()
})
saveAsBtn.SetToolTip(lang.L("Save As"))
// Delete button
deleteBtn := ttwidget.NewButtonWithIcon("", theme.DeleteIcon(), func() {
g.showDeletePresetDialog()
})
deleteBtn.SetToolTip(lang.L("Delete"))
// AutoEQ button
g.autoEQBtn = widget.NewButton(lang.L("AutoEQ"), func() {
if g.OnLoadAutoEQProfile != nil {
g.OnLoadAutoEQProfile()
}
})
// Profile label (hidden by default)
g.profileLabel = widget.NewLabel("")
g.profileLabel.Hide()
// Set minimum width for preset dropdown
g.presetSelect.Resize(fyne.NewSize(200, g.presetSelect.MinSize().Height))
// Top bar with controls - AutoEQ in main row for better discoverability
topBar := container.NewVBox(
// Main row: EQ type, preset selector, AutoEQ, and action buttons
container.NewHBox(
widget.NewLabel(lang.L("EQ Type:")),
g.eqTypeSelect,
widget.NewLabel(lang.L("EQ Preset:")),
g.presetSelect,
layout.NewSpacer(),
g.saveBtn,
saveAsBtn,
deleteBtn,
resetBtn,
g.autoEQBtn,
),
// Second row: Profile label (shown only when AutoEQ profile is active)
g.profileLabel,
)
// Build slider area
g.sliderArea = g.buildSliderArea(preamp, bands, bandGains)
// Store topBar and create main container
g.topBar = topBar
g.container = container.NewBorder(g.topBar, nil, nil, nil, g.sliderArea)
}
func (g *GraphicEqualizer) buildSliderArea(preamp float64, bands []string, bandGains []float64) *fyne.Container {
// Range labels
rng := container.NewVBox(
newCaptionTextSizeLabel("+12", fyne.TextAlignTrailing),
layout.NewSpacer(),
newCaptionTextSizeLabel("0", fyne.TextAlignTrailing),
newCaptionTextSizeLabel("0 dB", fyne.TextAlignTrailing),
layout.NewSpacer(),
newCaptionTextSizeLabel("-12", fyne.TextAlignTrailing),
)
g.bandSliders = make([]*eqSlider, len(bands))
bandSlidersCtr := container.New(layouts.NewGridLayoutWithColumnsAndPadding(len(bands)+2, -16))
pre := newCaptionTextSizeLabel("Pre", fyne.TextAlignCenter)
preampSlider := newEQSlider()
preampSlider.SetValue(preamp)
preampSlider.OnChanged = func(f float64) {
// Preamp slider
pre := newCaptionTextSizeLabel(lang.L("EQ Preamp"), fyne.TextAlignCenter)
g.preampSlider = newEQSlider()
g.preampSlider.SetValue(preamp)
g.preampSlider.OnChanged = func(f float64) {
if g.OnPreampChanged != nil {
g.OnPreampChanged(f)
}
preampSlider.UpdateToolTip()
g.preampSlider.UpdateToolTip()
if !g.isApplyingPreset {
g.isDirty = true
g.updateSaveButtonState()
if g.OnManualAdjustment != nil {
g.OnManualAdjustment()
}
}
}
preampSlider.UpdateToolTip()
bandSlidersCtr.Add(container.NewBorder(nil, pre, nil, nil, preampSlider))
g.preampSlider.UpdateToolTip()
bandSlidersCtr.Add(container.NewBorder(nil, pre, nil, nil, g.preampSlider))
bandSlidersCtr.Add(container.NewBorder(nil, widget.NewLabel(""), nil, nil, rng))
// Band sliders
for i, band := range bands {
s := newEQSlider()
if i < len(bandGains) {
@@ -66,13 +235,21 @@ func (g *GraphicEqualizer) buildSliders(preamp float64, bands []string, bandGain
g.OnChanged(_i, f)
}
g.bandSliders[_i].UpdateToolTip()
if !g.isApplyingPreset {
g.isDirty = true
g.updateSaveButtonState()
if g.OnManualAdjustment != nil {
g.OnManualAdjustment()
}
}
}
l := newCaptionTextSizeLabel(band, fyne.TextAlignCenter)
c := container.NewBorder(nil, l, nil, nil, s)
bandSlidersCtr.Add(c)
g.bandSliders[i] = s
}
g.container = container.NewStack(
return container.NewStack(
container.NewBorder(nil, widget.NewLabel(""), nil, nil,
container.NewBorder(nil, nil, util.NewHSpace(5), util.NewHSpace(5),
container.NewVBox(
@@ -86,6 +263,351 @@ func (g *GraphicEqualizer) buildSliders(preamp float64, bands []string, bandGain
)
}
// RebuildForEQType rebuilds the sliders for a new EQ type
func (g *GraphicEqualizer) RebuildForEQType(eqType string, bandGains []float64) {
// Determine band frequencies for the new type
var bands []string
if eqType == "ISO10Band" {
bands = []string{"31", "62", "125", "250", "500", "1k", "2k", "4k", "8k", "16k"}
} else {
bands = []string{"25", "40", "63", "100", "160", "250", "400", "630", "1k", "1.6k", "2.5k", "4k", "6.3k", "10k", "16k"}
}
// Get current preamp value
currentPreamp := 0.0
if g.preampSlider != nil {
currentPreamp = g.preampSlider.Value
}
// Rebuild the slider area
newSliderArea := g.buildSliderArea(currentPreamp, bands, bandGains)
// Replace the old slider area in the container
g.sliderArea = newSliderArea
g.container.Objects = []fyne.CanvasObject{g.topBar, g.sliderArea}
g.container.Refresh()
// Clear loaded preset when EQ type changes
g.loadedPreset = nil
g.isDirty = false
g.updateSaveButtonState()
}
func (g *GraphicEqualizer) updatePresetSelect() {
presetNames := make([]string, len(g.eqPresets))
for i, p := range g.eqPresets {
displayName := p.Name
if !p.IsBuiltin {
displayName = p.Name + " *" // Mark custom presets with asterisk
}
presetNames[i] = displayName
}
if g.presetSelect == nil {
g.presetSelect = widget.NewSelect(presetNames, func(selected string) {
// Remove asterisk marker if present
cleanName := selected
if len(selected) > 2 && selected[len(selected)-2:] == " *" {
cleanName = selected[:len(selected)-2]
}
for _, p := range g.eqPresets {
if p.Name == cleanName {
g.applyPreset(p)
if g.OnPresetSelected != nil {
g.OnPresetSelected(cleanName)
}
break
}
}
})
g.presetSelect.PlaceHolder = lang.L("EQ Preset")
} else {
g.presetSelect.Options = presetNames
g.presetSelect.Refresh()
}
}
// setActivePreset sets the dropdown selection to match the given preset name
func (g *GraphicEqualizer) setActivePreset(presetName string) {
if presetName == "" {
return
}
// Find the preset and determine display name (with asterisk for custom)
for _, p := range g.eqPresets {
if p.Name == presetName {
displayName := p.Name
if !p.IsBuiltin {
displayName = p.Name + " *"
}
g.presetSelect.SetSelected(displayName)
return
}
}
}
func (g *GraphicEqualizer) applyPreset(preset backend.EQPreset) {
g.isApplyingPreset = true
defer func() { g.isApplyingPreset = false }()
// If preset type differs from current type, switch type first
if preset.Type != "" && preset.Type != g.currentEQType {
g.currentEQType = preset.Type
// Update the type selector UI
if preset.Type == "ISO10Band" {
g.eqTypeSelect.SetSelected("ISO 10-Band")
} else {
g.eqTypeSelect.SetSelected("ISO 15-Band")
}
// Notify about type change
if g.OnEQTypeChanged != nil {
g.OnEQTypeChanged(preset.Type)
}
}
// Apply preamp
g.preampSlider.SetValue(preset.Preamp)
g.preampSlider.UpdateToolTip()
if g.OnPreampChanged != nil {
g.OnPreampChanged(preset.Preamp)
}
// Apply band gains
for i, gain := range preset.Bands {
if i < len(g.bandSliders) {
g.bandSliders[i].SetValue(gain)
g.bandSliders[i].UpdateToolTip()
if g.OnChanged != nil {
g.OnChanged(i, gain)
}
}
}
// Track the loaded preset and clear dirty state
presetCopy := preset
g.loadedPreset = &presetCopy
g.isDirty = false
g.updateSaveButtonState()
}
func (g *GraphicEqualizer) getCurrentSettings() backend.EQPreset {
bands := make([]float64, len(g.bandSliders))
for i, slider := range g.bandSliders {
bands[i] = slider.Value
}
return backend.EQPreset{
Type: g.currentEQType,
Preamp: g.preampSlider.Value,
Bands: bands,
}
}
func (g *GraphicEqualizer) updateSaveButtonState() {
if g.saveBtn == nil {
return
}
if g.loadedPreset != nil && !g.loadedPreset.IsBuiltin && g.isDirty {
g.saveBtn.Enable()
} else {
g.saveBtn.Disable()
}
}
func (g *GraphicEqualizer) saveCurrentPreset() {
if g.loadedPreset == nil || g.loadedPreset.IsBuiltin {
return
}
g.savePresetWithName(g.loadedPreset.Name)
}
func (g *GraphicEqualizer) savePresetWithName(name string) {
preset := g.getCurrentSettings()
preset.Name = name
preset.IsBuiltin = false
if err := g.presetManager.SavePreset(preset); err != nil {
dialog.ShowError(err, g.parentWindow)
return
}
// Update loaded preset and clear dirty state
g.loadedPreset = &preset
g.isDirty = false
g.updateSaveButtonState()
// Reload presets and update UI
g.loadPresets()
g.updatePresetSelect()
// Select the newly saved preset
g.presetSelect.SetSelected(preset.Name + " *")
if g.OnPresetSelected != nil {
g.OnPresetSelected(preset.Name)
}
}
func (g *GraphicEqualizer) showSaveAsDialog() {
nameEntry := widget.NewEntry()
nameEntry.SetPlaceHolder(lang.L("Preset name"))
// Pre-fill with loaded preset name if it's a custom preset
if g.loadedPreset != nil && !g.loadedPreset.IsBuiltin {
nameEntry.SetText(g.loadedPreset.Name)
}
formDialog := dialog.NewForm(
lang.L("Save Preset As"),
lang.L("Save"),
lang.L("Cancel"),
[]*widget.FormItem{
widget.NewFormItem(lang.L("Name"), nameEntry),
},
func(confirmed bool) {
if !confirmed || nameEntry.Text == "" {
return
}
name := nameEntry.Text
// Check if name matches a builtin preset
for _, p := range g.eqPresets {
if p.Name == name && p.IsBuiltin {
dialog.ShowInformation(
lang.L("Invalid Name"),
lang.L("Cannot use the name of a builtin preset"),
g.parentWindow,
)
return
}
}
// Check if name matches an existing custom preset
for _, p := range g.eqPresets {
if p.Name == name && !p.IsBuiltin {
dialog.ShowConfirm(
lang.L("Overwrite Preset"),
fmt.Sprintf(lang.L("Preset '%s' already exists. Overwrite?"), name),
func(overwrite bool) {
if overwrite {
g.savePresetWithName(name)
}
},
g.parentWindow,
)
return
}
}
g.savePresetWithName(name)
},
g.parentWindow,
)
formDialog.Resize(fyne.NewSize(400, 150))
formDialog.Show()
}
// matchesPreset compares current slider values against a preset
func (g *GraphicEqualizer) matchesPreset(preset backend.EQPreset) bool {
if g.preampSlider == nil {
return false
}
if math.Abs(g.preampSlider.Value-preset.Preamp) > 0.05 {
return false
}
if len(g.bandSliders) != len(preset.Bands) {
return false
}
for i, slider := range g.bandSliders {
if math.Abs(slider.Value-preset.Bands[i]) > 0.05 {
return false
}
}
return true
}
// ClearLoadedPresetState clears the loaded preset and dirty state
func (g *GraphicEqualizer) ClearLoadedPresetState() {
g.loadedPreset = nil
g.isDirty = false
g.updateSaveButtonState()
}
func (g *GraphicEqualizer) showDeletePresetDialog() {
selected := g.presetSelect.Selected
if selected == "" {
dialog.ShowInformation(lang.L("No Preset Selected"), lang.L("Please select a preset to delete"), g.parentWindow)
return
}
// Remove asterisk marker if present
cleanName := selected
if len(selected) > 2 && selected[len(selected)-2:] == " *" {
cleanName = selected[:len(selected)-2]
}
// Find the preset
var presetToDelete *backend.EQPreset
for i, p := range g.eqPresets {
if p.Name == cleanName {
presetToDelete = &g.eqPresets[i]
break
}
}
if presetToDelete == nil || presetToDelete.IsBuiltin {
dialog.ShowInformation(lang.L("Cannot Delete"), lang.L("Cannot delete builtin presets"), g.parentWindow)
return
}
dialog.ShowConfirm(
lang.L("Delete Preset"),
fmt.Sprintf(lang.L("Delete preset '%s'?"), cleanName),
func(confirmed bool) {
if !confirmed {
return
}
if err := g.presetManager.DeletePreset(cleanName); err != nil {
dialog.ShowError(err, g.parentWindow)
return
}
// Notify about deletion
if g.OnPresetDeleted != nil {
g.OnPresetDeleted(cleanName)
}
// Reload presets and update UI
g.loadPresets()
g.updatePresetSelect()
g.presetSelect.ClearSelected()
},
g.parentWindow,
)
}
// SetProfileLabel displays the name of the applied AutoEQ profile
func (g *GraphicEqualizer) SetProfileLabel(profileName string) {
if profileName == "" {
g.profileLabel.SetText("")
g.profileLabel.Hide()
} else {
g.profileLabel.SetText(fmt.Sprintf("%s: %s", lang.L("Profile"), profileName))
g.profileLabel.Show()
}
}
// ClearProfileLabel hides the profile label (called on manual adjustment)
func (g *GraphicEqualizer) ClearProfileLabel() {
g.SetProfileLabel("")
}
// ClearPresetSelection clears the preset dropdown selection
func (g *GraphicEqualizer) ClearPresetSelection() {
g.presetSelect.ClearSelected()
}
func newCaptionTextSizeLabel(text string, alignment fyne.TextAlign) *widget.RichText {
l := widget.NewRichTextWithText(text)
ts := l.Segments[0].(*widget.TextSegment)
+24 -10
View File
@@ -272,7 +272,11 @@ func (s *searchResult) Update(result *mediaprovider.SearchResult) {
}
s.id = result.ID
s.contentType = result.Type
s.image.PlaceholderIcon = placeholderIconForContentType(result.Type)
if result.Icon != nil {
s.image.PlaceholderIcon = result.Icon
} else {
s.image.PlaceholderIcon = placeholderIconForContentType(result.Type)
}
s.imageLoader.Load(result.CoverID)
s.title.SetText(result.Name)
@@ -300,19 +304,29 @@ func (s *searchResult) Update(result *mediaprovider.SearchResult) {
} else {
secondaryText = ""
}
case mediaprovider.ContentTypeOther:
secondaryText = result.ArtistName
}
s.secondary.Segments = []widget.RichTextSegment{
&widget.TextSegment{
Text: result.Type.String(),
Style: widget.RichTextStyle{SizeName: theme.SizeNameCaptionText, TextStyle: fyne.TextStyle{Bold: true}, Inline: true},
},
if result.Type == mediaprovider.ContentTypeOther {
s.secondary.Segments = []widget.RichTextSegment{}
} else {
s.secondary.Segments = []widget.RichTextSegment{
&widget.TextSegment{
Text: result.Type.String(),
Style: widget.RichTextStyle{SizeName: theme.SizeNameCaptionText, TextStyle: fyne.TextStyle{Bold: true}, Inline: true},
},
}
}
if secondaryText != "" {
if len(s.secondary.Segments) > 0 {
s.secondary.Segments = append(s.secondary.Segments,
&widget.TextSegment{
Text: " · ",
Style: widget.RichTextStyle{SizeName: theme.SizeNameCaptionText, Inline: true},
})
}
s.secondary.Segments = append(s.secondary.Segments,
&widget.TextSegment{
Text: " · ",
Style: widget.RichTextStyle{SizeName: theme.SizeNameCaptionText, Inline: true},
},
&widget.TextSegment{
Text: secondaryText,
Style: widget.RichTextStyle{SizeName: theme.SizeNameCaptionText, Inline: true},
+175 -6
View File
@@ -2,6 +2,7 @@ package dialogs
import (
"errors"
"log"
"math"
"os"
"slices"
@@ -41,16 +42,26 @@ type SettingsDialog struct {
OnPageNeedsRefresh func()
OnClearCaches func()
config *backend.Config
audioDevices []mpv.AudioDevice
themeFiles map[string]string // filename -> displayName
promptText *widget.RichText
config *backend.Config
audioDevices []mpv.AudioDevice
themeFiles map[string]string // filename -> displayName
promptText *widget.RichText
eqPresetManager *backend.EQPresetManager
autoEQManager *backend.AutoEQManager
imageManager util.ImageFetcher
window fyne.Window
toastProvider ToastProvider
clientDecidesScrobble bool
content fyne.CanvasObject
}
type ToastProvider interface {
ShowSuccessToast(message string)
ShowErrorToast(message string)
}
// TODO: having this depend on the mpv package for the AudioDevice type is kinda gross. Refactor.
func NewSettingsDialog(
config *backend.Config,
@@ -62,9 +73,23 @@ func NewSettingsDialog(
isReplayGainPlayer bool,
isEqualizerPlayer bool,
canSavePlayQueue bool,
eqPresetMgr *backend.EQPresetManager,
window fyne.Window,
autoEQManager *backend.AutoEQManager,
imageManager util.ImageFetcher,
toastProvider ToastProvider,
) *SettingsDialog {
s := &SettingsDialog{config: config, audioDevices: audioDeviceList, themeFiles: themeFileList, clientDecidesScrobble: clientDecidesScrobble}
s := &SettingsDialog{
config: config,
audioDevices: audioDeviceList,
themeFiles: themeFileList,
clientDecidesScrobble: clientDecidesScrobble,
eqPresetManager: eqPresetMgr,
autoEQManager: autoEQManager,
imageManager: imageManager,
window: window,
toastProvider: toastProvider,
}
s.ExtendBaseWidget(s)
// TODO: It may be a nicer UX to always create the equalizer tab,
@@ -465,7 +490,11 @@ func (s *SettingsDialog) createEqualizerTab(eqBands []string) *container.TabItem
enabled.Checked = s.config.LocalPlayback.EqualizerEnabled
geq := NewGraphicEqualizer(s.config.LocalPlayback.EqualizerPreamp,
eqBands,
s.config.LocalPlayback.GraphicEqualizerBands)
s.config.LocalPlayback.GraphicEqualizerBands,
s.config.LocalPlayback.EqualizerType,
s.eqPresetManager,
s.window,
s.config.LocalPlayback.ActiveEQPresetName)
debouncer := util.NewDebouncer(350*time.Millisecond, func() {
if s.OnEqualizerSettingsChanged != nil {
s.OnEqualizerSettingsChanged()
@@ -479,10 +508,150 @@ func (s *SettingsDialog) createEqualizerTab(eqBands []string) *container.TabItem
s.config.LocalPlayback.EqualizerPreamp = g
debouncer()
}
geq.OnManualAdjustment = func() {
// Clear AutoEQ profile when user manually adjusts sliders
// Preset name persists so Save button can overwrite the loaded preset
s.config.LocalPlayback.AutoEQProfilePath = ""
s.config.LocalPlayback.AutoEQProfileName = ""
geq.ClearProfileLabel()
}
geq.OnLoadAutoEQProfile = func() {
s.openAutoEQBrowser(geq, debouncer)
}
geq.OnPresetSelected = func(presetName string) {
// Save the active preset name in config
s.config.LocalPlayback.ActiveEQPresetName = presetName
}
geq.OnPresetDeleted = func(presetName string) {
// Clear active preset name if the deleted preset was active
if s.config.LocalPlayback.ActiveEQPresetName == presetName {
s.config.LocalPlayback.ActiveEQPresetName = ""
}
}
geq.OnEQTypeChanged = func(eqType string) {
// Update config with new EQ type
s.config.LocalPlayback.EqualizerType = eqType
// Convert bands using interpolation to preserve EQ curve shape
var newBands []float64
currentBands := s.config.LocalPlayback.GraphicEqualizerBands
if eqType == "ISO10Band" {
// Converting from 15-band to 10-band
if len(currentBands) == 15 {
// Use interpolation to downsample
var bands15 [15]float64
copy(bands15[:], currentBands)
bands10 := backend.InterpolateEQ15BandTo10Band(bands15)
newBands = bands10[:]
} else {
// Already 10-band or invalid size, just copy what we can
newBands = make([]float64, 10)
numCopy := min(len(currentBands), 10)
copy(newBands, currentBands[:numCopy])
}
} else {
// Converting from 10-band to 15-band
if len(currentBands) == 10 {
// Use interpolation to upsample
var bands10 [10]float64
copy(bands10[:], currentBands)
bands15 := backend.InterpolateEQ10To15Band(bands10)
newBands = bands15[:]
} else {
// Already 15-band or invalid size, just copy what we can
newBands = make([]float64, 15)
numCopy := min(len(currentBands), 15)
copy(newBands, currentBands[:numCopy])
}
}
s.config.LocalPlayback.GraphicEqualizerBands = newBands
// Dynamically rebuild the UI with the correct number of sliders
geq.RebuildForEQType(eqType, newBands)
// Apply the change to the player
if s.OnEqualizerSettingsChanged != nil {
s.OnEqualizerSettingsChanged()
}
}
// Restore profile label if a profile is currently applied
if s.config.LocalPlayback.AutoEQProfileName != "" {
geq.SetProfileLabel(s.config.LocalPlayback.AutoEQProfileName)
}
cont := container.NewBorder(enabled, nil, nil, nil, geq)
return container.NewTabItem(lang.L("Equalizer"), cont)
}
func (s *SettingsDialog) openAutoEQBrowser(geq *GraphicEqualizer, debouncer func()) {
if s.autoEQManager == nil {
log.Printf("ERROR: AutoEQ manager not available (nil)")
return
}
if s.imageManager == nil {
log.Printf("ERROR: Image manager not available (nil)")
return
}
browser := NewAutoEQBrowser(s.autoEQManager, s.imageManager, s.toastProvider)
// Show in a modal popup dialog
var popup *widget.PopUp
popup = widget.NewModalPopUp(browser.SearchDialog, s.window.Canvas())
browser.SetOnProfileSelected(func(profile *backend.AutoEQProfile) {
s.applyAutoEQProfile(profile, geq, debouncer)
popup.Hide()
})
browser.SetOnDismiss(func() {
popup.Hide()
})
popup.Show()
s.window.Canvas().Focus(browser.GetSearchEntry())
}
func (s *SettingsDialog) applyAutoEQProfile(profile *backend.AutoEQProfile, geq *GraphicEqualizer, debouncer func()) {
// Use native 10-band AutoEQ profile
// Update config to use ISO10Band type
s.config.LocalPlayback.EqualizerType = "ISO10Band"
s.config.LocalPlayback.EqualizerPreamp = profile.Preamp
s.config.LocalPlayback.AutoEQProfilePath = profile.Path
s.config.LocalPlayback.AutoEQProfileName = profile.Name
s.config.LocalPlayback.ActiveEQPresetName = "" // Clear preset when applying AutoEQ
// Ensure GraphicEqualizerBands has the right size for 10 bands
if len(s.config.LocalPlayback.GraphicEqualizerBands) != 10 {
s.config.LocalPlayback.GraphicEqualizerBands = make([]float64, 10)
}
// Copy native 10-band values
for i := 0; i < 10; i++ {
s.config.LocalPlayback.GraphicEqualizerBands[i] = profile.Bands[i]
}
// Update UI using applyPreset to avoid triggering manual adjustment
preset := backend.EQPreset{
Name: profile.Name,
Type: "ISO10Band",
Preamp: profile.Preamp,
Bands: profile.Bands[:],
}
geq.applyPreset(preset)
// Clear preset dropdown and loaded preset state since AutoEQ is now active
geq.ClearPresetSelection()
geq.ClearLoadedPresetState()
// Show profile label
geq.SetProfileLabel(profile.Name)
// Trigger equalizer update
debouncer()
}
func (s *SettingsDialog) createAppearanceTab(window fyne.Window) *container.TabItem {
themeNames := []string{"Default"}
themeFileNames := []string{""}
+3 -1
View File
@@ -54,7 +54,7 @@ var (
RadioIcon fyne.Resource = theme.NewThemedResource(res.ResBroadcastSvg)
FavoriteIcon fyne.Resource = theme.NewThemedResource(res.ResHeartFilledSvg)
NotFavoriteIcon fyne.Resource = theme.NewThemedResource(res.ResHeartOutlineSvg)
NowPlayingIcon fyne.Resource = theme.NewThemedResource(res.ResHeadphonesSvg)
HeadphonesIcon fyne.Resource = theme.NewThemedResource(res.ResHeadphonesSvg)
PlaylistIcon fyne.Resource = theme.NewThemedResource(res.ResPlaylistSvg)
PlayNextIcon fyne.Resource = theme.NewThemedResource(res.ResPlaylistAddNextSvg)
PlayQueueIcon fyne.Resource = theme.NewThemedResource(res.ResPlayqueueSvg)
@@ -69,6 +69,8 @@ var (
SortIcon fyne.Resource = theme.NewThemedResource(res.ResUpdownarrowSvg)
VisualizationIcon fyne.Resource = theme.NewThemedResource(res.ResOscilloscopeSvg)
LibraryIcon fyne.Resource = theme.NewThemedResource(res.ResLibrarySvg)
SaveIcon fyne.Resource = theme.NewThemedResource(res.ResSaveSvg)
SaveAsIcon fyne.Resource = theme.NewThemedResource(res.ResSaveasSvg)
)
type AppearanceMode string
+1 -1
View File
@@ -146,7 +146,7 @@ func (t *Toolbar) CreateRenderer() fyne.WidgetRenderer {
}
func (t *Toolbar) setupNavigationButtons(navigateFn func(controller.Route)) {
t.addNavigationButton(myTheme.NowPlayingIcon, controller.NowPlaying, func() {
t.addNavigationButton(myTheme.HeadphonesIcon, controller.NowPlaying, func() {
navigateFn(controller.NowPlayingRoute())
})
t.addNavigationButton(myTheme.FavoriteIcon, controller.Favorites, func() {