diff --git a/backend/app.go b/backend/app.go index 8f56b1e..d4f2b0b 100644 --- a/backend/app.go +++ b/backend/app.go @@ -37,6 +37,10 @@ func StartupApp() (*App, error) { a := &App{} a.bgrndCtx, a.cancel = context.WithCancel(context.Background()) + log.Printf("Starting %s...", AppName) + log.Printf("Using config dir: %s", configdir.LocalConfig(AppName)) + log.Printf("Using cache dir: %s", configdir.LocalCache(AppName)) + a.readConfig() if err := a.initMPV(); err != nil { diff --git a/ui/albumgrid.go b/ui/albumgrid.go index 4b5de41..6740c26 100644 --- a/ui/albumgrid.go +++ b/ui/albumgrid.go @@ -3,6 +3,7 @@ package ui import ( "context" "image" + "log" "supersonic/backend" "supersonic/ui/widgets" @@ -21,41 +22,35 @@ type AlbumGrid struct { iter backend.AlbumIterator fetching bool done bool + showYear bool - imageFetcher ImageFetcher - OnPlayAlbum func(string) + imageFetcher ImageFetcher + OnPlayAlbum func(string) + OnShowArtistPage func(string) } var _ fyne.Widget = (*AlbumGrid)(nil) -func NewAlbumGrid(iter backend.AlbumIterator, fetch ImageFetcher) *AlbumGrid { +func NewFixedAlbumGrid(albums []*subsonic.AlbumID3, fetch ImageFetcher, showYear bool) *AlbumGrid { + ag := &AlbumGrid{ + albums: albums, + done: true, + imageFetcher: fetch, + showYear: showYear, + } + ag.ExtendBaseWidget(ag) + ag.createGridWrapList() + return ag +} + +func NewAlbumGrid(iter backend.AlbumIterator, fetch ImageFetcher, showYear bool) *AlbumGrid { ag := &AlbumGrid{ iter: iter, imageFetcher: fetch, } ag.ExtendBaseWidget(ag) - g := widget.NewGridWrapList( - func() int { - return len(ag.albums) - }, - // create func - func() fyne.CanvasObject { - ac := widgets.NewAlbumCard() - ac.OnPlay = func() { - if ag.OnPlayAlbum != nil { - ag.OnPlayAlbum(ac.AlbumID()) - } - } - return ac - }, - // update func - func(itemID int, obj fyne.CanvasObject) { - ac := obj.(*widgets.AlbumCard) - ag.doUpdateAlbumCard(itemID, ac) - }, - ) - ag.grid = g + ag.createGridWrapList() // fetch initial albums ag.fetchMoreAlbums(36) @@ -75,6 +70,35 @@ func (ag *AlbumGrid) Reset(iter backend.AlbumIterator) { ag.fetchMoreAlbums(36) } +func (ag *AlbumGrid) createGridWrapList() { + g := widget.NewGridWrapList( + func() int { + return len(ag.albums) + }, + // create func + func() fyne.CanvasObject { + ac := widgets.NewAlbumCard(ag.showYear) + ac.OnPlay = func() { + if ag.OnPlayAlbum != nil { + ag.OnPlayAlbum(ac.AlbumID()) + } + } + ac.OnShowArtistPage = func() { + if ag.OnShowArtistPage != nil { + ag.OnShowArtistPage(ac.ArtistID()) + } + } + return ac + }, + // update func + func(itemID int, obj fyne.CanvasObject) { + ac := obj.(*widgets.AlbumCard) + ag.doUpdateAlbumCard(itemID, ac) + }, + ) + ag.grid = g +} + func (ag *AlbumGrid) doUpdateAlbumCard(albumIdx int, ac *widgets.AlbumCard) { album := ag.albums[albumIdx] if ac.PrevAlbumID == album.ID { @@ -99,6 +123,8 @@ func (ag *AlbumGrid) doUpdateAlbumCard(albumIdx int, ac *widgets.AlbumCard) { if err == nil { ac.Cover.SetImage(i) ac.Cover.Refresh() + } else { + log.Printf("error fetching image: %s", err.Error()) } } }(ctx) diff --git a/ui/albumspage.go b/ui/albumspage.go index e891459..3d4f3ae 100644 --- a/ui/albumspage.go +++ b/ui/albumspage.go @@ -18,6 +18,7 @@ type AlbumsPage struct { im *backend.ImageManager lm *backend.LibraryManager + nav func(Route) grid *AlbumGrid searchGrid *AlbumGrid searchText string @@ -48,10 +49,11 @@ func (s *selectWidget) MinSize() fyne.Size { return fyne.NewSize(170, s.height) } -func NewAlbumsPage(title string, lm *backend.LibraryManager, im *backend.ImageManager) *AlbumsPage { +func NewAlbumsPage(title string, sortOrder string, lm *backend.LibraryManager, im *backend.ImageManager, nav func(Route)) *AlbumsPage { a := &AlbumsPage{ - lm: lm, - im: im, + lm: lm, + im: im, + nav: nav, } a.ExtendBaseWidget(a) @@ -60,11 +62,13 @@ func NewAlbumsPage(title string, lm *backend.LibraryManager, im *backend.ImageMa SizeName: theme.SizeNameHeadingText, } a.sortOrder = NewSelect(backend.AlbumSortOrders, nil) - a.sortOrder.Selected = a.sortOrder.Options[0] + a.sortOrder.Selected = sortOrder a.sortOrder.OnChanged = a.onSortOrderChanged sortVbox := container.NewVBox(layout.NewSpacer(), a.sortOrder, layout.NewSpacer()) - a.grid = NewAlbumGrid(lm.AlbumsIter(backend.AlbumSortOrder(a.sortOrder.Selected)), im.GetAlbumThumbnail) + iter := lm.AlbumsIter(backend.AlbumSortOrder(a.sortOrder.Selected)) + a.grid = NewAlbumGrid(iter, im.GetAlbumThumbnail, false /*showYear*/) a.grid.OnPlayAlbum = a.onPlayAlbum + a.grid.OnShowArtistPage = a.onShowArtistPage a.container = container.NewBorder( container.NewHBox(widgets.NewHSpace(15), a.titleDisp, sortVbox), nil, @@ -86,8 +90,9 @@ func (a *AlbumsPage) OnSearched(query string) { return } if a.searchGrid == nil { - a.searchGrid = NewAlbumGrid(a.lm.SearchIter(query), a.im.GetAlbumThumbnail) + a.searchGrid = NewAlbumGrid(a.lm.SearchIter(query), a.im.GetAlbumThumbnail, false /*showYear*/) a.searchGrid.OnPlayAlbum = a.onPlayAlbum + a.searchGrid.OnShowArtistPage = a.onShowArtistPage } else { a.searchGrid.Reset(a.lm.SearchIter(query)) } @@ -95,12 +100,20 @@ func (a *AlbumsPage) OnSearched(query string) { a.Refresh() } +func (a *AlbumsPage) SetPlayAlbumCallback(cb func(string)) { + a.OnPlayAlbum = cb +} + func (a *AlbumsPage) onPlayAlbum(albumID string) { if a.OnPlayAlbum != nil { a.OnPlayAlbum(albumID) } } +func (a *AlbumsPage) onShowArtistPage(artistID string) { + a.nav(ArtistRoute(artistID)) +} + func (a *AlbumsPage) onSortOrderChanged(order string) { a.grid.Reset(a.lm.AlbumsIter(backend.AlbumSortOrder(order))) if a.searchText == "" { diff --git a/ui/artistpage.go b/ui/artistpage.go new file mode 100644 index 0000000..494b8ff --- /dev/null +++ b/ui/artistpage.go @@ -0,0 +1,73 @@ +package ui + +import ( + "log" + "supersonic/backend" + + "fyne.io/fyne/v2" + "fyne.io/fyne/v2/container" + "fyne.io/fyne/v2/layout" + "fyne.io/fyne/v2/theme" + "fyne.io/fyne/v2/widget" +) + +var _ fyne.Widget = (*ArtistPage)(nil) + +type ArtistPage struct { + widget.BaseWidget + + artistID string + im *backend.ImageManager + sm *backend.ServerManager + grid *AlbumGrid + titleDisp *widget.RichText + container *fyne.Container + OnPlayAlbum func(string) +} + +func NewArtistPage(artistID string, sm *backend.ServerManager, im *backend.ImageManager) *ArtistPage { + a := &ArtistPage{ + artistID: artistID, + sm: sm, + im: im, + } + a.ExtendBaseWidget(a) + a.titleDisp = widget.NewRichTextWithText("Artist") + a.titleDisp.Segments[0].(*widget.TextSegment).Style = widget.RichTextStyle{ + SizeName: theme.SizeNameHeadingText, + } + a.container = container.NewBorder(a.titleDisp, nil, nil, nil, layout.NewSpacer()) + a.loadAsync() + return a +} + +func (a *ArtistPage) SetPlayAlbumCallback(cb func(string)) { + a.OnPlayAlbum = cb +} + +func (a *ArtistPage) onPlayAlbum(albumID string) { + if a.OnPlayAlbum != nil { + a.OnPlayAlbum(albumID) + } +} + +func (a *ArtistPage) loadAsync() { + go func() { + artist, err := a.sm.Server.GetArtist(a.artistID) + if err != nil { + log.Printf("Failed to get artist: %s", err.Error()) + return + } + a.titleDisp.Segments[0].(*widget.TextSegment).Text = artist.Name + a.titleDisp.Refresh() + ag := NewFixedAlbumGrid(artist.Album, a.im.GetAlbumThumbnail, true /*showYear*/) + ag.OnPlayAlbum = a.onPlayAlbum + a.container.Objects[0] = ag + a.container.Refresh() + }() +} + +func (a *ArtistPage) CreateRenderer() fyne.WidgetRenderer { + a.ExtendBaseWidget(a) + return widget.NewSimpleRenderer(a.container) +} diff --git a/ui/browsingpane.go b/ui/browsingpane.go index 110a97e..f97883c 100644 --- a/ui/browsingpane.go +++ b/ui/browsingpane.go @@ -1,12 +1,14 @@ package ui import ( + "supersonic/backend" "supersonic/ui/widgets" "sync" "time" "fyne.io/fyne/v2" "fyne.io/fyne/v2/container" + "fyne.io/fyne/v2/theme" "fyne.io/fyne/v2/widget" ) @@ -18,14 +20,26 @@ type Searchable interface { OnSearched(string) } +type CanPlayAlbum interface { + SetPlayAlbumCallback(func(albumID string)) +} + type BrowsingPane struct { widget.BaseWidget + app *backend.App + + curPage Page + + forward *widget.Button + back *widget.Button + history []Page + historyIdx int + searchBar *widgets.SearchEntry pendingSearchLock sync.Mutex pendingSearch bool searchGoroutine bool - curPage Page container *fyne.Container } @@ -34,24 +48,58 @@ type blankPage struct { widget.Separator } -func NewBrowsingPane() *BrowsingPane { - b := &BrowsingPane{} +func NewBrowsingPane(app *backend.App) *BrowsingPane { + b := &BrowsingPane{app: app} b.ExtendBaseWidget(b) b.searchBar = widgets.NewSearchEntry() b.searchBar.OnTextChanged = b.onSearchTextChanged + b.back = widget.NewButtonWithIcon("", theme.NavigateBackIcon(), b.GoBack) + b.forward = widget.NewButtonWithIcon("", theme.NavigateNextIcon(), b.GoForward) b.curPage = &blankPage{} b.container = container.NewBorder( - container.NewHBox(widgets.NewHSpace(15), b.searchBar), + container.NewHBox(b.back, b.forward, b.searchBar), nil, nil, nil, b.curPage) return b } func (b *BrowsingPane) SetPage(p Page) { + b.addPageToHistory(p) + b.doSetPage(p) +} + +func (b *BrowsingPane) doSetPage(p Page) { b.curPage = p + if pa, ok := p.(CanPlayAlbum); ok { + pa.SetPlayAlbumCallback(func(albumID string) { + _ = b.app.PlaybackManager.PlayAlbum(albumID) + }) + } + _, s := p.(Searchable) + b.searchBar.Hidden = !s b.container.Objects[0] = p b.Refresh() } +func (b *BrowsingPane) addPageToHistory(p Page) { + b.history = b.history[:b.historyIdx] + b.history = append(b.history, p) + b.historyIdx++ +} + +func (b *BrowsingPane) GoBack() { + if b.historyIdx > 1 { + b.historyIdx -= 1 + b.doSetPage(b.history[b.historyIdx-1]) + } +} + +func (b *BrowsingPane) GoForward() { + if b.historyIdx < len(b.history) { + b.historyIdx++ + b.doSetPage(b.history[b.historyIdx-1]) + } +} + func (b *BrowsingPane) onSearchTextChanged(text string) { if text == "" { b.sendSearch("") diff --git a/ui/mainwindow.go b/ui/mainwindow.go index 686637f..81f79e4 100644 --- a/ui/mainwindow.go +++ b/ui/mainwindow.go @@ -13,6 +13,7 @@ import ( type MainWindow struct { Window fyne.Window + Router Router BrowsingPane *BrowsingPane BottomPanel *BottomPanel @@ -22,9 +23,10 @@ type MainWindow struct { func NewMainWindow(fyneApp fyne.App, appName string, app *backend.App) MainWindow { m := MainWindow{ Window: fyneApp.NewWindow(appName), - BrowsingPane: NewBrowsingPane(), + BrowsingPane: NewBrowsingPane(app), BottomPanel: NewBottomPanel(app.Player), } + m.Router = NewRouter(app, m.BrowsingPane) m.BottomPanel.SetPlaybackManager(app.PlaybackManager) m.BottomPanel.ImageManager = app.ImageManager m.container = container.NewBorder(nil, m.BottomPanel, nil, nil, m.BrowsingPane) @@ -38,11 +40,7 @@ func NewMainWindow(fyneApp fyne.App, appName string, app *backend.App) MainWindo m.Window.SetTitle(song.Title) }) app.ServerManager.OnServerConnected(func() { - ap := NewAlbumsPage("Albums", app.LibraryManager, app.ImageManager) - ap.OnPlayAlbum = func(albumID string) { - _ = app.PlaybackManager.PlayAlbum(albumID) - } - m.BrowsingPane.SetPage(ap) + m.Router.OpenRoute(AlbumsRoute(backend.AlbumSortRecentlyAdded)) }) return m } diff --git a/ui/router.go b/ui/router.go new file mode 100644 index 0000000..0ea6280 --- /dev/null +++ b/ui/router.go @@ -0,0 +1,58 @@ +package ui + +import "supersonic/backend" + +type PageName int + +const ( + Blank PageName = iota + Album + Albums + Artist + Artists + Playlist + Playlists +) + +type Route struct { + Page PageName + Arg string +} + +func AlbumsRoute(sortOrder backend.AlbumSortOrder) Route { + return Route{Page: Albums, Arg: string(sortOrder)} +} + +func ArtistRoute(artistID string) Route { + return Route{Page: Artist, Arg: artistID} +} + +type NavigationHandler interface { + SetPage(Page) +} + +type Router struct { + App *backend.App + Nav NavigationHandler +} + +func NewRouter(app *backend.App, nav NavigationHandler) Router { + return Router{ + App: app, + Nav: nav, + } +} + +func (r Router) CreatePage(rte Route) Page { + switch rte.Page { + case Albums: + return NewAlbumsPage("Albums", rte.Arg, r.App.LibraryManager, r.App.ImageManager, r.OpenRoute) + case Artist: + return NewArtistPage(rte.Arg, r.App.ServerManager, r.App.ImageManager) + } + return nil +} + +func (r Router) OpenRoute(rte Route) { + r.Nav.SetPage(r.CreatePage(rte)) +} diff --git a/ui/widgets/albumcard.go b/ui/widgets/albumcard.go index 354dce8..8502422 100644 --- a/ui/widgets/albumcard.go +++ b/ui/widgets/albumcard.go @@ -3,6 +3,7 @@ package widgets import ( "context" "image" + "strconv" "supersonic/ui/layout" @@ -62,10 +63,14 @@ type AlbumCard struct { widget.BaseWidget albumID string + artistID string title *widget.Label - artist *widget.Label + artist *CustomHyperlink + year *widget.Label container *fyne.Container + showYear bool + // updated by AlbumGrid Cover *albumCover @@ -73,7 +78,9 @@ type AlbumCard struct { PrevAlbumID string ImgLoadCancel context.CancelFunc - OnPlay func() + OnPlay func() + OnShowAlbumPage func() + OnShowArtistPage func() } func (a *AlbumCard) MouseIn(*desktop.MouseEvent) {} @@ -82,11 +89,13 @@ func (a *AlbumCard) MouseOut() {} func (a *AlbumCard) MouseMoved(*desktop.MouseEvent) {} -func NewAlbumCard() *AlbumCard { +func NewAlbumCard(showYear bool) *AlbumCard { a := &AlbumCard{ - title: widget.NewLabel(""), - artist: widget.NewLabel(""), - Cover: newAlbumCover(), + title: widget.NewLabel(""), + artist: NewCustomHyperlink(), + year: widget.NewLabel(""), + Cover: newAlbumCover(), + showYear: showYear, } a.ExtendBaseWidget(a) a.Cover.OnDoubleTapped = func() { @@ -94,8 +103,12 @@ func NewAlbumCard() *AlbumCard { a.OnPlay() } } + a.artist.OnTapped = func() { + if a.OnShowArtistPage != nil { + a.OnShowArtistPage() + } + } a.title.Wrapping = fyne.TextTruncate - a.artist.Wrapping = fyne.TextTruncate a.title.TextStyle = fyne.TextStyle{Bold: true} a.createContainer() @@ -103,8 +116,12 @@ func NewAlbumCard() *AlbumCard { } func (a *AlbumCard) createContainer() { - titleArtist := container.New(&layout.VboxCustomPadding{ExtraPad: -16}, a.title, a.artist) - c := container.New(&layout.VboxCustomPadding{ExtraPad: -5}, a.Cover, titleArtist) + var secondLabel fyne.Widget = a.artist + if a.showYear { + secondLabel = a.year + } + info := container.New(&layout.VboxCustomPadding{ExtraPad: -16}, a.title, secondLabel) + c := container.New(&layout.VboxCustomPadding{ExtraPad: -5}, a.Cover, info) pad := &layout.CenterPadLayout{PadLeftRight: 20, PadTopBottom: 10} a.container = container.New(pad, c) } @@ -112,13 +129,19 @@ func (a *AlbumCard) createContainer() { func (a *AlbumCard) Update(al *subsonic.AlbumID3) { a.title.SetText(al.Name) a.artist.SetText(al.Artist) + a.year.SetText(strconv.Itoa(al.Year)) a.albumID = al.ID + a.artistID = al.ArtistID } func (a *AlbumCard) AlbumID() string { return a.albumID } +func (a *AlbumCard) ArtistID() string { + return a.artistID +} + func (a *AlbumCard) CreateRenderer() fyne.WidgetRenderer { return widget.NewSimpleRenderer(a.container) } diff --git a/ui/widgets/customhyperlink.go b/ui/widgets/customhyperlink.go new file mode 100644 index 0000000..99367fe --- /dev/null +++ b/ui/widgets/customhyperlink.go @@ -0,0 +1,80 @@ +package widgets + +import ( + "fyne.io/fyne/v2" + "fyne.io/fyne/v2/container" + "fyne.io/fyne/v2/layout" + "fyne.io/fyne/v2/widget" +) + +type hyperlinkWrapper struct { + widget.BaseWidget + + h *widget.Hyperlink + l *widget.Label + maxWidth float32 +} + +func newHyperlinkWrapper(maxWidth float32) *hyperlinkWrapper { + h := &hyperlinkWrapper{ + h: widget.NewHyperlink("", nil), + l: widget.NewLabel(""), + maxWidth: maxWidth, + } + h.h.Wrapping = fyne.TextTruncate + h.ExtendBaseWidget(h) + return h +} + +func (h *hyperlinkWrapper) MinSize() fyne.Size { + w := fyne.Min(h.maxWidth, h.l.MinSize().Width) + return fyne.NewSize(w, h.h.MinSize().Height) +} + +func (h *hyperlinkWrapper) SetText(text string) { + h.h.SetText(text) + h.l.SetText(text) +} + +func (h *hyperlinkWrapper) CreateRenderer() fyne.WidgetRenderer { + return widget.NewSimpleRenderer(h.h) +} + +type CustomHyperlink struct { + widget.BaseWidget + h *hyperlinkWrapper + + OnTapped func() + + container *fyne.Container + minSize fyne.Size +} + +func NewCustomHyperlink() *CustomHyperlink { + c := &CustomHyperlink{ + h: newHyperlinkWrapper(200), + } + c.h.h.OnTapped = func() { + if c.OnTapped != nil { + c.OnTapped() + } + } + c.ExtendBaseWidget(c) + c.container = container.NewHBox(c.h, layout.NewSpacer()) + return c +} + +func (c *CustomHyperlink) SetText(text string) { + s := widget.NewLabel(text).MinSize() + c.h.SetText(text) + c.minSize = fyne.NewSize(fyne.Min(c.Size().Width, s.Width), s.Height) + c.Refresh() +} + +func (c *CustomHyperlink) Refresh() { + c.container.Refresh() +} + +func (c *CustomHyperlink) CreateRenderer() fyne.WidgetRenderer { + return widget.NewSimpleRenderer(c.container) +}