diff --git a/ui/controller/controller.go b/ui/controller/controller.go index 84ef147..95d149c 100644 --- a/ui/controller/controller.go +++ b/ui/controller/controller.go @@ -322,49 +322,52 @@ func (m *Controller) PromptForFirstServer() { pop.Show() } -// Show dialog to prompt for playlist. +// Show dialog to select playlist. // Depending on the results of that dialog, potentially create a new playlist // Add tracks to the user-specified playlist func (m *Controller) DoAddTracksToPlaylistWorkflow(trackIDs []string) { - go func() { - pls, err := m.App.ServerManager.Server.GetPlaylists() - pls = sharedutil.FilterSlice(pls, func(pl *mediaprovider.Playlist) bool { - return pl.Owner == m.App.ServerManager.LoggedInUser - }) - if err != nil { - // TODO: surface this error to user - log.Printf("error getting user-owned playlists: %s", err.Error()) - return - } - - selectedIdx := -1 - plNames := make([]string, 0, len(pls)) - for i, pl := range pls { - plNames = append(plNames, pl.Name) - if defId := m.App.Config.Application.DefaultPlaylistID; defId != "" && pl.ID == defId { - selectedIdx = i - } - } - - dlg := dialogs.NewAddToPlaylistDialog("Add to Playlist", plNames, selectedIdx) - pop := widget.NewModalPopUp(dlg, m.MainWindow.Canvas()) - m.ClosePopUpOnEscape(pop) - dlg.OnCanceled = pop.Hide - dlg.OnSubmit = func(playlistChoice int, newPlaylistName string) { - pop.Hide() - m.doModalClosed() - if playlistChoice < 0 { - go m.App.ServerManager.Server.CreatePlaylist(newPlaylistName, trackIDs) + sp := dialogs.NewSelectPlaylistDialog(m.App.ServerManager.Server, m.App.ImageManager, m.App.ServerManager.LoggedInUser) + pop := widget.NewModalPopUp(sp.SearchDialog, m.MainWindow.Canvas()) + sp.SetOnDismiss(func() { + pop.Hide() + m.doModalClosed() + }) + sp.SetOnNavigateTo(func(contentType mediaprovider.ContentType, id string) { + pop.Hide() + if id == "" /* creating new playlist */ { + go m.App.ServerManager.Server.CreatePlaylist(sp.SearchDialog.SearchQuery(), trackIDs) + } else { + m.App.Config.Application.DefaultPlaylistID = id + if sp.SkipDuplicates { + go func() { + currentTrackIDs := make(map[string]struct{}) + if selectedPlaylist, err := m.App.ServerManager.Server.GetPlaylist(id); err != nil { + log.Printf("error getting playlist: %s", err.Error()) + } else { + for _, track := range selectedPlaylist.Tracks { + currentTrackIDs[track.ID] = struct{}{} + } + filterTrackIDs := sharedutil.FilterSlice(trackIDs, func(trackID string) bool { + _, ok := currentTrackIDs[trackID] + return !ok + }) + m.App.ServerManager.Server.AddPlaylistTracks(id, filterTrackIDs) + } + }() } else { - playlist := pls[playlistChoice] - m.App.Config.Application.DefaultPlaylistID = playlist.ID - go m.App.ServerManager.Server.AddPlaylistTracks( - playlist.ID, trackIDs) + go m.App.ServerManager.Server.AddPlaylistTracks(id, trackIDs) } } - m.haveModal = true - pop.Show() - }() + + }) + m.ClosePopUpOnEscape(pop) + m.haveModal = true + min := sp.MinSize() + height := fyne.Max(min.Height, fyne.Min(min.Height*1.5, m.MainWindow.Canvas().Size().Height*0.7)) + sp.SearchDialog.Show() + pop.Resize(fyne.NewSize(min.Width, height)) + pop.Show() + m.MainWindow.Canvas().Focus(sp.GetSearchEntry()) } func (m *Controller) DoEditPlaylistWorkflow(playlist *mediaprovider.Playlist) { @@ -642,6 +645,7 @@ func (c *Controller) ShowQuickSearch() { c.haveModal = true min := qs.MinSize() height := fyne.Max(min.Height, fyne.Min(min.Height*1.5, c.MainWindow.Canvas().Size().Height*0.7)) + qs.SearchDialog.Show() pop.Resize(fyne.NewSize(min.Width, height)) pop.Show() c.MainWindow.Canvas().Focus(qs.GetSearchEntry()) diff --git a/ui/dialogs/addtoplaylistdialog.go b/ui/dialogs/addtoplaylistdialog.go deleted file mode 100644 index 0f8e16f..0000000 --- a/ui/dialogs/addtoplaylistdialog.go +++ /dev/null @@ -1,120 +0,0 @@ -package dialogs - -import ( - "time" - - "fyne.io/fyne/v2" - "fyne.io/fyne/v2/container" - "fyne.io/fyne/v2/layout" - "fyne.io/fyne/v2/widget" -) - -type AddToPlaylistDialog struct { - widget.BaseWidget - - OnCanceled func() - OnSubmit func(playlistChoice int, newPlaylistName string) - - playlistSelect *widget.Select - newPlaylistLabel *widget.Label - newPlaylistName *widget.Entry - okBtn *widget.Button - - container *fyne.Container -} - -var _ fyne.Widget = (*AddToPlaylistDialog)(nil) - -func NewAddToPlaylistDialog(title string, existingPlaylistNames []string, selectedIdx int) *AddToPlaylistDialog { - a := &AddToPlaylistDialog{} - a.ExtendBaseWidget(a) - - titleLabel := widget.NewLabel(title) - titleLabel.TextStyle.Bold = true - options := []string{"New playlist..."} - options = append(options, existingPlaylistNames...) - a.playlistSelect = widget.NewSelect(options, func(_ string) { - a.onSelectionChanged() - }) - a.playlistSelect.PlaceHolder = "(Choose playlist)" - if selectedIdx >= 0 { - // calling SetSelectedIndex before showing the Select crashes... - go func() { - time.Sleep(10 * time.Millisecond) - // add 1 to selectedIdx to account for "(Choose playlist)" entry - a.playlistSelect.SetSelectedIndex(selectedIdx + 1) - }() - } - a.newPlaylistName = widget.NewEntry() - a.newPlaylistName.Hidden = true - a.newPlaylistName.OnChanged = func(text string) { - if len(text) > 0 { - a.okBtn.Enable() - } else { - a.okBtn.Disable() - } - } - a.newPlaylistLabel = widget.NewLabel("Name") - a.newPlaylistLabel.Hidden = true - - a.okBtn = widget.NewButton("OK", a.onOK) - a.okBtn.Importance = widget.HighImportance - a.okBtn.Disable() - cancelBtn := widget.NewButton("Cancel", a.onCancel) - - a.container = container.NewVBox( - container.NewHBox(layout.NewSpacer(), titleLabel, layout.NewSpacer()), - container.New(layout.NewFormLayout(), - widget.NewLabel("Playlist"), - a.playlistSelect, - a.newPlaylistLabel, - a.newPlaylistName), - widget.NewSeparator(), - container.NewHBox(layout.NewSpacer(), a.okBtn, cancelBtn)) - - return a -} - -func (a *AddToPlaylistDialog) onOK() { - var newPlaylistName string - playlistChoice := -1 - if sel := a.playlistSelect.SelectedIndex(); sel == 0 { - newPlaylistName = a.newPlaylistName.Text - } else { - playlistChoice = sel - 1 - } - if a.OnSubmit != nil { - a.OnSubmit(playlistChoice, newPlaylistName) - } -} - -func (a *AddToPlaylistDialog) onSelectionChanged() { - if a.playlistSelect.SelectedIndex() == 0 { - a.newPlaylistName.Show() - a.newPlaylistLabel.Show() - if len(a.newPlaylistName.Text) == 0 { - a.okBtn.Disable() - } else { - a.okBtn.Enable() - } - } else { - a.newPlaylistName.Hide() - a.newPlaylistLabel.Hide() - a.okBtn.Enable() - } -} - -func (a *AddToPlaylistDialog) onCancel() { - if a.OnCanceled != nil { - a.OnCanceled() - } -} - -func (a *AddToPlaylistDialog) MinSize() fyne.Size { - a.ExtendBaseWidget(a) - return fyne.NewSize(300, a.container.MinSize().Height) -} - -func (a *AddToPlaylistDialog) CreateRenderer() fyne.WidgetRenderer { - return widget.NewSimpleRenderer(a.container) -} diff --git a/ui/dialogs/quicksearch.go b/ui/dialogs/quicksearch.go index 089a301..13d2381 100644 --- a/ui/dialogs/quicksearch.go +++ b/ui/dialogs/quicksearch.go @@ -1,12 +1,9 @@ package dialogs import ( - "fmt" "log" "fyne.io/fyne/v2" - "fyne.io/fyne/v2/theme" - "fyne.io/fyne/v2/widget" "github.com/dweymouth/supersonic/backend/mediaprovider" "github.com/dweymouth/supersonic/ui/util" ) @@ -17,18 +14,8 @@ type QuickSearch struct { } func NewQuickSearch(mp mediaprovider.MediaProvider, im util.ImageFetcher) *QuickSearch { - - q := &QuickSearch{ - mp: mp, - } - - sd := NewSearchDialog( - im, - "Quick Search", - q.onSearched, - q.onUpdateSearchResult, - ) - q.SearchDialog = sd + q := &QuickSearch{mp: mp} + q.SearchDialog = NewSearchDialog(im, "Quick Search", "Close", q.onSearched) return q } @@ -44,53 +31,6 @@ func (q *QuickSearch) onSearched(query string) []*mediaprovider.SearchResult { return results } -func (q *QuickSearch) onUpdateSearchResult(sr *searchResult, result *mediaprovider.SearchResult) { - - maybePluralize := func(s string, size int) string { - if size != 1 { - return s + "s" - } - return s - } - - var secondaryText string - switch result.Type { - case mediaprovider.ContentTypeAlbum: - secondaryText = result.ArtistName - case mediaprovider.ContentTypeArtist: - secondaryText = fmt.Sprintf("%d %s", result.Size, maybePluralize("album", result.Size)) - case mediaprovider.ContentTypeTrack: - secondaryText = result.ArtistName - case mediaprovider.ContentTypePlaylist: - secondaryText = fmt.Sprintf("%d %s", result.Size, maybePluralize("track", result.Size)) - case mediaprovider.ContentTypeGenre: - if result.Size > 0 { - secondaryText = fmt.Sprintf("%d %s", result.Size, maybePluralize("album", result.Size)) - } else { - secondaryText = "" - } - } - sr.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 != "" { - sr.secondary.Segments = append(sr.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}, - }, - ) - } - sr.secondary.Refresh() -} - func (q *QuickSearch) SetOnDismiss(onDismiss func()) { q.SearchDialog.OnDismiss = onDismiss } @@ -104,5 +44,5 @@ func (q *QuickSearch) MinSize() fyne.Size { } func (q *QuickSearch) GetSearchEntry() fyne.Focusable { - return q.SearchDialog.SearchEntry + return q.SearchDialog.GetSearchEntry() } diff --git a/ui/dialogs/searchdialog.go b/ui/dialogs/searchdialog.go index 2afa2fa..8f646e1 100644 --- a/ui/dialogs/searchdialog.go +++ b/ui/dialogs/searchdialog.go @@ -1,6 +1,7 @@ package dialogs import ( + "fmt" "image" "log" "sync" @@ -23,30 +24,36 @@ import ( type SearchDialog struct { widget.BaseWidget - SearchEntry fyne.Focusable // exported so it can be focused by the Controller + PlaceholderText string - imgSource util.ImageFetcher + // Additional item that can be placed to the left + // of the dismiss buttons + ActionItem fyne.CanvasObject + OnDismiss func() + OnNavigateTo func(mediaprovider.ContentType, string) + OnSearched func(string) []*mediaprovider.SearchResult + + imgSource util.ImageFetcher resultsMutex sync.RWMutex searchResults []*mediaprovider.SearchResult - loadingDots *widgets.LoadingDots - list *widget.List selectedIndex int - content *fyne.Container - - OnDismiss func() - OnNavigateTo func(mediaprovider.ContentType, string) - OnSearched func(string) []*mediaprovider.SearchResult - OnUpdateSearchResults func(*searchResult, *mediaprovider.SearchResult) + searchEntry *searchEntry + loadingDots *widgets.LoadingDots + list *widget.List + dialogTitle string + dismissText string + content *fyne.Container } -func NewSearchDialog(im util.ImageFetcher, placeholderTitle string, onSearched func(string) []*mediaprovider.SearchResult, onUpdateSearchResult func(*searchResult, *mediaprovider.SearchResult)) *SearchDialog { +func NewSearchDialog(im util.ImageFetcher, title, dismissBtn string, onSearched func(string) []*mediaprovider.SearchResult) *SearchDialog { sd := &SearchDialog{ - imgSource: im, - loadingDots: widgets.NewLoadingDots(), - OnSearched: onSearched, - OnUpdateSearchResults: onUpdateSearchResult, + imgSource: im, + loadingDots: widgets.NewLoadingDots(), + OnSearched: onSearched, + dialogTitle: title, + dismissText: dismissBtn, } sd.ExtendBaseWidget(sd) @@ -58,7 +65,7 @@ func NewSearchDialog(im util.ImageFetcher, placeholderTitle string, onSearched f se.OnTypedDown = sd.moveSelectionDown se.OnTypedUp = sd.moveSelectionUp se.OnTypedEscape = sd.onDismiss - sd.SearchEntry = se + sd.searchEntry = se sd.list = widget.NewList( func() int { sd.resultsMutex.RLock() @@ -75,23 +82,34 @@ func NewSearchDialog(im util.ImageFetcher, placeholderTitle string, onSearched f sd.resultsMutex.RUnlock() sr := co.(*searchResult) sr.index = lii - sd.update(sr, result) + sr.Update(result) }, ) - - dismissBtn := widget.NewButton("Close", sd.onDismiss) - title := widget.NewRichText(&widget.TextSegment{Text: placeholderTitle, Style: util.BoldRichTextStyle}) - title.Segments[0].(*widget.TextSegment).Style.Alignment = fyne.TextAlignCenter - sd.content = container.NewStack( - container.NewBorder( - container.NewVBox(title, se), - container.NewVBox(widget.NewSeparator(), container.NewHBox(layout.NewSpacer(), dismissBtn)), - nil, nil, sd.list), - container.NewCenter(sd.loadingDots), - ) return sd } +// GetSearchEntry returns the search Entry widget for focusing +func (sd *SearchDialog) GetSearchEntry() fyne.Focusable { + return sd.searchEntry +} + +// SearchQuery returns the current search query entered by the user +func (sd *SearchDialog) SearchQuery() string { + return sd.searchEntry.Text +} + +func (sd *SearchDialog) Show() { + sd.BaseWidget.Show() + go sd.onSearched("") +} + +func (sd *SearchDialog) Refresh() { + if sd.PlaceholderText != "" { + sd.searchEntry.SetPlaceHolder(sd.PlaceholderText) + } + sd.BaseWidget.Refresh() +} + func (sd *SearchDialog) onDismiss() { if sd.OnDismiss != nil { sd.OnDismiss() @@ -131,18 +149,7 @@ func (sd *SearchDialog) moveSelectionUp() { sd.list.Select(sd.selectedIndex) } -func (sd *SearchDialog) onSearched(query string) { - sd.loadingDots.Start() - var results []*mediaprovider.SearchResult - if query != "" { - res := sd.OnSearched(query) - if len(res) == 0 { - log.Println("No results matched the query.") - } else { - results = res - } - } - sd.loadingDots.Stop() +func (sd *SearchDialog) setResults(results []*mediaprovider.SearchResult) { sd.resultsMutex.Lock() sd.searchResults = results sd.resultsMutex.Unlock() @@ -152,7 +159,37 @@ func (sd *SearchDialog) onSearched(query string) { sd.list.Select(0) } +func (sd *SearchDialog) onSearched(query string) { + sd.loadingDots.Start() + var results []*mediaprovider.SearchResult + res := sd.OnSearched(query) + if len(res) == 0 { + log.Println("No results matched the query.") + } else { + results = res + } + sd.loadingDots.Stop() + sd.setResults(results) +} + func (sd *SearchDialog) CreateRenderer() fyne.WidgetRenderer { + dismissBtn := widget.NewButton(sd.dismissText, sd.onDismiss) + title := widget.NewRichText(&widget.TextSegment{Text: sd.dialogTitle, Style: util.BoldRichTextStyle}) + title.Segments[0].(*widget.TextSegment).Style.Alignment = fyne.TextAlignCenter + bottomRow := container.NewHBox() + if sd.ActionItem != nil { + bottomRow.Objects = []fyne.CanvasObject{sd.ActionItem, layout.NewSpacer(), dismissBtn} + } else { + bottomRow.Objects = []fyne.CanvasObject{layout.NewSpacer(), dismissBtn} + } + sd.content = container.NewStack( + container.NewBorder( + container.NewVBox(title, sd.searchEntry), + container.NewVBox(widget.NewSeparator(), bottomRow), + nil, nil, sd.list), + container.NewCenter(sd.loadingDots), + ) + return widget.NewSimpleRenderer(sd.content) } @@ -160,22 +197,6 @@ func (sd *SearchDialog) MinSize() fyne.Size { return fyne.NewSize(400, 350) } -func (sd *SearchDialog) update(sr *searchResult, result *mediaprovider.SearchResult) { - if result == nil { - return - } - if sr.contentType == result.Type && sr.id == result.ID { - return // nothing to do - } - sr.id = result.ID - sr.contentType = result.Type - sr.image.CenterIcon = placeholderIconForContentType(result.Type) - sr.imageLoader.Load(result.CoverID) - sr.title.SetText(result.Name) - - sd.OnUpdateSearchResults(sr, result) -} - func placeholderIconForContentType(c mediaprovider.ContentType) fyne.Resource { switch c { case mediaprovider.ContentTypeAlbum: @@ -231,6 +252,64 @@ func newSearchResult(parent *SearchDialog) *searchResult { return qs } +func (s *searchResult) Update(result *mediaprovider.SearchResult) { + if result == nil { + return + } + if s.contentType == result.Type && s.id == result.ID && s.title.Text == result.Name { + return // nothing to do + } + s.id = result.ID + s.contentType = result.Type + s.image.CenterIcon = placeholderIconForContentType(result.Type) + s.imageLoader.Load(result.CoverID) + s.title.SetText(result.Name) + + maybePluralize := func(s string, size int) string { + if size != 1 { + return s + "s" + } + return s + } + var secondaryText string + switch result.Type { + case mediaprovider.ContentTypeAlbum: + secondaryText = result.ArtistName + case mediaprovider.ContentTypeArtist: + secondaryText = fmt.Sprintf("%d %s", result.Size, maybePluralize("album", result.Size)) + case mediaprovider.ContentTypeTrack: + secondaryText = result.ArtistName + case mediaprovider.ContentTypePlaylist: + secondaryText = fmt.Sprintf("%d %s", result.Size, maybePluralize("track", result.Size)) + case mediaprovider.ContentTypeGenre: + if result.Size > 0 { + secondaryText = fmt.Sprintf("%d %s", result.Size, maybePluralize("album", result.Size)) + } else { + secondaryText = "" + } + } + 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 != "" { + 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}, + }, + ) + } + + s.secondary.Refresh() +} + func (q *searchResult) Tapped(_ *fyne.PointEvent) { q.parent.onSelected(q.index) } diff --git a/ui/dialogs/selectplaylist.go b/ui/dialogs/selectplaylist.go new file mode 100644 index 0000000..9032e0d --- /dev/null +++ b/ui/dialogs/selectplaylist.go @@ -0,0 +1,107 @@ +package dialogs + +import ( + // "fmt" + "fmt" + "log" + "strings" + + "fyne.io/fyne/v2" + "fyne.io/fyne/v2/data/binding" + "fyne.io/fyne/v2/widget" + "github.com/deluan/sanitize" + "github.com/dweymouth/supersonic/backend/mediaprovider" + "github.com/dweymouth/supersonic/sharedutil" + "github.com/dweymouth/supersonic/ui/util" +) + +type SelectPlaylist struct { + SearchDialog *SearchDialog + mp mediaprovider.MediaProvider + loggedInUser string + allPlaylistResuts []*mediaprovider.SearchResult + SkipDuplicates bool +} + +func NewSelectPlaylistDialog(mp mediaprovider.MediaProvider, im util.ImageFetcher, loggedInUser string) *SelectPlaylist { + sp := &SelectPlaylist{ + mp: mp, + loggedInUser: loggedInUser, + SkipDuplicates: false, + } + sd := NewSearchDialog( + im, + "Add to playlist", + "Cancel", + sp.onSearched, + ) + sd.ActionItem = widget.NewCheckWithData("Skip duplicate tracks", binding.BindBool(&sp.SkipDuplicates)) + sd.PlaceholderText = "Search playlists or new playlist name" + sp.SearchDialog = sd + return sp +} + +func (sp *SelectPlaylist) fetchUserOwnedPlaylists() { + playlists, err := sp.mp.GetPlaylists() + if err != nil { + // TODO: surface this error to user + log.Printf("error getting playlists: %s", err.Error()) + } + userPlaylists := sharedutil.FilterSlice(playlists, func(playlist *mediaprovider.Playlist) bool { + return playlist.Owner == sp.loggedInUser + }) + sp.allPlaylistResuts = sharedutil.MapSlice(userPlaylists, sp.playlistToSearchResult) +} + +func (sp *SelectPlaylist) playlistToSearchResult(playlist *mediaprovider.Playlist) *mediaprovider.SearchResult { + if playlist == nil { + return nil + } + return &mediaprovider.SearchResult{ + Name: playlist.Name, + ID: playlist.ID, + CoverID: playlist.CoverArtID, + Type: mediaprovider.ContentTypePlaylist, + Size: playlist.TrackCount, + ArtistName: playlist.Name, + } +} + +func (sp *SelectPlaylist) onSearched(query string) []*mediaprovider.SearchResult { + if sp.allPlaylistResuts == nil { + sp.fetchUserOwnedPlaylists() + } + var results []*mediaprovider.SearchResult + if query == "" { + results = sp.allPlaylistResuts + } else { + results = sharedutil.FilterSlice(sp.allPlaylistResuts, func(playlist *mediaprovider.SearchResult) bool { + return strings.Contains( + sanitize.Accents(strings.ToLower(playlist.Name)), + sanitize.Accents(strings.ToLower(query)), + ) + }) + results = append(results, &mediaprovider.SearchResult{ + Name: fmt.Sprintf("Create new playlist: %s", query), + Type: mediaprovider.ContentTypePlaylist, + }) + } + + return results +} + +func (sp *SelectPlaylist) SetOnDismiss(onDismiss func()) { + sp.SearchDialog.OnDismiss = onDismiss +} + +func (sp *SelectPlaylist) SetOnNavigateTo(onNavigateTo func(mediaprovider.ContentType, string)) { + sp.SearchDialog.OnNavigateTo = onNavigateTo +} + +func (sp *SelectPlaylist) MinSize() fyne.Size { + return sp.SearchDialog.MinSize() +} + +func (sp *SelectPlaylist) GetSearchEntry() fyne.Focusable { + return sp.SearchDialog.GetSearchEntry() +}