merge conflicts
This commit is contained in:
@@ -35,6 +35,7 @@ func NewBottomPanel(pm *backend.PlaybackManager, im *backend.ImageManager, contr
|
||||
bp.ExtendBaseWidget(bp)
|
||||
|
||||
pm.OnSongChange(bp.onSongChange)
|
||||
pm.OnRadioMetadataChange(bp.onRadioMetadataChange)
|
||||
pm.OnWaveformImgUpdate(bp.updateWaveformImg)
|
||||
pm.OnPlayTimeUpdate(func(cur, total float64, _ bool) {
|
||||
fyne.Do(func() {
|
||||
@@ -146,6 +147,16 @@ func (bp *BottomPanel) onSongChange(song mediaprovider.MediaItem, _ *mediaprovid
|
||||
})
|
||||
}
|
||||
|
||||
func (bp *BottomPanel) onRadioMetadataChange(radioName, title, artist string) {
|
||||
fyne.Do(func() {
|
||||
bp.NowPlaying.Update(&mediaprovider.Track{
|
||||
Title: title,
|
||||
ArtistNames: []string{artist},
|
||||
Album: radioName,
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
func (bp *BottomPanel) updateWaveformImg(img *backend.WaveformImage) {
|
||||
fyne.Do(func() {
|
||||
bp.Controls.UpdateWaveformImg(img)
|
||||
|
||||
+167
-68
@@ -31,9 +31,10 @@ var (
|
||||
var _ fyne.Widget = (*ArtistPage)(nil)
|
||||
|
||||
type artistPageState struct {
|
||||
artistID string
|
||||
activeView int
|
||||
trackSort widgets.TracklistSort
|
||||
artistID string
|
||||
activeView int
|
||||
topTrackSort widgets.TracklistSort
|
||||
allTrackSort widgets.TracklistSort
|
||||
|
||||
pool *util.WidgetPool
|
||||
cfg *backend.ArtistPageConfig
|
||||
@@ -42,8 +43,9 @@ type artistPageState struct {
|
||||
im *backend.ImageManager
|
||||
contr *controller.Controller
|
||||
|
||||
gridScrollPos float32 // for album grid (or grouped releases)
|
||||
listScrollPos float32 // for Top Tracks list
|
||||
gridScrollPos float32 // for album grid (or grouped releases)
|
||||
topListScrollPos float32 // for Top Tracks list
|
||||
allListScrollPos float32 // for All Tracks list
|
||||
|
||||
sectionVis widgets.GroupedReleasesSectionVisibility
|
||||
sectionVisNeedApply bool
|
||||
@@ -57,6 +59,9 @@ type ArtistPage struct {
|
||||
|
||||
artistInfo *mediaprovider.ArtistWithAlbums
|
||||
|
||||
topTracks []*mediaprovider.Track
|
||||
allTracks []*mediaprovider.Track
|
||||
|
||||
albumGrid *widgets.GridView
|
||||
groupedReleases *widgets.GroupedReleases
|
||||
tracklistCtr *fyne.Container
|
||||
@@ -69,13 +74,21 @@ type ArtistPage struct {
|
||||
const (
|
||||
viewTopTracks = "Top Tracks"
|
||||
viewDiscography = "Discography"
|
||||
viewAllTracks = "All Tracks"
|
||||
)
|
||||
|
||||
func NewArtistPage(artistID string, cfg *backend.ArtistPageConfig, pool *util.WidgetPool, pm *backend.PlaybackManager, mp mediaprovider.MediaProvider, im *backend.ImageManager, contr *controller.Controller) *ArtistPage {
|
||||
activeView := 0
|
||||
if cfg.InitialView == viewTopTracks {
|
||||
|
||||
switch cfg.InitialView {
|
||||
case viewDiscography:
|
||||
activeView = 0
|
||||
case viewTopTracks:
|
||||
activeView = 1
|
||||
case viewAllTracks:
|
||||
activeView = 2
|
||||
}
|
||||
|
||||
return newArtistPage(artistPageState{
|
||||
artistID: artistID,
|
||||
cfg: cfg,
|
||||
@@ -102,7 +115,7 @@ func newArtistPage(state artistPageState) *ArtistPage {
|
||||
if img, ok := state.im.GetCachedArtistImage(state.artistID); ok {
|
||||
a.header.artistImage.SetImage(img, true /*tappable*/)
|
||||
}
|
||||
viewToggle := widgets.NewToggleText(0, []string{lang.L("Discography"), lang.L("Top Tracks")})
|
||||
viewToggle := widgets.NewToggleText(0, []string{lang.L("Discography"), lang.L("Top Tracks"), lang.L("All Tracks")})
|
||||
viewToggle.SetActivatedLabel(a.activeView)
|
||||
viewToggle.OnChanged = a.onViewChange
|
||||
a.sortButton = widgets.NewSortChooserButton(util.LocalizeSlice(discographySorts), func(selIdx int) {
|
||||
@@ -147,6 +160,8 @@ func (a *ArtistPage) Route() controller.Route {
|
||||
}
|
||||
|
||||
func (a *ArtistPage) Reload() {
|
||||
a.topTracks = nil
|
||||
a.allTracks = nil
|
||||
go a.load()
|
||||
}
|
||||
|
||||
@@ -155,8 +170,14 @@ func (a *ArtistPage) Save() SavedPage {
|
||||
s := a.artistPageState
|
||||
if a.tracklistCtr != nil {
|
||||
tl := a.tracklistCtr.Objects[0].(*widgets.Tracklist)
|
||||
s.listScrollPos = tl.GetScrollOffset()
|
||||
s.trackSort = tl.Sorting()
|
||||
switch a.activeView {
|
||||
case 1:
|
||||
s.topListScrollPos = tl.GetScrollOffset()
|
||||
s.topTrackSort = tl.Sorting()
|
||||
case 2:
|
||||
s.allListScrollPos = tl.GetScrollOffset()
|
||||
s.allTrackSort = tl.Sorting()
|
||||
}
|
||||
tl.Clear()
|
||||
a.pool.Release(util.WidgetTypeTracklist, tl)
|
||||
}
|
||||
@@ -195,7 +216,7 @@ func (g *ArtistPage) Scroll(scrollAmt float32) {
|
||||
g.albumGrid.ScrollToOffset(g.albumGrid.GetScrollOffset() + scrollAmt)
|
||||
} else if g.activeView == 0 && g.groupedReleases != nil {
|
||||
g.groupedReleases.ScrollToOffset(g.groupedReleases.GetScrollOffset() + scrollAmt)
|
||||
} else if g.activeView == 1 && g.tracklistCtr != nil {
|
||||
} else if (g.activeView == 1 || g.activeView == 2) && g.tracklistCtr != nil {
|
||||
tl := g.tracklistCtr.Objects[0].(*widgets.Tracklist)
|
||||
tl.ScrollBy(scrollAmt)
|
||||
}
|
||||
@@ -306,11 +327,7 @@ func (a *ArtistPage) load() {
|
||||
}
|
||||
a.artistInfo = artist
|
||||
a.header.Update(artist, a.im)
|
||||
if a.activeView == 0 {
|
||||
a.showAlbumGrid(false /*reSort*/)
|
||||
} else {
|
||||
go a.showTopTracks()
|
||||
}
|
||||
a.onViewChange(a.activeView)
|
||||
})
|
||||
|
||||
info, err := a.mp.GetArtistInfo(a.artistID)
|
||||
@@ -325,6 +342,11 @@ func (a *ArtistPage) load() {
|
||||
}
|
||||
|
||||
func (a *ArtistPage) showAlbumGrid(reSort bool) {
|
||||
if a.activeView == 0 && a.albumGrid != nil {
|
||||
return // already showing album grid
|
||||
}
|
||||
a.activeView = 0
|
||||
|
||||
allAlbums := func() bool {
|
||||
return slices.IndexFunc(a.artistInfo.Albums, func(al *mediaprovider.Album) bool {
|
||||
return al.ReleaseTypes&mediaprovider.ReleaseTypeCompilation > 0 ||
|
||||
@@ -337,7 +359,6 @@ func (a *ArtistPage) showAlbumGrid(reSort bool) {
|
||||
if a.albumGrid == nil && a.groupedReleases == nil {
|
||||
if a.artistInfo == nil {
|
||||
// page not loaded yet or invalid artist
|
||||
a.activeView = 0 // if page still loading, will show discography view first
|
||||
return
|
||||
}
|
||||
if useGroupedReleases {
|
||||
@@ -390,80 +411,158 @@ func (a *ArtistPage) showAlbumGrid(reSort bool) {
|
||||
a.container.Objects[0].Refresh()
|
||||
}
|
||||
|
||||
// should be called asynchronously
|
||||
func (a *ArtistPage) showTopTracks() {
|
||||
updated := false
|
||||
updatePage := func() {
|
||||
a.sortButton.Hide()
|
||||
a.container.Objects[0].(*fyne.Container).Objects[0] = a.tracklistCtr
|
||||
a.container.Objects[0].Refresh()
|
||||
if a.activeView == 1 && a.tracklistCtr != nil {
|
||||
return // already showing top tracks
|
||||
}
|
||||
|
||||
var tl *widgets.Tracklist
|
||||
if a.tracklistCtr == nil {
|
||||
if a.artistInfo == nil {
|
||||
// page not loaded yet or invalid artist
|
||||
a.activeView = 1 // if page still loading, will show tracks view first
|
||||
return
|
||||
tl = a.obtainNewTracklist()
|
||||
a.tracklistCtr = container.New(
|
||||
&layout.CustomPaddedLayout{LeftPadding: 15, RightPadding: 15, BottomPadding: 10},
|
||||
tl)
|
||||
a.contr.ConnectTracklistActions(tl)
|
||||
} else {
|
||||
tl = a.tracklistCtr.Objects[0].(*widgets.Tracklist)
|
||||
tl.Clear()
|
||||
}
|
||||
a.activeView = 1
|
||||
tl.SetSorting(a.topTrackSort)
|
||||
a.container.Objects[0].(*fyne.Container).Objects[0] = a.tracklistCtr
|
||||
a.container.Objects[0].Refresh()
|
||||
|
||||
updateTracklist := func(ts []*mediaprovider.Track) {
|
||||
tl.SetTracks(ts)
|
||||
tl.SetNowPlaying(a.nowPlayingID)
|
||||
if a.topListScrollPos != 0 {
|
||||
tl.ScrollToOffset(a.topListScrollPos)
|
||||
}
|
||||
}
|
||||
if a.topTracks != nil {
|
||||
updateTracklist(a.topTracks)
|
||||
return
|
||||
}
|
||||
|
||||
tl.SetLoading(true)
|
||||
go func() {
|
||||
ts, err := a.mp.GetTopTracks(a.artistInfo.Artist, 20)
|
||||
if err != nil {
|
||||
log.Printf("error getting top songs: %s", err.Error())
|
||||
return
|
||||
}
|
||||
if a.disposed {
|
||||
if a.disposed || a.activeView != 1 {
|
||||
return
|
||||
}
|
||||
updated = true // mark that updatePage() will be called here
|
||||
|
||||
fyne.Do(func() {
|
||||
var tl *widgets.Tracklist
|
||||
if t := a.pool.Obtain(util.WidgetTypeTracklist); t != nil {
|
||||
tl = t.(*widgets.Tracklist)
|
||||
tl.Reset()
|
||||
tl.SetTracks(ts)
|
||||
} else {
|
||||
tl = widgets.NewTracklist(ts, a.im, false)
|
||||
}
|
||||
tl.Options = widgets.TracklistOptions{AutoNumber: true}
|
||||
_, canRate := a.mp.(mediaprovider.SupportsRating)
|
||||
_, canShare := a.mp.(mediaprovider.SupportsSharing)
|
||||
tl.Options.DisableRating = !canRate
|
||||
tl.Options.DisableSharing = !canShare
|
||||
tl.SetVisibleColumns(a.cfg.TracklistColumns)
|
||||
tl.SetSorting(a.trackSort)
|
||||
tl.OnVisibleColumnsChanged = func(cols []string) {
|
||||
a.cfg.TracklistColumns = cols
|
||||
}
|
||||
tl.SetNowPlaying(a.nowPlayingID)
|
||||
a.contr.ConnectTracklistActions(tl)
|
||||
if a.listScrollPos != 0 {
|
||||
tl.ScrollToOffset(a.listScrollPos)
|
||||
a.listScrollPos = 0
|
||||
}
|
||||
a.tracklistCtr = container.New(
|
||||
&layout.CustomPaddedLayout{LeftPadding: 15, RightPadding: 15, BottomPadding: 10},
|
||||
tl)
|
||||
updatePage()
|
||||
a.topTracks = ts
|
||||
tl.SetLoading(false)
|
||||
updateTracklist(ts)
|
||||
})
|
||||
}()
|
||||
}
|
||||
|
||||
func (a *ArtistPage) showAllTracks() {
|
||||
if a.activeView == 2 && a.tracklistCtr != nil {
|
||||
return // already showing all tracks
|
||||
}
|
||||
|
||||
if !updated {
|
||||
fyne.Do(updatePage)
|
||||
var tl *widgets.Tracklist
|
||||
if a.tracklistCtr == nil {
|
||||
tl = a.obtainNewTracklist()
|
||||
a.tracklistCtr = container.New(
|
||||
&layout.CustomPaddedLayout{LeftPadding: 15, RightPadding: 15, BottomPadding: 10},
|
||||
tl)
|
||||
a.contr.ConnectTracklistActions(tl)
|
||||
} else {
|
||||
tl = a.tracklistCtr.Objects[0].(*widgets.Tracklist)
|
||||
tl.Clear()
|
||||
}
|
||||
a.activeView = 2
|
||||
tl.SetSorting(a.allTrackSort)
|
||||
a.container.Objects[0].(*fyne.Container).Objects[0] = a.tracklistCtr
|
||||
a.container.Objects[0].Refresh()
|
||||
|
||||
updateTracklist := func(ts []*mediaprovider.Track) {
|
||||
tl.SetTracks(ts)
|
||||
tl.SetNowPlaying(a.nowPlayingID)
|
||||
if a.allListScrollPos != 0 {
|
||||
tl.ScrollToOffset(a.allListScrollPos)
|
||||
}
|
||||
}
|
||||
if a.allTracks != nil {
|
||||
updateTracklist(a.allTracks)
|
||||
return
|
||||
}
|
||||
|
||||
tl.SetLoading(true)
|
||||
go func() {
|
||||
ts, err := a.mp.GetArtistTracks(a.artistID)
|
||||
if err != nil {
|
||||
log.Printf("error getting all songs: %s", err.Error())
|
||||
return
|
||||
}
|
||||
if a.disposed || a.activeView != 2 {
|
||||
return
|
||||
}
|
||||
|
||||
fyne.Do(func() {
|
||||
a.allTracks = ts
|
||||
tl.SetLoading(false)
|
||||
updateTracklist(ts)
|
||||
})
|
||||
}()
|
||||
}
|
||||
|
||||
func (a *ArtistPage) obtainNewTracklist() *widgets.Tracklist {
|
||||
var tl *widgets.Tracklist
|
||||
if t := a.pool.Obtain(util.WidgetTypeTracklist); t != nil {
|
||||
tl = t.(*widgets.Tracklist)
|
||||
tl.Reset()
|
||||
tl.SetTracks([]*mediaprovider.Track{})
|
||||
} else {
|
||||
tl = widgets.NewTracklist([]*mediaprovider.Track{}, a.im, false)
|
||||
}
|
||||
tl.Options = widgets.TracklistOptions{AutoNumber: true}
|
||||
_, canRate := a.mp.(mediaprovider.SupportsRating)
|
||||
_, canShare := a.mp.(mediaprovider.SupportsSharing)
|
||||
tl.Options.DisableRating = !canRate
|
||||
tl.Options.DisableSharing = !canShare
|
||||
tl.SetVisibleColumns(a.cfg.TracklistColumns)
|
||||
tl.OnVisibleColumnsChanged = func(cols []string) {
|
||||
a.cfg.TracklistColumns = cols
|
||||
}
|
||||
return tl
|
||||
}
|
||||
|
||||
func (a *ArtistPage) onViewChange(num int) {
|
||||
if num == 0 {
|
||||
a.showAlbumGrid(false /*reSort*/)
|
||||
} else {
|
||||
// needs to request info from server if first time,
|
||||
// so call it asynchronously
|
||||
go a.showTopTracks()
|
||||
// save current data
|
||||
if a.tracklistCtr != nil {
|
||||
tl := a.tracklistCtr.Objects[0].(*widgets.Tracklist)
|
||||
switch a.activeView {
|
||||
case 1:
|
||||
a.topListScrollPos = tl.GetScrollOffset()
|
||||
a.topTrackSort = tl.Sorting()
|
||||
case 2:
|
||||
a.allListScrollPos = tl.GetScrollOffset()
|
||||
a.allTrackSort = tl.Sorting()
|
||||
}
|
||||
}
|
||||
a.activeView = num
|
||||
if num == 1 {
|
||||
a.cfg.InitialView = viewTopTracks
|
||||
} else {
|
||||
|
||||
switch num {
|
||||
case 0:
|
||||
a.showAlbumGrid(false /*reSort*/)
|
||||
a.sortButton.Show()
|
||||
a.cfg.InitialView = viewDiscography
|
||||
case 1:
|
||||
a.showTopTracks()
|
||||
a.sortButton.Hide()
|
||||
a.cfg.InitialView = viewTopTracks
|
||||
case 2:
|
||||
a.showAllTracks()
|
||||
a.sortButton.Hide()
|
||||
a.cfg.InitialView = viewAllTracks
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -26,7 +26,7 @@ func NewRouter(app *backend.App, controller *controller.Controller, nav Navigati
|
||||
App: app,
|
||||
Controller: controller,
|
||||
Nav: nav,
|
||||
widgetPool: util.NewWidgetPool(),
|
||||
widgetPool: util.NewWidgetPool(app.BackgroundContext()),
|
||||
}
|
||||
return r
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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},
|
||||
|
||||
@@ -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{""}
|
||||
|
||||
@@ -109,6 +109,15 @@ func NewMainWindow(fyneApp fyne.App, appName, displayAppName, appVersion string,
|
||||
app.PlaybackManager.OnSongChange(func(item mediaprovider.MediaItem, _ *mediaprovider.Track) {
|
||||
fyne.Do(func() { m.UpdateOnTrackChange(item) })
|
||||
})
|
||||
app.PlaybackManager.OnRadioMetadataChange(func(radioName, title, artist string) {
|
||||
fyne.Do(func() {
|
||||
if title != "" {
|
||||
m.Window.SetTitle(fmt.Sprintf("%s – %s · %s", title, artist, res.DisplayName))
|
||||
} else {
|
||||
m.Window.SetTitle(fmt.Sprintf("%s · %s", radioName, res.DisplayName))
|
||||
}
|
||||
})
|
||||
})
|
||||
app.PlaybackManager.OnQueueChange(func() {
|
||||
fyne.Do(func() { m.Sidebar.SetQueueTracks(app.PlaybackManager.GetPlayQueue()) })
|
||||
})
|
||||
|
||||
+3
-1
@@ -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
@@ -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() {
|
||||
|
||||
+10
-3
@@ -1,6 +1,7 @@
|
||||
package util
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
@@ -40,14 +41,20 @@ type pooledWidget struct {
|
||||
releasedAt int64 // unixMillis
|
||||
}
|
||||
|
||||
func NewWidgetPool() *WidgetPool {
|
||||
func NewWidgetPool(ctx context.Context) *WidgetPool {
|
||||
p := &WidgetPool{
|
||||
pools: make([][]pooledWidget, numWidgetTypes),
|
||||
}
|
||||
go func() {
|
||||
t := time.NewTicker(2 * time.Minute)
|
||||
for range t.C {
|
||||
p.cleanUpExpiredItems()
|
||||
defer t.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-t.C:
|
||||
p.cleanUpExpiredItems()
|
||||
}
|
||||
}
|
||||
}()
|
||||
return p
|
||||
|
||||
Reference in New Issue
Block a user