diff --git a/backend/config.go b/backend/config.go index 29566bb..15dbdf7 100644 --- a/backend/config.go +++ b/backend/config.go @@ -57,6 +57,8 @@ type AppConfig struct { EnableAutoUpdateChecker bool RequestTimeoutSeconds int EnableOSMediaPlayerAPIs bool + ShowSidebar bool + SidebarWidthFraction float64 FontNormalTTF string FontBoldTTF string @@ -207,6 +209,7 @@ func DefaultConfig(appVersionTag string) *Config { EnableAutoUpdateChecker: true, RequestTimeoutSeconds: 15, EnableOSMediaPlayerAPIs: true, + SidebarWidthFraction: 0.8, }, AlbumPage: AlbumPageConfig{ TracklistColumns: []string{"Artist", "Time", "Plays", "Favorite", "Rating"}, diff --git a/res/bundled.go b/res/bundled.go index 4956f1a..f3660ab 100644 --- a/res/bundled.go +++ b/res/bundled.go @@ -80,6 +80,11 @@ var ResPlayqueueSvg = &fyne.StaticResource{ StaticContent: []byte( "\n\n\n\r\n\r\n\r\n\r\n\r\n\r\n\r\n\r\n\r"), } +var ResSidebarSvg = &fyne.StaticResource{ + StaticName: "sidebar.svg", + StaticContent: []byte( + "\r\n\r\n\r\n\r\n"), +} var ResStarOutlineSvg = &fyne.StaticResource{ StaticName: "star-outline.svg", StaticContent: []byte( diff --git a/res/bundled_gen.sh b/res/bundled_gen.sh index 2792568..c367529 100755 --- a/res/bundled_gen.sh +++ b/res/bundled_gen.sh @@ -15,6 +15,7 @@ fyne bundle -append -prefix Res icons/publicdomain/oscilloscope.svg >> bundled.g fyne bundle -append -prefix Res icons/publicdomain/people.svg >> bundled.go fyne bundle -append -prefix Res icons/publicdomain/playlist.svg >> bundled.go fyne bundle -append -prefix Res icons/publicdomain/playqueue.svg >> bundled.go +fyne bundle -append -prefix Res icons/publicdomain/sidebar.svg >> bundled.go fyne bundle -append -prefix Res icons/publicdomain/star-outline.svg >> bundled.go fyne bundle -append -prefix Res icons/publicdomain/star-filled.svg >> bundled.go fyne bundle -append -prefix Res icons/publicdomain/theatermasks.svg >> bundled.go diff --git a/res/icons/publicdomain/sidebar.svg b/res/icons/publicdomain/sidebar.svg new file mode 100644 index 0000000..0f35df0 --- /dev/null +++ b/res/icons/publicdomain/sidebar.svg @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/res/translations/en.json b/res/translations/en.json index 48ee900..0c27198 100644 --- a/res/translations/en.json +++ b/res/translations/en.json @@ -236,6 +236,7 @@ "Time": "Time", "Title": "Title", "Title (A-Z)": "Title (A-Z)", + "Toggle sidebar": "Toggle sidebar", "Top Tracks": "Top Tracks", "Total time": "Total time", "Track": "Track", diff --git a/ui/browsing/browsingpane.go b/ui/browsing/browsingpane.go index deecf88..f7db871 100644 --- a/ui/browsing/browsingpane.go +++ b/ui/browsing/browsingpane.go @@ -3,17 +3,12 @@ package browsing import ( "fyne.io/fyne/v2" "fyne.io/fyne/v2/container" - "fyne.io/fyne/v2/lang" "fyne.io/fyne/v2/layout" - "fyne.io/fyne/v2/theme" "fyne.io/fyne/v2/widget" "github.com/dweymouth/supersonic/backend" "github.com/dweymouth/supersonic/backend/mediaprovider" "github.com/dweymouth/supersonic/ui/controller" - "github.com/dweymouth/supersonic/ui/layouts" myTheme "github.com/dweymouth/supersonic/ui/theme" - - ttwidget "github.com/dweymouth/fyne-tooltip/widget" ) type Page interface { @@ -60,60 +55,25 @@ type CanShowPlayQueue interface { type BrowsingPane struct { widget.BaseWidget - app *backend.App + OnHistoryChanged func() - curPage Page + playbackManager *backend.PlaybackManager - home *ttwidget.Button - forward *ttwidget.Button - back *ttwidget.Button - reload *ttwidget.Button + curPage Page history []SavedPage historyIdx int - settingsBtn *ttwidget.Button - settingsMenu *fyne.Menu - navBtnsContainer *fyne.Container - pageContainer *fyne.Container - container *fyne.Container - navBtnsPageMap map[controller.PageName]fyne.Resource + pageContainer *fyne.Container } -func NewBrowsingPane(app *backend.App, contr *controller.Controller, onGoHome func()) *BrowsingPane { - b := &BrowsingPane{app: app} +func NewBrowsingPane(app *backend.PlaybackManager, contr *controller.Controller, onGoHome func()) *BrowsingPane { + b := &BrowsingPane{playbackManager: app} b.ExtendBaseWidget(b) - b.home = ttwidget.NewButtonWithIcon("", theme.HomeIcon(), onGoHome) - b.home.SetToolTip(lang.L("Home")) - b.back = ttwidget.NewButtonWithIcon("", theme.NavigateBackIcon(), b.GoBack) - b.back.SetToolTip(lang.L("Back")) - b.forward = ttwidget.NewButtonWithIcon("", theme.NavigateNextIcon(), b.GoForward) - b.forward.SetToolTip(lang.L("Forward")) - b.reload = ttwidget.NewButtonWithIcon("", theme.ViewRefreshIcon(), b.Reload) - b.reload.SetToolTip(lang.L("Reload")) - b.app.PlaybackManager.OnSongChange(b.onSongChange) - b.app.PlaybackManager.OnPlayTimeUpdate(b.onPlayTimeUpdate) - b.app.PlaybackManager.OnQueueChange(b.onQueueChange) + b.playbackManager.OnSongChange(b.onSongChange) + b.playbackManager.OnPlayTimeUpdate(b.onPlayTimeUpdate) + b.playbackManager.OnQueueChange(b.onQueueChange) bkgrnd := myTheme.NewThemedRectangle(myTheme.ColorNamePageBackground) b.pageContainer = container.NewStack(bkgrnd, layout.NewSpacer()) - b.settingsBtn = ttwidget.NewButtonWithIcon("", theme.SettingsIcon(), func() { - p := widget.NewPopUpMenu(b.settingsMenu, - fyne.CurrentApp().Driver().CanvasForObject(b.settingsBtn)) - p.ShowAtPosition(fyne.NewPos(b.Size().Width-p.MinSize().Width+4, - b.navBtnsContainer.MinSize().Height+theme.Padding())) - }) - b.settingsBtn.SetToolTip(lang.L("Menu")) - quickSearchBtn := ttwidget.NewButtonWithIcon("", theme.SearchIcon(), contr.ShowQuickSearch) - quickSearchBtn.SetToolTip(lang.L("Search Everywhere")) - b.settingsMenu = fyne.NewMenu("") - b.navBtnsContainer = container.NewHBox() - b.navBtnsPageMap = map[controller.PageName]fyne.Resource{} - b.container = container.NewBorder(container.New( - &layout.CustomPaddedLayout{LeftPadding: -5, RightPadding: -5}, - container.New(layouts.NewLeftMiddleRightLayout(0, 0), - container.NewHBox(b.home, b.back, b.forward, b.reload), b.navBtnsContainer, - container.NewHBox(layout.NewSpacer(), quickSearchBtn, b.settingsBtn))), - nil, nil, nil, b.pageContainer) - b.updateHistoryButtons() return b } @@ -124,77 +84,19 @@ func (b *BrowsingPane) SetPage(p Page) { b.pageContainer.Objects[1] = layout.NewSpacer() b.curPage = nil b.pageContainer.Refresh() - b.updateNavBtnsColor(p) } else { oldPage := b.curPage if b.doSetPage(p) && oldPage != nil { b.addPageToHistory(oldPage, true) - b.updateHistoryButtons() } } + b.onHistoryChanged() } func (b *BrowsingPane) ClearHistory() { b.history = nil b.historyIdx = 0 - b.updateHistoryButtons() -} - -func (b *BrowsingPane) AddSettingsMenuItem(label string, icon fyne.Resource, action func()) { - item := fyne.NewMenuItem(label, action) - item.Icon = icon - b.settingsMenu.Items = append(b.settingsMenu.Items, item) -} - -func (b *BrowsingPane) AddSettingsSubmenu(label string, icon fyne.Resource, menu *fyne.Menu) { - item := fyne.NewMenuItem(label, nil) - item.ChildMenu = menu - item.Icon = icon - b.settingsMenu.Items = append(b.settingsMenu.Items, item) -} - -func (b *BrowsingPane) SetSubmenuForMenuItem(label string, submenu *fyne.Menu) { - for _, item := range b.settingsMenu.Items { - if item.Label == label { - item.ChildMenu = submenu - } - } -} - -func (b *BrowsingPane) AddSettingsMenuSeparator() { - b.settingsMenu.Items = append(b.settingsMenu.Items, - fyne.NewMenuItemSeparator()) -} - -func (b *BrowsingPane) AddNavigationButton(icon fyne.Resource, pageName controller.PageName, action func()) *ttwidget.Button { - // make a copy of the icon, because it can change the color - browsingPaneIcon := theme.NewThemedResource(icon) - btn := ttwidget.NewButtonWithIcon("", browsingPaneIcon, action) - btn.SetToolTip(lang.L(pageName.String())) - b.navBtnsContainer.Add(btn) - b.navBtnsPageMap[pageName] = browsingPaneIcon - return btn -} - -func (b *BrowsingPane) DisableNavigationButtons() { - for _, obj := range b.navBtnsContainer.Objects { - obj.(fyne.Disableable).Disable() - } -} - -func (b *BrowsingPane) EnableNavigationButtons() { - for _, obj := range b.navBtnsContainer.Objects { - obj.(fyne.Disableable).Enable() - } -} - -func (b *BrowsingPane) ActivateNavigationButton(num int) { - if num < len(b.navBtnsContainer.Objects) { - btn := b.navBtnsContainer.Objects[num].(*widget.Button) - if !btn.Disabled() && !btn.Hidden { - btn.OnTapped() - } - } + b.onHistoryChanged() } func (b *BrowsingPane) GetSearchBarIfAny() fyne.Focusable { @@ -255,15 +157,20 @@ func (b *BrowsingPane) doSetPage(p Page) bool { b.curPage = p if np, ok := p.(CanShowNowPlaying); ok { // inform page of currently playing track - np.OnSongChange(b.app.PlaybackManager.NowPlaying(), nil) + np.OnSongChange(b.playbackManager.NowPlaying(), nil) } b.pageContainer.Remove(b.curPage) b.pageContainer.Objects[1] = p - b.updateNavBtnsColor(p) b.Refresh() return true } +func (b *BrowsingPane) onHistoryChanged() { + if b.OnHistoryChanged != nil { + b.OnHistoryChanged() + } +} + func (b *BrowsingPane) onSongChange(song mediaprovider.MediaItem, lastScrobbledIfAny *mediaprovider.Track) { fyne.Do(func() { if b.curPage == nil { @@ -313,17 +220,12 @@ func (b *BrowsingPane) addPageToHistory(p Page, truncate bool) { b.historyIdx++ } -func (b *BrowsingPane) updateHistoryButtons() { - if b.historyIdx > 0 { - b.back.Enable() - } else { - b.back.Disable() - } - if b.historyIdx < len(b.history)-1 { - b.forward.Enable() - } else { - b.forward.Disable() - } +func (b *BrowsingPane) CanGoBack() bool { + return b.historyIdx > 0 +} + +func (b *BrowsingPane) CanGoForward() bool { + return b.historyIdx < len(b.history)-1 } func (b *BrowsingPane) GoBack() { @@ -334,7 +236,7 @@ func (b *BrowsingPane) GoBack() { b.addPageToHistory(b.curPage, false) b.historyIdx -= 2 b.doSetPage(p) - b.updateHistoryButtons() + b.onHistoryChanged() } } @@ -343,7 +245,7 @@ func (b *BrowsingPane) GoForward() { p := b.history[b.historyIdx+1].Restore() b.addPageToHistory(b.curPage, false) b.doSetPage(p) - b.updateHistoryButtons() + b.onHistoryChanged() } } @@ -361,15 +263,5 @@ func (b *BrowsingPane) CurrentPage() controller.Route { } func (b *BrowsingPane) CreateRenderer() fyne.WidgetRenderer { - return widget.NewSimpleRenderer(b.container) -} - -func (b *BrowsingPane) updateNavBtnsColor(p Page) { - for pageName, icon := range b.navBtnsPageMap { - if p != nil && pageName == p.Route().Page { - icon.(*theme.ThemedResource).ColorName = theme.ColorNamePrimary - } else { - icon.(*theme.ThemedResource).ColorName = theme.ColorNameForeground - } - } + return widget.NewSimpleRenderer(b.pageContainer) } diff --git a/ui/container/split.go b/ui/container/split.go new file mode 100644 index 0000000..13abe9c --- /dev/null +++ b/ui/container/split.go @@ -0,0 +1,422 @@ +// Copied from Fyne develop codebase, to get improvements +// to split container ahead of the Fyne 2.7 release +package container + +import ( + "fyne.io/fyne/v2" + "fyne.io/fyne/v2/canvas" + "fyne.io/fyne/v2/driver/desktop" + "fyne.io/fyne/v2/theme" + "fyne.io/fyne/v2/widget" +) + +// Declare conformity with CanvasObject interface +var _ fyne.CanvasObject = (*Split)(nil) + +// Split defines a container whose size is split between two children. +// +// Since: 1.4 +type Split struct { + widget.BaseWidget + Offset float64 + Horizontal bool + Leading fyne.CanvasObject + Trailing fyne.CanvasObject + + // to communicate to the renderer that the next refresh + // is just an offset update (ie a resize and move only) + // cleared by renderer in Refresh() + offsetUpdated bool +} + +// NewHSplit creates a horizontally arranged container with the specified leading and trailing elements. +// A vertical split bar that can be dragged will be added between the elements. +// +// Since: 1.4 +func NewHSplit(leading, trailing fyne.CanvasObject) *Split { + return newSplitContainer(true, leading, trailing) +} + +// NewVSplit creates a vertically arranged container with the specified top and bottom elements. +// A horizontal split bar that can be dragged will be added between the elements. +// +// Since: 1.4 +func NewVSplit(top, bottom fyne.CanvasObject) *Split { + return newSplitContainer(false, top, bottom) +} + +func newSplitContainer(horizontal bool, leading, trailing fyne.CanvasObject) *Split { + s := &Split{ + Offset: 0.5, // Sensible default, can be overridden with SetOffset + Horizontal: horizontal, + Leading: leading, + Trailing: trailing, + } + s.BaseWidget.ExtendBaseWidget(s) + return s +} + +// CreateRenderer is a private method to Fyne which links this widget to its renderer +func (s *Split) CreateRenderer() fyne.WidgetRenderer { + s.BaseWidget.ExtendBaseWidget(s) + d := newDivider(s) + return &splitContainerRenderer{ + split: s, + divider: d, + objects: []fyne.CanvasObject{s.Leading, d, s.Trailing}, + } +} + +// ExtendBaseWidget is used by an extending widget to make use of BaseWidget functionality. +// +// Deprecated: Support for extending containers is being removed +func (s *Split) ExtendBaseWidget(wid fyne.Widget) { + s.BaseWidget.ExtendBaseWidget(wid) +} + +// SetOffset sets the offset (0.0 to 1.0) of the Split divider. +// 0.0 - Leading is min size, Trailing uses all remaining space. +// 0.5 - Leading & Trailing equally share the available space. +// 1.0 - Trailing is min size, Leading uses all remaining space. +func (s *Split) SetOffset(offset float64) { + if s.Offset == offset { + return + } + s.Offset = offset + s.offsetUpdated = true + s.Refresh() +} + +var _ fyne.WidgetRenderer = (*splitContainerRenderer)(nil) + +type splitContainerRenderer struct { + split *Split + divider *divider + objects []fyne.CanvasObject +} + +func (r *splitContainerRenderer) Destroy() { +} + +func (r *splitContainerRenderer) Layout(size fyne.Size) { + var dividerPos, leadingPos, trailingPos fyne.Position + var dividerSize, leadingSize, trailingSize fyne.Size + + dividerVisible := r.split.Leading.Visible() && r.split.Trailing.Visible() + if !r.split.Leading.Visible() { + trailingPos = fyne.NewPos(0, 0) + trailingSize = size + } else if !r.split.Trailing.Visible() { + leadingPos = fyne.NewPos(0, 0) + leadingSize = size + } else if dividerVisible { + if r.split.Horizontal { + lw, tw := r.computeSplitLengths(size.Width, r.minLeadingWidth(), r.minTrailingWidth()) + leadingPos.X = 0 + leadingSize.Width = lw + leadingSize.Height = size.Height + dividerPos.X = lw + dividerSize.Width = dividerThickness(r.divider) + dividerSize.Height = size.Height + trailingPos.X = lw + dividerSize.Width + trailingSize.Width = tw + trailingSize.Height = size.Height + } else { + lh, th := r.computeSplitLengths(size.Height, r.minLeadingHeight(), r.minTrailingHeight()) + leadingPos.Y = 0 + leadingSize.Width = size.Width + leadingSize.Height = lh + dividerPos.Y = lh + dividerSize.Width = size.Width + dividerSize.Height = dividerThickness(r.divider) + trailingPos.Y = lh + dividerSize.Height + trailingSize.Width = size.Width + trailingSize.Height = th + } + } + + r.divider.Move(dividerPos) + r.divider.Resize(dividerSize) + r.divider.Hidden = !dividerVisible + + r.split.Leading.Move(leadingPos) + r.split.Leading.Resize(leadingSize) + r.split.Trailing.Move(trailingPos) + r.split.Trailing.Resize(trailingSize) + canvas.Refresh(r.divider) +} + +func (r *splitContainerRenderer) MinSize() fyne.Size { + s := fyne.NewSize(0, 0) + dividerVisible := r.split.Leading.Visible() && r.split.Trailing.Visible() + for i, o := range r.objects { + if (i == 1 /*divider*/ && !dividerVisible) || (i != 1 && !o.Visible()) { + continue + } + min := o.MinSize() + if r.split.Horizontal { + s.Width += min.Width + s.Height = fyne.Max(s.Height, min.Height) + } else { + s.Width = fyne.Max(s.Width, min.Width) + s.Height += min.Height + } + } + return s +} + +func (r *splitContainerRenderer) Objects() []fyne.CanvasObject { + return r.objects +} + +func (r *splitContainerRenderer) Refresh() { + if r.split.offsetUpdated { + r.Layout(r.split.Size()) + r.split.offsetUpdated = false + return + } + + r.objects[0] = r.split.Leading + // [1] is divider which doesn't change + r.objects[2] = r.split.Trailing + r.Layout(r.split.Size()) + + r.split.Leading.Refresh() + r.divider.Refresh() + r.split.Trailing.Refresh() + canvas.Refresh(r.split) +} + +func (r *splitContainerRenderer) computeSplitLengths(total, lMin, tMin float32) (float32, float32) { + available := float64(total - dividerThickness(r.divider)) + if available <= 0 { + return 0, 0 + } + ld := float64(lMin) + tr := float64(tMin) + offset := r.split.Offset + + min := ld / available + max := 1 - tr/available + if min <= max { + if offset < min { + offset = min + } + if offset > max { + offset = max + } + } else { + offset = ld / (ld + tr) + } + + ld = offset * available + tr = available - ld + return float32(ld), float32(tr) +} + +func (r *splitContainerRenderer) minLeadingWidth() float32 { + if r.split.Leading.Visible() { + return r.split.Leading.MinSize().Width + } + return 0 +} + +func (r *splitContainerRenderer) minLeadingHeight() float32 { + if r.split.Leading.Visible() { + return r.split.Leading.MinSize().Height + } + return 0 +} + +func (r *splitContainerRenderer) minTrailingWidth() float32 { + if r.split.Trailing.Visible() { + return r.split.Trailing.MinSize().Width + } + return 0 +} + +func (r *splitContainerRenderer) minTrailingHeight() float32 { + if r.split.Trailing.Visible() { + return r.split.Trailing.MinSize().Height + } + return 0 +} + +// Declare conformity with interfaces +var ( + _ fyne.CanvasObject = (*divider)(nil) + _ fyne.Draggable = (*divider)(nil) + _ desktop.Cursorable = (*divider)(nil) + _ desktop.Hoverable = (*divider)(nil) +) + +type divider struct { + widget.BaseWidget + split *Split + hovered bool + startDragOff *fyne.Position + currentDragPos fyne.Position +} + +func newDivider(split *Split) *divider { + d := ÷r{ + split: split, + } + d.ExtendBaseWidget(d) + return d +} + +// CreateRenderer is a private method to Fyne which links this widget to its renderer +func (d *divider) CreateRenderer() fyne.WidgetRenderer { + d.ExtendBaseWidget(d) + th := d.Theme() + v := fyne.CurrentApp().Settings().ThemeVariant() + + background := canvas.NewRectangle(th.Color(theme.ColorNameShadow, v)) + foreground := canvas.NewRectangle(th.Color(theme.ColorNameForeground, v)) + return ÷rRenderer{ + divider: d, + background: background, + foreground: foreground, + objects: []fyne.CanvasObject{background, foreground}, + } +} + +func (d *divider) Cursor() desktop.Cursor { + if d.split.Horizontal { + return desktop.HResizeCursor + } + return desktop.VResizeCursor +} + +func (d *divider) DragEnd() { + d.startDragOff = nil +} + +func (d *divider) Dragged(e *fyne.DragEvent) { + if d.startDragOff == nil { + d.currentDragPos = d.Position().Add(e.Position) + start := e.Position.Subtract(e.Dragged) + d.startDragOff = &start + } else { + d.currentDragPos = d.currentDragPos.Add(e.Dragged) + } + + x, y := d.currentDragPos.Components() + var offset, leadingRatio, trailingRatio float64 + if d.split.Horizontal { + widthFree := float64(d.split.Size().Width - dividerThickness(d)) + leadingRatio = float64(d.split.Leading.MinSize().Width) / widthFree + trailingRatio = 1. - (float64(d.split.Trailing.MinSize().Width) / widthFree) + offset = float64(x-d.startDragOff.X) / widthFree + } else { + heightFree := float64(d.split.Size().Height - dividerThickness(d)) + leadingRatio = float64(d.split.Leading.MinSize().Height) / heightFree + trailingRatio = 1. - (float64(d.split.Trailing.MinSize().Height) / heightFree) + offset = float64(y-d.startDragOff.Y) / heightFree + } + + if offset < leadingRatio { + offset = leadingRatio + } + if offset > trailingRatio { + offset = trailingRatio + } + d.split.SetOffset(offset) +} + +func (d *divider) MouseIn(event *desktop.MouseEvent) { + d.hovered = true + d.Refresh() +} + +func (d *divider) MouseMoved(event *desktop.MouseEvent) {} + +func (d *divider) MouseOut() { + d.hovered = false + d.Refresh() +} + +var _ fyne.WidgetRenderer = (*dividerRenderer)(nil) + +type dividerRenderer struct { + divider *divider + background *canvas.Rectangle + foreground *canvas.Rectangle + objects []fyne.CanvasObject +} + +func (r *dividerRenderer) Destroy() { +} + +func (r *dividerRenderer) Layout(size fyne.Size) { + r.background.Resize(size) + var x, y, w, h float32 + if r.divider.split.Horizontal { + x = (dividerThickness(r.divider) - handleThickness(r.divider)) / 2 + y = (size.Height - handleLength(r.divider)) / 2 + w = handleThickness(r.divider) + h = handleLength(r.divider) + } else { + x = (size.Width - handleLength(r.divider)) / 2 + y = (dividerThickness(r.divider) - handleThickness(r.divider)) / 2 + w = handleLength(r.divider) + h = handleThickness(r.divider) + } + r.foreground.Move(fyne.NewPos(x, y)) + r.foreground.Resize(fyne.NewSize(w, h)) +} + +func (r *dividerRenderer) MinSize() fyne.Size { + if r.divider.split.Horizontal { + return fyne.NewSize(dividerThickness(r.divider), dividerLength(r.divider)) + } + return fyne.NewSize(dividerLength(r.divider), dividerThickness(r.divider)) +} + +func (r *dividerRenderer) Objects() []fyne.CanvasObject { + return r.objects +} + +func (r *dividerRenderer) Refresh() { + th := r.divider.Theme() + v := fyne.CurrentApp().Settings().ThemeVariant() + + if r.divider.hovered { + r.background.FillColor = th.Color(theme.ColorNameHover, v) + } else { + r.background.FillColor = th.Color(theme.ColorNameShadow, v) + } + r.background.Refresh() + r.foreground.FillColor = th.Color(theme.ColorNameForeground, v) + r.foreground.Refresh() + r.Layout(r.divider.Size()) +} + +func dividerTheme(d *divider) fyne.Theme { + if d == nil { + return theme.Current() + } + + return d.Theme() +} + +func dividerThickness(d *divider) float32 { + th := dividerTheme(d) + return th.Size(theme.SizeNamePadding) * 2 +} + +func dividerLength(d *divider) float32 { + th := dividerTheme(d) + return th.Size(theme.SizeNamePadding) * 6 +} + +func handleThickness(d *divider) float32 { + th := dividerTheme(d) + return th.Size(theme.SizeNamePadding) / 2 +} + +func handleLength(d *divider) float32 { + th := dividerTheme(d) + return th.Size(theme.SizeNamePadding) * 4 +} diff --git a/ui/mainwindow.go b/ui/mainwindow.go index ef21795..62ea750 100644 --- a/ui/mainwindow.go +++ b/ui/mainwindow.go @@ -13,6 +13,7 @@ import ( "github.com/dweymouth/supersonic/backend/mediaprovider" "github.com/dweymouth/supersonic/res" "github.com/dweymouth/supersonic/ui/browsing" + uicontainer "github.com/dweymouth/supersonic/ui/container" "github.com/dweymouth/supersonic/ui/controller" "github.com/dweymouth/supersonic/ui/dialogs" "github.com/dweymouth/supersonic/ui/shortcuts" @@ -24,6 +25,7 @@ import ( "fyne.io/fyne/v2/dialog" "fyne.io/fyne/v2/driver/desktop" "fyne.io/fyne/v2/lang" + "fyne.io/fyne/v2/layout" "fyne.io/fyne/v2/theme" "fyne.io/fyne/v2/widget" ) @@ -35,17 +37,17 @@ type MainWindow struct { Router browsing.Router Controller *controller.Controller BrowsingPane *browsing.BrowsingPane + Sidebar *Sidebar + Toolbar *Toolbar BottomPanel *BottomPanel ToastOverlay *ToastOverlay + splitContainer *uicontainer.Split theme *myTheme.MyTheme haveSystemTray bool alreadyConnected bool // tracks if we have already connected to a server before content *mainWindowContent - // needs to be shown/hidden when switching between servers based on whether they support radio - radioBtn fyne.CanvasObject - // updated when changing servers or libraries librarySubmenu *fyne.Menu } @@ -66,9 +68,13 @@ func NewMainWindow(fyneApp fyne.App, appName, displayAppName, appVersion string, m.SetupSystemTrayMenu(displayAppName, fyneApp) } m.Controller = controller.New(app, appVersion, m.Window) - m.BrowsingPane = browsing.NewBrowsingPane(app, m.Controller, func() { m.Router.NavigateTo(m.StartupPage()) }) + m.BrowsingPane = browsing.NewBrowsingPane(app.PlaybackManager, m.Controller, func() { m.Router.NavigateTo(m.StartupPage()) }) + m.Sidebar = NewSidebar(m.Controller, m.App.ImageManager) m.ToastOverlay = NewToastOverlay() m.Router = browsing.NewRouter(app, m.Controller, m.BrowsingPane) + goHomeFn := func() { m.Router.NavigateTo(m.StartupPage()) } + m.Toolbar = NewToolbar(m.BrowsingPane, m.Router.NavigateTo, goHomeFn, m.Controller.ShowQuickSearch, m.toggleSidebar) + // inject controller dependencies m.Controller.NavHandler = m.Router.NavigateTo m.Controller.ReloadFunc = m.BrowsingPane.Reload @@ -94,27 +100,30 @@ 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.OnQueueChange(func() { + fyne.Do(func() { m.Sidebar.SetQueueTracks(app.PlaybackManager.GetPlayQueue()) }) + }) app.ServerManager.OnServerConnected(func(conf *backend.ServerConfig) { go m.RunOnServerConnectedTasks(conf, app, displayAppName) }) app.ServerManager.OnLogout(func() { - m.BrowsingPane.DisableNavigationButtons() + m.Toolbar.DisableNavigationButtons() m.BrowsingPane.SetPage(nil) m.BrowsingPane.ClearHistory() m.Controller.PromptForLoginAndConnect() }) - m.BrowsingPane.AddSettingsMenuItem(lang.L("Log Out"), theme.LogoutIcon(), func() { app.ServerManager.Logout(true) }) - m.BrowsingPane.AddSettingsMenuItem(lang.L("Switch Servers"), theme.LoginIcon(), func() { app.ServerManager.Logout(false) }) - m.BrowsingPane.AddSettingsSubmenu(lang.L("Select Library"), myTheme.LibraryIcon, fyne.NewMenu("", + m.Toolbar.AddSettingsMenuItem(lang.L("Log Out"), theme.LogoutIcon(), func() { app.ServerManager.Logout(true) }) + m.Toolbar.AddSettingsMenuItem(lang.L("Switch Servers"), theme.LoginIcon(), func() { app.ServerManager.Logout(false) }) + m.Toolbar.AddSettingsSubmenu(lang.L("Select Library"), myTheme.LibraryIcon, fyne.NewMenu("", fyne.NewMenuItem(lang.L("All Libraries"), func() { /* dummy - will get replaced on server login */ }))) - m.BrowsingPane.AddSettingsMenuItem(lang.L("Rescan Library"), theme.ViewRefreshIcon(), func() { app.ServerManager.Server.RescanLibrary() }) - m.BrowsingPane.AddSettingsMenuSeparator() - m.BrowsingPane.AddSettingsSubmenu(lang.L("Visualizations"), myTheme.VisualizationIcon, + m.Toolbar.AddSettingsMenuItem(lang.L("Rescan Library"), theme.ViewRefreshIcon(), func() { app.ServerManager.Server.RescanLibrary() }) + m.Toolbar.AddSettingsMenuSeparator() + m.Toolbar.AddSettingsSubmenu(lang.L("Visualizations"), myTheme.VisualizationIcon, fyne.NewMenu("", []*fyne.MenuItem{ fyne.NewMenuItem(lang.L("Peak Meter"), m.Controller.ShowPeakMeter), }...)) - m.BrowsingPane.AddSettingsMenuSeparator() - m.BrowsingPane.AddSettingsMenuItem(lang.L("Check for Updates"), theme.DownloadIcon(), func() { + m.Toolbar.AddSettingsMenuSeparator() + m.Toolbar.AddSettingsMenuItem(lang.L("Check for Updates"), theme.DownloadIcon(), func() { go func() { if t := app.UpdateChecker.CheckLatestVersionTag(); t != "" && t != app.VersionTag() { fyne.Do(func() { m.ShowNewVersionDialog(displayAppName, t) }) @@ -127,20 +136,24 @@ func NewMainWindow(fyneApp fyne.App, appName, displayAppName, appVersion string, } }() }) - m.BrowsingPane.AddSettingsMenuItem(lang.L("Settings")+"...", theme.SettingsIcon(), m.showSettingsDialog) - m.BrowsingPane.AddSettingsMenuItem(lang.L("About")+"...", theme.InfoIcon(), m.Controller.ShowAboutDialog) - m.addNavigationButtons() - m.BrowsingPane.DisableNavigationButtons() + m.Toolbar.AddSettingsMenuItem(lang.L("Settings")+"...", theme.SettingsIcon(), m.showSettingsDialog) + m.Toolbar.AddSettingsMenuItem(lang.L("About")+"...", theme.InfoIcon(), m.Controller.ShowAboutDialog) + m.Toolbar.DisableNavigationButtons() m.addShortcuts() - center := container.NewStack(m.BrowsingPane, m.ToastOverlay) - m.content = newMainWindowContent(container.NewBorder(nil, m.BottomPanel, nil, nil, center), + m.splitContainer = uicontainer.NewHSplit(m.BrowsingPane, m.Sidebar) + m.splitContainer.Offset = min(1.0, max(0.0, m.App.Config.Application.SidebarWidthFraction)) + m.Sidebar.Hidden = !m.App.Config.Application.ShowSidebar + center := container.NewStack(m.splitContainer, m.ToastOverlay) + toolbarWrapper := container.New( + &layout.CustomPaddedLayout{LeftPadding: -theme.Padding(), RightPadding: -theme.Padding()}, m.Toolbar) + m.content = newMainWindowContent(container.NewBorder(toolbarWrapper, m.BottomPanel, nil, nil, center), m.Controller.UnselectAll) m.Window.SetContent(fynetooltip.AddWindowToolTipLayer(m.content, m.Window.Canvas())) m.setInitialSize() m.Window.SetCloseIntercept(func() { - m.SaveWindowSize() + m.SaveWindowSettings() if app.Config.Application.CloseToSystemTray && m.HaveSystemTray() { m.Window.Hide() } else { @@ -154,9 +167,12 @@ func NewMainWindow(fyneApp fyne.App, appName, displayAppName, appVersion string, func (m *MainWindow) UpdateOnTrackChange(item mediaprovider.MediaItem) { if item == nil { m.Window.SetTitle(res.DisplayName) + m.Sidebar.SetNowPlaying("") return } + meta := item.Metadata() + m.Sidebar.SetNowPlaying(meta.ID) artistDisp := "" if tr, ok := item.(*mediaprovider.Track); ok { artistDisp = " – " + strings.Join(tr.ArtistNames, ", ") @@ -285,24 +301,20 @@ func (m *MainWindow) RunOnServerConnectedTasks(serverConf *backend.ServerConfig, } m.librarySubmenu = libraryMenu m.librarySubmenu.Items[initialLibraryMenuIdx].Checked = true - m.BrowsingPane.SetSubmenuForMenuItem(lang.L("Select Library"), libraryMenu) + m.Toolbar.SetSubmenuForMenuItem(lang.L("Select Library"), libraryMenu) if initialLibraryID != "" { m.App.ServerManager.Server.SetLibrary(initialLibraryID) } fyne.Do(func() { - m.BrowsingPane.EnableNavigationButtons() + m.Toolbar.EnableNavigationButtons() m.Router.NavigateTo(m.StartupPage()) _, canRate := m.App.ServerManager.Server.(mediaprovider.SupportsRating) m.BottomPanel.NowPlaying.DisableRating = !canRate _, supportsRadio := m.App.ServerManager.Server.(mediaprovider.RadioProvider) - if supportsRadio { - m.radioBtn.Show() - } else { - m.radioBtn.Hide() - } + m.Toolbar.SetRadioButtonVisible(supportsRadio) }) m.App.SaveConfigFile() @@ -395,31 +407,13 @@ func (m *MainWindow) ShowWhatsNewDialog() { dialog.ShowCustom("What's new in "+res.AppVersion, lang.L("Close"), dialogs.NewWhatsNewDialog(), m.Window) } -func (m *MainWindow) addNavigationButtons() { - m.BrowsingPane.AddNavigationButton(myTheme.NowPlayingIcon, controller.NowPlaying, func() { - m.Router.NavigateTo(controller.NowPlayingRoute()) - }) - m.BrowsingPane.AddNavigationButton(myTheme.FavoriteIcon, controller.Favorites, func() { - m.Router.NavigateTo(controller.FavoritesRoute()) - }) - m.BrowsingPane.AddNavigationButton(myTheme.AlbumIcon, controller.Albums, func() { - m.Router.NavigateTo(controller.AlbumsRoute()) - }) - m.BrowsingPane.AddNavigationButton(myTheme.ArtistIcon, controller.Artists, func() { - m.Router.NavigateTo(controller.ArtistsRoute()) - }) - m.BrowsingPane.AddNavigationButton(myTheme.GenreIcon, controller.Genres, func() { - m.Router.NavigateTo(controller.GenresRoute()) - }) - m.BrowsingPane.AddNavigationButton(myTheme.PlaylistIcon, controller.Playlists, func() { - m.Router.NavigateTo(controller.PlaylistsRoute()) - }) - m.BrowsingPane.AddNavigationButton(myTheme.TracksIcon, controller.Tracks, func() { - m.Router.NavigateTo(controller.TracksRoute()) - }) - m.radioBtn = m.BrowsingPane.AddNavigationButton(myTheme.RadioIcon, controller.Radios, func() { - m.Router.NavigateTo(controller.RadiosRoute()) - }) +func (m *MainWindow) toggleSidebar() { + if m.Sidebar.Visible() { + m.Sidebar.Hide() + } else { + m.Sidebar.Show() + } + m.splitContainer.Refresh() } func (m *MainWindow) addShortcuts() { @@ -473,7 +467,7 @@ func (m *MainWindow) addShortcuts() { for i, ns := range shortcuts.NavShortcuts { m.Canvas().AddShortcut(&ns, func(i int) func(fyne.Shortcut) { return func(fyne.Shortcut) { - m.BrowsingPane.ActivateNavigationButton(i) + m.Toolbar.ActivateNavigationButton(i) } }(i)) } @@ -525,14 +519,16 @@ func (m *MainWindow) SetContent(c fyne.CanvasObject) { } func (m *MainWindow) Quit() { - m.SaveWindowSize() + m.SaveWindowSettings() fyne.CurrentApp().Quit() } -func (m *MainWindow) SaveWindowSize() { +func (m *MainWindow) SaveWindowSettings() { util.SaveWindowSize(m.Window, &m.App.Config.Application.WindowWidth, &m.App.Config.Application.WindowHeight) + m.App.Config.Application.ShowSidebar = !m.Sidebar.Hidden + m.App.Config.Application.SidebarWidthFraction = m.splitContainer.Offset } // widget just so we can catch a tap event that doesn't land anywhere else diff --git a/ui/sidebar.go b/ui/sidebar.go new file mode 100644 index 0000000..f3fa7a9 --- /dev/null +++ b/ui/sidebar.go @@ -0,0 +1,46 @@ +package ui + +import ( + "fyne.io/fyne/v2" + "fyne.io/fyne/v2/container" + "fyne.io/fyne/v2/lang" + "fyne.io/fyne/v2/widget" + "github.com/dweymouth/supersonic/backend" + "github.com/dweymouth/supersonic/backend/mediaprovider" + "github.com/dweymouth/supersonic/ui/controller" + "github.com/dweymouth/supersonic/ui/theme" + "github.com/dweymouth/supersonic/ui/widgets" +) + +type Sidebar struct { + widget.BaseWidget + + queueList *widgets.PlayQueueList +} + +func NewSidebar(contr *controller.Controller, im *backend.ImageManager) *Sidebar { + s := &Sidebar{ + queueList: widgets.NewPlayQueueList(im, false), + } + s.queueList.Reorderable = true + contr.ConnectPlayQueuelistActions(s.queueList) + s.ExtendBaseWidget(s) + return s +} + +func (s *Sidebar) SetQueueTracks(items []mediaprovider.MediaItem) { + s.queueList.SetItems(items) +} + +func (s *Sidebar) SetNowPlaying(itemID string) { + s.queueList.SetNowPlaying(itemID) +} + +func (s *Sidebar) CreateRenderer() fyne.WidgetRenderer { + return widget.NewSimpleRenderer(container.NewStack( + theme.NewThemedRectangle(theme.ColorNamePageBackground), + container.NewAppTabs( + container.NewTabItem(lang.L("Play Queue"), container.NewPadded(s.queueList)), + ), + )) +} diff --git a/ui/theme/theme.go b/ui/theme/theme.go index 43e6095..a05f30e 100644 --- a/ui/theme/theme.go +++ b/ui/theme/theme.go @@ -60,6 +60,7 @@ var ( FilterIcon fyne.Resource = theme.NewThemedResource(res.ResFilterSvg) RepeatIcon fyne.Resource = theme.NewThemedResource(res.ResRepeatSvg) RepeatOneIcon fyne.Resource = theme.NewThemedResource(res.ResRepeatoneSvg) + SidebarIcon fyne.Resource = theme.NewThemedResource(res.ResSidebarSvg) SortIcon fyne.Resource = theme.NewThemedResource(res.ResUpdownarrowSvg) VisualizationIcon fyne.Resource = theme.NewThemedResource(res.ResOscilloscopeSvg) LibraryIcon fyne.Resource = theme.NewThemedResource(res.ResLibrarySvg) diff --git a/ui/toolbar.go b/ui/toolbar.go new file mode 100644 index 0000000..1ba87ac --- /dev/null +++ b/ui/toolbar.go @@ -0,0 +1,206 @@ +package ui + +import ( + "fyne.io/fyne/v2" + "fyne.io/fyne/v2/container" + "fyne.io/fyne/v2/lang" + "fyne.io/fyne/v2/layout" + "fyne.io/fyne/v2/theme" + "fyne.io/fyne/v2/widget" + "github.com/dweymouth/supersonic/ui/browsing" + "github.com/dweymouth/supersonic/ui/controller" + "github.com/dweymouth/supersonic/ui/layouts" + myTheme "github.com/dweymouth/supersonic/ui/theme" + + ttwidget "github.com/dweymouth/fyne-tooltip/widget" +) + +type Toolbar struct { + widget.BaseWidget + + browsingPane *browsing.BrowsingPane + + home *ttwidget.Button + forward *ttwidget.Button + back *ttwidget.Button + reload *ttwidget.Button + + navBtnsContainer *fyne.Container + navBtnsPageMap map[controller.PageName]fyne.Resource + radioBtn fyne.CanvasObject + + quickSearchBtn *ttwidget.Button + sidebarBtn *ttwidget.Button + settingsBtn *ttwidget.Button + settingsMenu *fyne.Menu +} + +func NewToolbar(browsingPane *browsing.BrowsingPane, navigateFn func(controller.Route), homeFunc, showSearchFn, toggleSidebarFn func()) *Toolbar { + t := &Toolbar{ + browsingPane: browsingPane, + navBtnsContainer: container.NewHBox(), + navBtnsPageMap: make(map[controller.PageName]fyne.Resource), + settingsMenu: fyne.NewMenu(""), + } + t.ExtendBaseWidget(t) + + browsingPane.OnHistoryChanged = t.updateNavButtons + + t.home = ttwidget.NewButtonWithIcon("", theme.HomeIcon(), homeFunc) + t.home.SetToolTip(lang.L("Home")) + t.back = ttwidget.NewButtonWithIcon("", theme.NavigateBackIcon(), browsingPane.GoBack) + t.back.SetToolTip(lang.L("Back")) + t.forward = ttwidget.NewButtonWithIcon("", theme.NavigateNextIcon(), browsingPane.GoForward) + t.forward.SetToolTip(lang.L("Forward")) + t.reload = ttwidget.NewButtonWithIcon("", theme.ViewRefreshIcon(), browsingPane.Reload) + t.reload.SetToolTip(lang.L("Reload")) + + t.setupNavigationButtons(navigateFn) + + t.quickSearchBtn = ttwidget.NewButtonWithIcon("", theme.SearchIcon(), showSearchFn) + t.quickSearchBtn.SetToolTip(lang.L("Search Everywhere")) + t.sidebarBtn = ttwidget.NewButtonWithIcon("", myTheme.SidebarIcon, toggleSidebarFn) + t.sidebarBtn.SetToolTip(lang.L("Toggle sidebar")) + t.settingsBtn = ttwidget.NewButtonWithIcon("", theme.SettingsIcon(), func() { + p := widget.NewPopUpMenu(t.settingsMenu, + fyne.CurrentApp().Driver().CanvasForObject(t.settingsBtn)) + p.ShowAtPosition(fyne.NewPos(t.Size().Width-p.MinSize().Width+4, + t.navBtnsContainer.MinSize().Height+theme.Padding())) + }) + t.settingsBtn.SetToolTip(lang.L("Menu")) + + t.updateNavButtons() + return t +} + +// SetRadioButtonVisible sets whether the radio button is visible +func (t *Toolbar) SetRadioButtonVisible(vis bool) { + if vis { + t.radioBtn.Show() + } else { + t.radioBtn.Hide() + } +} + +// AddSettingsMenuItem adds an item to the Settings menu +func (t *Toolbar) AddSettingsMenuItem(label string, icon fyne.Resource, action func()) { + item := fyne.NewMenuItem(label, action) + item.Icon = icon + t.settingsMenu.Items = append(t.settingsMenu.Items, item) +} + +// AddSettingsSubmenu adds a submenu to the Settings menu +func (t *Toolbar) AddSettingsSubmenu(label string, icon fyne.Resource, menu *fyne.Menu) { + item := fyne.NewMenuItem(label, nil) + item.ChildMenu = menu + item.Icon = icon + t.settingsMenu.Items = append(t.settingsMenu.Items, item) +} + +// AddSettingsMenuSeparator adds a separator to the settings menu +func (t *Toolbar) AddSettingsMenuSeparator() { + t.settingsMenu.Items = append(t.settingsMenu.Items, + fyne.NewMenuItemSeparator()) +} + +// SetSubmenuForMenuItem sets (changes) the submenu for the first menu item matching the label +func (t *Toolbar) SetSubmenuForMenuItem(label string, submenu *fyne.Menu) { + for _, item := range t.settingsMenu.Items { + if item.Label == label { + item.ChildMenu = submenu + return + } + } +} + +// DisableNavigationButtons disables all navigation buttons +func (t *Toolbar) DisableNavigationButtons() { + for _, obj := range t.navBtnsContainer.Objects { + obj.(fyne.Disableable).Disable() + } +} + +// EnableNavigationButtons enables all navigation buttons +func (t *Toolbar) EnableNavigationButtons() { + for _, obj := range t.navBtnsContainer.Objects { + obj.(fyne.Disableable).Enable() + } +} + +// ActivateNavigationButton executes the button action for the nth navigation button +func (t *Toolbar) ActivateNavigationButton(n int) { + if n < len(t.navBtnsContainer.Objects) { + btn := t.navBtnsContainer.Objects[n].(*ttwidget.Button) + if !btn.Disabled() && !btn.Hidden { + btn.OnTapped() + } + } +} + +func (t *Toolbar) CreateRenderer() fyne.WidgetRenderer { + content := container.New(layouts.NewLeftMiddleRightLayout(0, 0), + container.NewHBox(t.home, t.back, t.forward, t.reload), + t.navBtnsContainer, + container.NewHBox(layout.NewSpacer(), t.quickSearchBtn, t.sidebarBtn, t.settingsBtn)) + return widget.NewSimpleRenderer(content) +} + +func (t *Toolbar) setupNavigationButtons(navigateFn func(controller.Route)) { + t.addNavigationButton(myTheme.NowPlayingIcon, controller.NowPlaying, func() { + navigateFn(controller.NowPlayingRoute()) + }) + t.addNavigationButton(myTheme.FavoriteIcon, controller.Favorites, func() { + navigateFn(controller.FavoritesRoute()) + }) + t.addNavigationButton(myTheme.AlbumIcon, controller.Albums, func() { + navigateFn(controller.AlbumsRoute()) + }) + t.addNavigationButton(myTheme.ArtistIcon, controller.Artists, func() { + navigateFn(controller.ArtistsRoute()) + }) + t.addNavigationButton(myTheme.GenreIcon, controller.Genres, func() { + navigateFn(controller.GenresRoute()) + }) + t.addNavigationButton(myTheme.PlaylistIcon, controller.Playlists, func() { + navigateFn(controller.PlaylistsRoute()) + }) + t.addNavigationButton(myTheme.TracksIcon, controller.Tracks, func() { + navigateFn(controller.TracksRoute()) + }) + t.radioBtn = t.addNavigationButton(myTheme.RadioIcon, controller.Radios, func() { + navigateFn(controller.RadiosRoute()) + }) +} + +func (t *Toolbar) addNavigationButton(icon fyne.Resource, pageName controller.PageName, action func()) *ttwidget.Button { + // make a copy of the icon, because it can change the color + browsingPaneIcon := theme.NewThemedResource(icon) + btn := ttwidget.NewButtonWithIcon("", browsingPaneIcon, action) + btn.SetToolTip(lang.L(pageName.String())) + t.navBtnsContainer.Add(btn) + t.navBtnsPageMap[pageName] = browsingPaneIcon + return btn +} + +func (t *Toolbar) updateNavButtons() { + if t.browsingPane.CanGoBack() { + t.back.Enable() + } else { + t.back.Disable() + } + if t.browsingPane.CanGoForward() { + t.forward.Enable() + } else { + t.forward.Disable() + } + + p := t.browsingPane.CurrentPage() + for pageName, icon := range t.navBtnsPageMap { + if pageName == p.Page { + icon.(*theme.ThemedResource).ColorName = theme.ColorNamePrimary + } else { + icon.(*theme.ThemedResource).ColorName = theme.ColorNameForeground + } + } + t.Refresh() +}