diff --git a/backend/playbackmanager.go b/backend/playbackmanager.go index ffe8956..fd22518 100644 --- a/backend/playbackmanager.go +++ b/backend/playbackmanager.go @@ -78,6 +78,10 @@ func NewPlaybackManager(ctx context.Context, s *ServerManager, p *player.Player) pm.startPollTimePos() }) + s.OnLogout(func() { + pm.StopAndClearPlayQueue() + }) + return pm } @@ -204,6 +208,14 @@ func (p *PlaybackManager) RemoveTracksFromQueue(trackIdxs []int) { } } +// Stop playback and clear the play queue. +func (p *PlaybackManager) StopAndClearPlayQueue() { + p.player.Stop() + p.player.ClearPlayQueue() + p.doUpdateTimePos() + p.playQueue = nil +} + func (p *PlaybackManager) checkScrobble(playDur time.Duration) { if len(p.playQueue) == 0 || p.nowPlayingIdx < 0 { return diff --git a/backend/servermanager.go b/backend/servermanager.go index f71f7e0..a5568b3 100644 --- a/backend/servermanager.go +++ b/backend/servermanager.go @@ -5,6 +5,7 @@ import ( "github.com/dweymouth/go-subsonic/subsonic" "github.com/google/uuid" + "github.com/zalando/go-keyring" ) type ServerManager struct { @@ -12,6 +13,7 @@ type ServerManager struct { Server *subsonic.Client onServerConnected []func() + onLogout []func() } func NewServerManager() *ServerManager { @@ -36,6 +38,29 @@ func (s *ServerManager) ConnectToServer(conf *ServerConfig, password string) err return nil } +func (s *ServerManager) Logout() { + if s.Server != nil { + keyring.Delete(AppName, s.ServerID.String()) + for _, cb := range s.onLogout { + cb() + } + s.Server = nil + s.ServerID = uuid.UUID{} + } +} + func (s *ServerManager) OnServerConnected(cb func()) { s.onServerConnected = append(s.onServerConnected, cb) } + +func (s *ServerManager) OnLogout(cb func()) { + s.onLogout = append(s.onLogout, cb) +} + +func (s *ServerManager) GetServerPassword(server *ServerConfig) (string, error) { + return keyring.Get(AppName, server.ID.String()) +} + +func (s *ServerManager) SetServerPassword(server *ServerConfig, password string) error { + return keyring.Set(AppName, server.ID.String(), password) +} diff --git a/main.go b/main.go index 5ec929c..d4c6974 100644 --- a/main.go +++ b/main.go @@ -10,7 +10,6 @@ import ( "fyne.io/fyne/v2" "fyne.io/fyne/v2/app" "github.com/20after4/configdir" - "github.com/zalando/go-keyring" ) const ( @@ -49,17 +48,9 @@ func main() { time.Sleep(250 * time.Millisecond) defaultServer := myApp.Config.GetDefaultServer() if defaultServer == nil { - mainWindow.PromptForFirstServer(func(nick, host, user, pass string) { - server := myApp.Config.AddServer(nick, host, user) - err := keyring.Set(appname, server.ID.String(), pass) - if err != nil { - log.Printf("error setting keyring credentials: %v", err) - // TODO: handle? - } - setupServer(myApp, server) - }) + mainWindow.Controller.PromptForFirstServer() } else { - setupServer(myApp, defaultServer) + mainWindow.Controller.DoConnectToServerWorkflow(defaultServer) } }() @@ -70,18 +61,8 @@ func main() { mainWindow.Window.Close() }) fyneApp.Run() + + // shutdown tasks myApp.Config.WriteConfigFile(configPath()) myApp.Shutdown() - -} - -func setupServer(app *backend.App, server *backend.ServerConfig) { - pass, err := keyring.Get(appname, server.ID.String()) - if err != nil { - log.Printf("error getting password from keyring: %v", err) - } - if err := app.ServerManager.ConnectToServer(server, pass); err != nil { - log.Printf("error connecting to server: %v", err) - // TODO: surface error to user - } } diff --git a/player/player.go b/player/player.go index 571104a..f8aabcb 100644 --- a/player/player.go +++ b/player/player.go @@ -159,6 +159,14 @@ func (p *Player) Stop() error { return err } +// Clears the play queue, except for the currently playing file. +func (p *Player) ClearPlayQueue() error { + if p.mpv == nil { + return ErrUnitialized + } + return p.mpv.Command([]string{"playlist-clear"}) +} + // Seeks within the currently playing track. // See MPV seek command documentation for more details. func (p *Player) Seek(target string, mode SeekMode) error { diff --git a/ui/browsing/albumpage.go b/ui/browsing/albumpage.go index 51ca39d..5abc2e7 100644 --- a/ui/browsing/albumpage.go +++ b/ui/browsing/albumpage.go @@ -61,6 +61,8 @@ func NewAlbumPage( a.ExtendBaseWidget(a) a.header = NewAlbumPageHeader(a) a.tracklist = widgets.NewTracklist(nil) + a.tracklist.SetVisibleColumns([]widgets.TracklistColumn{ + widgets.ColumnArtist, widgets.ColumnTime, widgets.ColumnPlays}) // connect tracklist actions a.tracklist.OnPlayTrackAt = a.onPlayTrackAt a.tracklist.OnAddToQueue = func(tracks []*subsonic.Child) { a.pm.LoadTracks(tracks, true) } diff --git a/ui/browsing/browsingpane.go b/ui/browsing/browsingpane.go index 8a6670a..eb31798 100644 --- a/ui/browsing/browsingpane.go +++ b/ui/browsing/browsingpane.go @@ -52,6 +52,8 @@ type BrowsingPane struct { history []SavedPage historyIdx int + settingsBtn *widget.Button + settingsMenu *fyne.Menu navBtnsContainer *fyne.Container pageContainer *fyne.Container container *fyne.Container @@ -65,27 +67,62 @@ func NewBrowsingPane(app *backend.App) *BrowsingPane { b.reload = widget.NewButtonWithIcon("", theme.ViewRefreshIcon(), b.Reload) b.app.PlaybackManager.OnSongChange(b.onSongChange) b.pageContainer = container.NewMax( + // TODO: get this color into the theme canvas.NewRectangle(color.RGBA{R: 24, G: 24, B: 24, A: 255}), layout.NewSpacer()) + b.settingsBtn = widget.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-theme.Padding()/2, + b.navBtnsContainer.MinSize().Height+theme.Padding())) + }) + b.settingsMenu = fyne.NewMenu("") b.navBtnsContainer = container.NewHBox() b.container = container.NewBorder( container.New(layouts.NewLeftMiddleRightLayout(0), - container.NewHBox(b.back, b.forward, b.reload), b.navBtnsContainer, layout.NewSpacer()), + container.NewHBox(b.back, b.forward, b.reload), b.navBtnsContainer, + container.NewHBox(layout.NewSpacer(), b.settingsBtn)), nil, nil, nil, b.pageContainer) return b } func (b *BrowsingPane) SetPage(p Page) { + if p == nil { + b.doSetPage(&blankPage{}) + return + } oldPage := b.curPage if b.doSetPage(p) && oldPage != nil { b.addPageToHistory(oldPage, true) } } +func (b *BrowsingPane) ClearHistory() { + b.history = nil + b.historyIdx = 0 +} + +func (b *BrowsingPane) AddSettingsMenuItem(label string, action func()) { + b.settingsMenu.Items = append(b.settingsMenu.Items, + fyne.NewMenuItem(label, action)) +} + func (b *BrowsingPane) AddNavigationButton(iconRes fyne.Resource, action func()) { b.navBtnsContainer.Add(widget.NewButtonWithIcon("", iconRes, action)) } +func (b *BrowsingPane) DisableNavigationButtons() { + for _, obj := range b.navBtnsContainer.Objects { + obj.(*widget.Button).Disable() + } +} + +func (b *BrowsingPane) EnableNavigationButtons() { + for _, obj := range b.navBtnsContainer.Objects { + obj.(*widget.Button).Enable() + } +} + func (b *BrowsingPane) GetSearchBarIfAny() fyne.Focusable { if s, ok := b.curPage.(Searchable); ok { return s.SearchWidget() @@ -162,3 +199,17 @@ func (b *BrowsingPane) Reload() { func (b *BrowsingPane) CreateRenderer() fyne.WidgetRenderer { return widget.NewSimpleRenderer(b.container) } + +type blankPage struct { + layout.Spacer +} + +var _ Page = (*blankPage)(nil) + +func (p *blankPage) Reload() {} + +func (p *blankPage) Route() Route { return Route{Page: Blank} } + +func (p *blankPage) Save() SavedPage { return p } + +func (p *blankPage) Restore() Page { return p } diff --git a/ui/browsing/nowplayingpage.go b/ui/browsing/nowplayingpage.go index 55f9df1..5b4c431 100644 --- a/ui/browsing/nowplayingpage.go +++ b/ui/browsing/nowplayingpage.go @@ -39,6 +39,8 @@ func NewNowPlayingPage( a := &NowPlayingPage{nowPlayingPageState: nowPlayingPageState{contr: contr, sm: sm, pm: pm, nav: nav}} a.ExtendBaseWidget(a) a.tracklist = widgets.NewTracklist(nil) + a.tracklist.SetVisibleColumns([]widgets.TracklistColumn{ + widgets.ColumnArtist, widgets.ColumnAlbum, widgets.ColumnTime}) a.tracklist.AutoNumber = true a.tracklist.DisablePlaybackMenu = true a.tracklist.OnPlayTrackAt = a.onPlayTrackAt diff --git a/ui/browsing/playlistpage.go b/ui/browsing/playlistpage.go index c41daf6..da02e03 100644 --- a/ui/browsing/playlistpage.go +++ b/ui/browsing/playlistpage.go @@ -49,6 +49,8 @@ func NewPlaylistPage( a.ExtendBaseWidget(a) a.header = NewPlaylistPageHeader(a) a.tracklist = widgets.NewTracklist(nil) + a.tracklist.SetVisibleColumns([]widgets.TracklistColumn{ + widgets.ColumnArtist, widgets.ColumnAlbum, widgets.ColumnTime, widgets.ColumnPlays}) a.tracklist.AutoNumber = true a.tracklist.AuxiliaryMenuItems = []*fyne.MenuItem{ fyne.NewMenuItem("Remove from playlist", a.onRemoveSelectedFromPlaylist), diff --git a/ui/controller/controller.go b/ui/controller/controller.go index 69dd137..b9fc176 100644 --- a/ui/controller/controller.go +++ b/ui/controller/controller.go @@ -38,6 +38,21 @@ func (m Controller) ShowPopUpImage(img image.Image) { )) } +func (m Controller) PromptForFirstServer() { + d := dialogs.NewAddEditServerDialog("Connect to Server", nil) + pop := widget.NewModalPopUp(d, m.MainWindow.Canvas()) + d.OnSubmit = func() { + pop.Hide() + server := m.App.Config.AddServer(d.Nickname, d.Host, d.Username) + if err := m.App.ServerManager.SetServerPassword(server, d.Password); err != nil { + log.Printf("error setting keyring credentials: %v", err) + // TODO: handle? + } + m.DoConnectToServerWorkflow(server) + } + pop.Show() +} + // Show dialog to prompt for playlist. // Depending on the results of that dialog, potentially create a new playlist // Add tracks to the user-specified playlist @@ -68,3 +83,53 @@ func (m Controller) DoAddTracksToPlaylistWorkflow(trackIDs []string) { } pop.Show() } + +func (c Controller) DoConnectToServerWorkflow(server *backend.ServerConfig) { + pass, err := c.App.ServerManager.GetServerPassword(server) + if err != nil { + log.Printf("error getting password from keyring: %v", err) + c.PromptForLogin() + } else { + c.tryConnectToServer(server, pass) + } +} + +func (m Controller) PromptForLogin() { + // TODO: this will need to be rewritten a bit when we support multi servers + // need to make sure the intended server is first in the list passed to NewLoginDialog + d := dialogs.NewLoginDialog(m.App.Config.Servers) + pop := widget.NewModalPopUp(d, m.MainWindow.Canvas()) + d.OnSubmit = func(server *backend.ServerConfig, password string) { + pop.Hide() + m.trySetPasswordAndConnectToServer(server, password) + } + d.OnEditServer = func(server *backend.ServerConfig) { + pop.Hide() + editD := dialogs.NewAddEditServerDialog("Edit server", server) + editPop := widget.NewModalPopUp(editD, m.MainWindow.Canvas()) + editD.OnSubmit = func() { + editPop.Hide() + server.Hostname = editD.Host + server.Nickname = editD.Nickname + server.Username = editD.Username + m.trySetPasswordAndConnectToServer(server, editD.Password) + } + editPop.Show() + } + pop.Show() +} + +func (c Controller) trySetPasswordAndConnectToServer(server *backend.ServerConfig, password string) { + if err := c.App.ServerManager.SetServerPassword(server, password); err != nil { + log.Printf("error setting keyring credentials: %v", err) + // TODO: handle? + } + c.tryConnectToServer(server, password) +} + +func (c Controller) tryConnectToServer(server *backend.ServerConfig, password string) { + if err := c.App.ServerManager.ConnectToServer(server, password); err != nil { + log.Printf("error connecting to server: %v", err) + // TODO: surface error to user + } +} diff --git a/ui/widgets/addserverform.go b/ui/dialogs/addeditserverdialog.go similarity index 73% rename from ui/widgets/addserverform.go rename to ui/dialogs/addeditserverdialog.go index 6c08579..a6e1bc5 100644 --- a/ui/widgets/addserverform.go +++ b/ui/dialogs/addeditserverdialog.go @@ -1,6 +1,8 @@ -package widgets +package dialogs import ( + "supersonic/backend" + "fyne.io/fyne/v2" "fyne.io/fyne/v2/container" "fyne.io/fyne/v2/data/binding" @@ -8,7 +10,7 @@ import ( "fyne.io/fyne/v2/widget" ) -type AddServerForm struct { +type AddEditServerDialog struct { widget.BaseWidget Nickname string @@ -20,11 +22,17 @@ type AddServerForm struct { container *fyne.Container } -var _ fyne.Widget = (*AddServerForm)(nil) +var _ fyne.Widget = (*AddEditServerDialog)(nil) -func NewAddServerForm(title string) *AddServerForm { - a := &AddServerForm{} +func NewAddEditServerDialog(title string, prefillServer *backend.ServerConfig) *AddEditServerDialog { + a := &AddEditServerDialog{} a.ExtendBaseWidget(a) + if prefillServer != nil { + a.Nickname = prefillServer.Nickname + a.Host = prefillServer.Hostname + a.Username = prefillServer.Username + } + titleLabel := widget.NewLabel(title) titleLabel.TextStyle.Bold = true nickField := widget.NewEntryWithData(binding.BindString(&a.Nickname)) @@ -59,11 +67,11 @@ func NewAddServerForm(title string) *AddServerForm { return a } -func (a *AddServerForm) MinSize() fyne.Size { +func (a *AddEditServerDialog) MinSize() fyne.Size { a.ExtendBaseWidget(a) return fyne.NewSize(300, a.container.MinSize().Height) } -func (a *AddServerForm) CreateRenderer() fyne.WidgetRenderer { +func (a *AddEditServerDialog) CreateRenderer() fyne.WidgetRenderer { return widget.NewSimpleRenderer(a.container) } diff --git a/ui/dialogs/logindialog.go b/ui/dialogs/logindialog.go new file mode 100644 index 0000000..3f6d714 --- /dev/null +++ b/ui/dialogs/logindialog.go @@ -0,0 +1,75 @@ +package dialogs + +import ( + "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" +) + +type LoginDialog struct { + widget.BaseWidget + + OnSubmit func(server *backend.ServerConfig, password string) + OnEditServer func(server *backend.ServerConfig) + + servers []*backend.ServerConfig + serverSelect *widget.Select + passField *widget.Entry + + container *fyne.Container +} + +var _ fyne.Widget = (*LoginDialog)(nil) + +func NewLoginDialog(servers []*backend.ServerConfig) *LoginDialog { + l := &LoginDialog{servers: servers} + l.ExtendBaseWidget(l) + titleLabel := widget.NewLabel("Login to Server") + titleLabel.TextStyle.Bold = true + serverNames := make([]string, len(servers)) + for i, s := range servers { + serverNames[i] = s.Nickname + } + l.serverSelect = widget.NewSelect(serverNames, func(_ string) {}) + l.serverSelect.SetSelectedIndex(0) + editBtn := widget.NewButtonWithIcon("", theme.DocumentCreateIcon(), l.onEditServer) + l.passField = widget.NewPasswordEntry() + okBtn := widget.NewButton("OK", l.onSubmit) + + l.container = container.NewVBox( + container.NewHBox(layout.NewSpacer(), titleLabel, layout.NewSpacer()), + container.New(layout.NewFormLayout(), + widget.NewLabel("Server"), + container.NewBorder(nil, nil, nil, editBtn, l.serverSelect), + widget.NewLabel("Password"), + l.passField), + widget.NewSeparator(), + container.NewHBox(layout.NewSpacer(), okBtn), + ) + return l +} + +func (l *LoginDialog) CreateRenderer() fyne.WidgetRenderer { + return widget.NewSimpleRenderer(l.container) +} + +func (l *LoginDialog) MinSize() fyne.Size { + l.ExtendBaseWidget(l) + return fyne.NewSize(300, l.container.MinSize().Height) +} + +func (l *LoginDialog) onSubmit() { + if l.OnSubmit != nil { + l.OnSubmit(l.servers[l.serverSelect.SelectedIndex()], l.passField.Text) + } +} + +func (l *LoginDialog) onEditServer() { + if l.OnEditServer != nil { + l.OnEditServer(l.servers[l.serverSelect.SelectedIndex()]) + } +} diff --git a/ui/layouts/columnslayout.go b/ui/layouts/columnslayout.go index bc40147..614915e 100644 --- a/ui/layouts/columnslayout.go +++ b/ui/layouts/columnslayout.go @@ -46,6 +46,9 @@ func (c *ColumnsLayout) Layout(objects []fyne.CanvasObject, size fyne.Size) { var x float32 for i := 0; i < len(objects); i++ { + if !objects[i].Visible() { + continue + } w := objects[i].MinSize().Width if i < len(c.ColumnWidths) && c.ColumnWidths[i] > w { w = c.ColumnWidths[i] diff --git a/ui/mainwindow.go b/ui/mainwindow.go index 44a5966..f394ea0 100644 --- a/ui/mainwindow.go +++ b/ui/mainwindow.go @@ -6,12 +6,10 @@ import ( "supersonic/ui/browsing" "supersonic/ui/controller" "supersonic/ui/os" - "supersonic/ui/widgets" "fyne.io/fyne/v2" "fyne.io/fyne/v2/container" "fyne.io/fyne/v2/driver/desktop" - "fyne.io/fyne/v2/widget" "github.com/dweymouth/go-subsonic/subsonic" ) @@ -64,23 +62,21 @@ func NewMainWindow(fyneApp fyne.App, appName string, app *backend.App, size fyne m.Window.SetTitle(song.Title) }) app.ServerManager.OnServerConnected(func() { + m.BrowsingPane.EnableNavigationButtons() m.Router.OpenRoute(HomePage) }) + app.ServerManager.OnLogout(func() { + m.BrowsingPane.DisableNavigationButtons() + m.BrowsingPane.SetPage(nil) + m.BrowsingPane.ClearHistory() + m.Controller.PromptForLogin() + }) + m.BrowsingPane.AddSettingsMenuItem("Log Out", app.ServerManager.Logout) m.addNavigationButtons() m.addShortcuts() return m } -func (m *MainWindow) PromptForFirstServer(cb func(string, string, string, string)) { - d := widgets.NewAddServerForm("Connect to Server") - pop := widget.NewModalPopUp(d, m.Canvas()) - d.OnSubmit = func() { - pop.Hide() - cb(d.Nickname, d.Host, d.Username, d.Password) - } - pop.Show() -} - func (m *MainWindow) addNavigationButtons() { m.BrowsingPane.AddNavigationButton(res.ResHeadphonesInvertPng, func() { m.Router.OpenRoute(browsing.NowPlayingRoute()) diff --git a/ui/widgets/tracklist.go b/ui/widgets/tracklist.go index d7b14aa..9f7c29d 100644 --- a/ui/widgets/tracklist.go +++ b/ui/widgets/tracklist.go @@ -16,102 +16,15 @@ import ( "github.com/dweymouth/go-subsonic/subsonic" ) -type TrackRow struct { - widget.BaseWidget +type TracklistColumn string - // internal state - trackIdx int - trackID string - isPlaying bool - tappedAt int64 // unixMillis - - num *widget.RichText - name *widget.RichText - artist *widget.RichText - dur *widget.RichText - - OnTapped func() - OnDoubleTapped func() - OnTappedSecondary func(e *fyne.PointEvent, trackIdx int) - - playingIcon fyne.CanvasObject - selectionRect *canvas.Rectangle - container *fyne.Container -} - -func NewTrackRow(layout *layouts.ColumnsLayout, playingIcon fyne.CanvasObject) *TrackRow { - t := &TrackRow{playingIcon: playingIcon} - t.ExtendBaseWidget(t) - t.num = widget.NewRichTextWithText("") - t.num.Segments[0].(*widget.TextSegment).Style.Alignment = fyne.TextAlignTrailing - t.name = widget.NewRichTextWithText("") - t.name.Wrapping = fyne.TextTruncate - t.artist = widget.NewRichTextWithText("") - t.artist.Wrapping = fyne.TextTruncate - t.dur = widget.NewRichTextWithText("") - t.dur.Segments[0].(*widget.TextSegment).Style.Alignment = fyne.TextAlignTrailing - - t.selectionRect = canvas.NewRectangle(theme.SelectionColor()) - t.selectionRect.Hidden = true - t.container = container.NewMax(t.selectionRect, - container.New(layout, - t.num, t.name, t.artist, t.dur)) - return t -} - -func (t *TrackRow) Update(tr *subsonic.Child, isPlaying bool, rowNum int) { - if tr.ID == t.trackID && isPlaying == t.isPlaying { - return - } - t.isPlaying = isPlaying - t.trackID = tr.ID - - if rowNum < 0 { - rowNum = tr.Track - } - t.num.Segments[0].(*widget.TextSegment).Text = strconv.Itoa(rowNum) - t.name.Segments[0].(*widget.TextSegment).Text = tr.Title - t.artist.Segments[0].(*widget.TextSegment).Text = tr.Artist - t.dur.Segments[0].(*widget.TextSegment).Text = util.SecondsToTimeString(float64(tr.Duration)) - - t.name.Segments[0].(*widget.TextSegment).Style.TextStyle = fyne.TextStyle{Bold: isPlaying} - t.artist.Segments[0].(*widget.TextSegment).Style.TextStyle = fyne.TextStyle{Bold: isPlaying} - t.dur.Segments[0].(*widget.TextSegment).Style.TextStyle = fyne.TextStyle{Bold: isPlaying} - - if isPlaying { - t.container.Objects[1].(*fyne.Container).Objects[0] = container.NewCenter(t.playingIcon) - } else { - t.container.Objects[1].(*fyne.Container).Objects[0] = t.num - } - - t.Refresh() -} - -func (t *TrackRow) CreateRenderer() fyne.WidgetRenderer { - return widget.NewSimpleRenderer(t.container) -} - -// We implement our own double tapping so that the Tapped behavior -// can be triggered instantly. -func (t *TrackRow) Tapped(*fyne.PointEvent) { - prevTap := t.tappedAt - t.tappedAt = time.Now().UnixMilli() - if t.tappedAt-prevTap < 300 { - if t.OnDoubleTapped != nil { - t.OnDoubleTapped() - } - } else { - if t.OnTapped != nil { - t.OnTapped() - } - } -} - -func (t *TrackRow) TappedSecondary(e *fyne.PointEvent) { - if t.OnTappedSecondary != nil { - t.OnTappedSecondary(e, t.trackIdx) - } -} +const ( + ColumnArtist TracklistColumn = "Artist" + ColumnAlbum TracklistColumn = "Album" + ColumnTime TracklistColumn = "Time" + ColumnPlays TracklistColumn = "Plays" + ColumnBitrate TracklistColumn = "Bitrate" +) type Tracklist struct { widget.BaseWidget @@ -128,6 +41,8 @@ type Tracklist struct { OnAddToQueue func(trackIDs []*subsonic.Child) OnAddToPlaylist func(trackIDs []string) + visibleColumns []bool + selectionMgr util.ListSelectionManager nowPlayingIdx int colLayout *layouts.ColumnsLayout @@ -138,16 +53,19 @@ type Tracklist struct { } func NewTracklist(tracks []*subsonic.Child) *Tracklist { - t := &Tracklist{Tracks: tracks, nowPlayingIdx: -1} + t := &Tracklist{Tracks: tracks, nowPlayingIdx: -1, visibleColumns: make([]bool, 5)} + t.ExtendBaseWidget(t) t.selectionMgr = util.NewListSelectionManager(func() int { return len(t.Tracks) }) - t.colLayout = layouts.NewColumnsLayout([]float32{35, -1, -1, 60}) - t.hdr = NewListHeader([]ListColumn{{"#", true}, {"Title", false}, {"Artist", false}, {"Time", true}}, t.colLayout) + t.colLayout = layouts.NewColumnsLayout([]float32{35, -1, -1, -1, 60, 65, 75}) + t.hdr = NewListHeader([]ListColumn{ + {"#", true}, {"Title", false}, {"Artist", false}, {"Album", false}, {"Time", true}, {"Plays", true}, {"Bitrate", true}}, + t.colLayout) playingIcon := container.NewCenter(container.NewHBox(NewHSpace(2), widget.NewIcon(theme.MediaPlayIcon()))) t.list = widget.NewList( func() int { return len(t.Tracks) }, func() fyne.CanvasObject { - tr := NewTrackRow(t.colLayout, playingIcon) + tr := NewTrackRow(t, playingIcon) tr.OnTapped = func() { t.onSelectTrack(tr.trackIdx) } tr.OnTappedSecondary = t.onShowContextMenu tr.OnDoubleTapped = func() { t.onPlayTrackAt(tr.trackIdx) } @@ -167,6 +85,15 @@ func NewTracklist(tracks []*subsonic.Child) *Tracklist { return t } +func (t *Tracklist) SetVisibleColumns(cols []TracklistColumn) { + for i := range t.visibleColumns { + t.visibleColumns[i] = false + } + for _, col := range cols { + t.visibleColumns[col.ColNumber()] = true + } +} + func (t *Tracklist) SetNowPlaying(trackID string) { t.nowPlayingIdx = -1 for i, tr := range t.Tracks { @@ -188,6 +115,14 @@ func (t *Tracklist) UnselectAll() { t.Refresh() } +func (t *Tracklist) Refresh() { + for i, tf := range t.visibleColumns { + // first 2 columns are built-in and always visible + t.hdr.SetColumnVisible(i+2, tf) + } + t.BaseWidget.Refresh() +} + func (t *Tracklist) CreateRenderer() fyne.WidgetRenderer { return widget.NewSimpleRenderer(t.container) } @@ -267,3 +202,140 @@ func (t *Tracklist) selectedTrackIDs() []string { func (t *Tracklist) SelectedTrackIndexes() []int { return t.selectionMgr.GetSelection() } + +func (c TracklistColumn) ColNumber() int { + // built-in columns # and Title are always visible + switch c { + case ColumnArtist: + return 0 + case ColumnAlbum: + return 1 + case ColumnTime: + return 2 + case ColumnPlays: + return 3 + case ColumnBitrate: + return 4 + default: + return -100 + } +} + +type TrackRow struct { + widget.BaseWidget + + // internal state + tracklist *Tracklist + trackIdx int + trackID string + isPlaying bool + tappedAt int64 // unixMillis + + num *widget.RichText + name *widget.RichText + artist *widget.RichText + album *widget.RichText + dur *widget.RichText + bitrate *widget.RichText + plays *widget.RichText + + OnTapped func() + OnDoubleTapped func() + OnTappedSecondary func(e *fyne.PointEvent, trackIdx int) + + playingIcon fyne.CanvasObject + selectionRect *canvas.Rectangle + container *fyne.Container +} + +func NewTrackRow(tracklist *Tracklist, playingIcon fyne.CanvasObject) *TrackRow { + t := &TrackRow{tracklist: tracklist, playingIcon: playingIcon} + t.ExtendBaseWidget(t) + t.num = widget.NewRichTextWithText("") + t.num.Segments[0].(*widget.TextSegment).Style.Alignment = fyne.TextAlignTrailing + t.name = widget.NewRichTextWithText("") + t.name.Wrapping = fyne.TextTruncate + t.artist = widget.NewRichTextWithText("") + t.artist.Wrapping = fyne.TextTruncate + t.album = widget.NewRichTextWithText("") + t.album.Wrapping = fyne.TextTruncate + t.dur = widget.NewRichTextWithText("") + t.dur.Segments[0].(*widget.TextSegment).Style.Alignment = fyne.TextAlignTrailing + t.plays = widget.NewRichTextWithText("") + t.plays.Segments[0].(*widget.TextSegment).Style.Alignment = fyne.TextAlignTrailing + t.bitrate = widget.NewRichTextWithText("") + t.bitrate.Segments[0].(*widget.TextSegment).Style.Alignment = fyne.TextAlignTrailing + + t.selectionRect = canvas.NewRectangle(theme.SelectionColor()) + t.selectionRect.Hidden = true + t.container = container.NewMax(t.selectionRect, + container.New(tracklist.colLayout, + t.num, t.name, t.artist, t.album, t.dur, t.plays, t.bitrate)) + return t +} + +func (t *TrackRow) Update(tr *subsonic.Child, isPlaying bool, rowNum int) { + if tr.ID == t.trackID && isPlaying == t.isPlaying { + return + } + t.isPlaying = isPlaying + t.trackID = tr.ID + + if rowNum < 0 { + rowNum = tr.Track + } + t.num.Segments[0].(*widget.TextSegment).Text = strconv.Itoa(rowNum) + t.name.Segments[0].(*widget.TextSegment).Text = tr.Title + t.artist.Segments[0].(*widget.TextSegment).Text = tr.Artist + t.album.Segments[0].(*widget.TextSegment).Text = tr.Album + t.dur.Segments[0].(*widget.TextSegment).Text = util.SecondsToTimeString(float64(tr.Duration)) + t.plays.Segments[0].(*widget.TextSegment).Text = strconv.Itoa(int(tr.PlayCount)) + t.bitrate.Segments[0].(*widget.TextSegment).Text = strconv.Itoa(tr.BitRate) + + t.name.Segments[0].(*widget.TextSegment).Style.TextStyle.Bold = isPlaying + t.artist.Segments[0].(*widget.TextSegment).Style.TextStyle.Bold = isPlaying + t.album.Segments[0].(*widget.TextSegment).Style.TextStyle.Bold = isPlaying + t.dur.Segments[0].(*widget.TextSegment).Style.TextStyle.Bold = isPlaying + t.plays.Segments[0].(*widget.TextSegment).Style.TextStyle.Bold = isPlaying + t.bitrate.Segments[0].(*widget.TextSegment).Style.TextStyle.Bold = isPlaying + + t.artist.Hidden = !t.tracklist.visibleColumns[ColumnArtist.ColNumber()] + t.album.Hidden = !t.tracklist.visibleColumns[ColumnAlbum.ColNumber()] + t.dur.Hidden = !t.tracklist.visibleColumns[ColumnTime.ColNumber()] + t.plays.Hidden = !t.tracklist.visibleColumns[ColumnPlays.ColNumber()] + t.bitrate.Hidden = !t.tracklist.visibleColumns[ColumnBitrate.ColNumber()] + + if isPlaying { + t.container.Objects[1].(*fyne.Container).Objects[0] = container.NewCenter(t.playingIcon) + } else { + t.container.Objects[1].(*fyne.Container).Objects[0] = t.num + } + + t.Refresh() +} + +func (t *TrackRow) CreateRenderer() fyne.WidgetRenderer { + return widget.NewSimpleRenderer(t.container) +} + +// We implement our own double tapping so that the Tapped behavior +// can be triggered instantly. +func (t *TrackRow) Tapped(*fyne.PointEvent) { + prevTap := t.tappedAt + t.tappedAt = time.Now().UnixMilli() + if t.tappedAt-prevTap < 300 { + if t.OnDoubleTapped != nil { + t.OnDoubleTapped() + } + } else { + if t.OnTapped != nil { + t.OnTapped() + } + } +} + +func (t *TrackRow) TappedSecondary(e *fyne.PointEvent) { + if t.OnTappedSecondary != nil { + t.OnTappedSecondary(e, t.trackIdx) + } +}