Merge pull request #382 from natilou/new-add-to-playlist

chore: Improve 'Add to playlists' dialog
This commit is contained in:
Drew Weymouth
2024-05-23 08:42:02 -07:00
committed by GitHub
5 changed files with 269 additions and 65 deletions
+1
View File
@@ -179,4 +179,5 @@ type SearchResult struct {
// Unset for ContentTypes Artist, Playlist, and Genre // Unset for ContentTypes Artist, Playlist, and Genre
ArtistName string ArtistName string
Query string
} }
+43 -36
View File
@@ -11,6 +11,7 @@ import (
"net/url" "net/url"
"os" "os"
"path/filepath" "path/filepath"
"slices"
"time" "time"
"github.com/dweymouth/supersonic/backend" "github.com/dweymouth/supersonic/backend"
@@ -322,49 +323,54 @@ func (m *Controller) PromptForFirstServer() {
pop.Show() 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 // Depending on the results of that dialog, potentially create a new playlist
// Add tracks to the user-specified playlist // Add tracks to the user-specified playlist
func (m *Controller) DoAddTracksToPlaylistWorkflow(trackIDs []string) { func (m *Controller) DoAddTracksToPlaylistWorkflow(trackIDs []string) {
go func() { sp := dialogs.NewSelectPlaylistDialog(m.App.ServerManager.Server, m.App.ImageManager, m.App.ServerManager.LoggedInUser)
pls, err := m.App.ServerManager.Server.GetPlaylists() pop := widget.NewModalPopUp(sp.SearchDialog, m.MainWindow.Canvas())
pls = sharedutil.FilterSlice(pls, func(pl *mediaprovider.Playlist) bool { sp.SetOnDismiss(func() {
return pl.Owner == m.App.ServerManager.LoggedInUser pop.Hide()
}) m.doModalClosed()
if err != nil { })
// TODO: surface this error to user sp.SetOnNavigateTo(func(contentType mediaprovider.ContentType, id string, query string) {
log.Printf("error getting user-owned playlists: %s", err.Error()) pop.Hide()
return if id == "" {
} go m.App.ServerManager.Server.CreatePlaylist(query, trackIDs)
} else {
m.App.Config.Application.DefaultPlaylistID = id
if sp.SkipDuplicates {
var filterTrackIDs []string
go func() {
if selectedPlaylist, err := m.App.ServerManager.Server.GetPlaylist(id); err != nil {
log.Printf("error getting playlist: %s", err.Error())
} else {
var trackIDsInPaylist []string
selectedIdx := -1 for _, track := range selectedPlaylist.Tracks {
plNames := make([]string, 0, len(pls)) trackIDsInPaylist = append(trackIDsInPaylist, track.ID)
for i, pl := range pls { }
plNames = append(plNames, pl.Name) filterTrackIDs = sharedutil.FilterSlice(trackIDs, func(trackID string) bool {
if defId := m.App.Config.Application.DefaultPlaylistID; defId != "" && pl.ID == defId { return !slices.Contains(trackIDsInPaylist, trackID)
selectedIdx = i })
}
}
dlg := dialogs.NewAddToPlaylistDialog("Add to Playlist", plNames, selectedIdx) }
pop := widget.NewModalPopUp(dlg, m.MainWindow.Canvas()) m.App.ServerManager.Server.AddPlaylistTracks(id, filterTrackIDs)
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)
} else { } else {
playlist := pls[playlistChoice] go m.App.ServerManager.Server.AddPlaylistTracks(id, trackIDs)
m.App.Config.Application.DefaultPlaylistID = playlist.ID
go m.App.ServerManager.Server.AddPlaylistTracks(
playlist.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) { func (m *Controller) DoEditPlaylistWorkflow(playlist *mediaprovider.Playlist) {
@@ -622,7 +628,7 @@ func (c *Controller) ShowQuickSearch() {
pop.Hide() pop.Hide()
c.doModalClosed() c.doModalClosed()
}) })
qs.SetOnNavigateTo(func(contentType mediaprovider.ContentType, id string) { qs.SetOnNavigateTo(func(contentType mediaprovider.ContentType, id string, query string) {
pop.Hide() pop.Hide()
c.doModalClosed() c.doModalClosed()
switch contentType { switch contentType {
@@ -642,6 +648,7 @@ func (c *Controller) ShowQuickSearch() {
c.haveModal = true c.haveModal = true
min := qs.MinSize() min := qs.MinSize()
height := fyne.Max(min.Height, fyne.Min(min.Height*1.5, c.MainWindow.Canvas().Size().Height*0.7)) 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.Resize(fyne.NewSize(min.Width, height))
pop.Show() pop.Show()
c.MainWindow.Canvas().Focus(qs.GetSearchEntry()) c.MainWindow.Canvas().Focus(qs.GetSearchEntry())
+2 -1
View File
@@ -27,6 +27,7 @@ func NewQuickSearch(mp mediaprovider.MediaProvider, im util.ImageFetcher) *Quick
"Quick Search", "Quick Search",
q.onSearched, q.onSearched,
q.onUpdateSearchResult, q.onUpdateSearchResult,
nil,
) )
q.SearchDialog = sd q.SearchDialog = sd
return q return q
@@ -95,7 +96,7 @@ func (q *QuickSearch) SetOnDismiss(onDismiss func()) {
q.SearchDialog.OnDismiss = onDismiss q.SearchDialog.OnDismiss = onDismiss
} }
func (q *QuickSearch) SetOnNavigateTo(onNavigateTo func(mediaprovider.ContentType, string)) { func (q *QuickSearch) SetOnNavigateTo(onNavigateTo func(mediaprovider.ContentType, string, string)) {
q.SearchDialog.OnNavigateTo = onNavigateTo q.SearchDialog.OnNavigateTo = onNavigateTo
} }
+71 -28
View File
@@ -33,20 +33,24 @@ type SearchDialog struct {
list *widget.List list *widget.List
selectedIndex int selectedIndex int
content *fyne.Container placeholderTitle string
content *fyne.Container
OnDismiss func() OnDismiss func()
OnNavigateTo func(mediaprovider.ContentType, string) OnNavigateTo func(mediaprovider.ContentType, string, string)
OnSearched func(string) []*mediaprovider.SearchResult OnSearched func(string) []*mediaprovider.SearchResult
OnUpdateSearchResults func(*searchResult, *mediaprovider.SearchResult) OnUpdateSearchResults func(*searchResult, *mediaprovider.SearchResult)
OnInit func() ([]*mediaprovider.SearchResult, *widget.Check)
} }
func NewSearchDialog(im util.ImageFetcher, placeholderTitle string, onSearched func(string) []*mediaprovider.SearchResult, onUpdateSearchResult func(*searchResult, *mediaprovider.SearchResult)) *SearchDialog { func NewSearchDialog(im util.ImageFetcher, placeholderTitle string, onSearched func(string) []*mediaprovider.SearchResult, onUpdateSearchResult func(*searchResult, *mediaprovider.SearchResult), onInit func() ([]*mediaprovider.SearchResult, *widget.Check)) *SearchDialog {
sd := &SearchDialog{ sd := &SearchDialog{
imgSource: im, imgSource: im,
loadingDots: widgets.NewLoadingDots(), loadingDots: widgets.NewLoadingDots(),
OnSearched: onSearched, OnSearched: onSearched,
OnUpdateSearchResults: onUpdateSearchResult, OnUpdateSearchResults: onUpdateSearchResult,
OnInit: onInit,
placeholderTitle: placeholderTitle,
} }
sd.ExtendBaseWidget(sd) sd.ExtendBaseWidget(sd)
@@ -78,20 +82,14 @@ func NewSearchDialog(im util.ImageFetcher, placeholderTitle string, onSearched f
sd.update(sr, result) sd.update(sr, 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 return sd
} }
func (sd *SearchDialog) Show() {
sd.onInit()
sd.BaseWidget.Show()
}
func (sd *SearchDialog) onDismiss() { func (sd *SearchDialog) onDismiss() {
if sd.OnDismiss != nil { if sd.OnDismiss != nil {
sd.OnDismiss() sd.OnDismiss()
@@ -109,8 +107,9 @@ func (sd *SearchDialog) onSelected(idx int) {
} }
id := sd.searchResults[idx].ID id := sd.searchResults[idx].ID
typ := sd.searchResults[idx].Type typ := sd.searchResults[idx].Type
query := sd.searchResults[idx].Query
sd.resultsMutex.RUnlock() sd.resultsMutex.RUnlock()
sd.OnNavigateTo(typ, id) sd.OnNavigateTo(typ, id, query)
} }
func (sd *SearchDialog) moveSelectionDown() { func (sd *SearchDialog) moveSelectionDown() {
@@ -131,18 +130,7 @@ func (sd *SearchDialog) moveSelectionUp() {
sd.list.Select(sd.selectedIndex) sd.list.Select(sd.selectedIndex)
} }
func (sd *SearchDialog) onSearched(query string) { func (sd *SearchDialog) setResults(results []*mediaprovider.SearchResult) {
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()
sd.resultsMutex.Lock() sd.resultsMutex.Lock()
sd.searchResults = results sd.searchResults = results
sd.resultsMutex.Unlock() sd.resultsMutex.Unlock()
@@ -152,6 +140,61 @@ func (sd *SearchDialog) onSearched(query string) {
sd.list.Select(0) sd.list.Select(0)
} }
func (sd *SearchDialog) SetContent(checkBox *widget.Check) {
dismissBtn := widget.NewButton("Close", sd.onDismiss)
title := widget.NewRichText(&widget.TextSegment{Text: sd.placeholderTitle, Style: util.BoldRichTextStyle})
title.Segments[0].(*widget.TextSegment).Style.Alignment = fyne.TextAlignCenter
se := sd.SearchEntry.(fyne.CanvasObject)
if checkBox != nil {
sd.content = container.NewStack(
container.NewBorder(
container.NewVBox(title, se),
container.NewVBox(widget.NewSeparator(), container.NewHBox(checkBox, layout.NewSpacer(), dismissBtn)),
nil, nil, sd.list),
container.NewCenter(sd.loadingDots),
)
} else {
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),
)
}
}
func (sd *SearchDialog) onInit() {
if sd.OnInit == nil {
sd.SetContent(nil)
return
}
sd.loadingDots.Start()
var results []*mediaprovider.SearchResult
res, checkBox := sd.OnInit()
if len(res) == 0 {
log.Println("No results")
} else {
results = res
}
sd.SetContent(checkBox)
sd.loadingDots.Stop()
sd.setResults(results)
}
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 { func (sd *SearchDialog) CreateRenderer() fyne.WidgetRenderer {
return widget.NewSimpleRenderer(sd.content) return widget.NewSimpleRenderer(sd.content)
} }
@@ -164,7 +207,7 @@ func (sd *SearchDialog) update(sr *searchResult, result *mediaprovider.SearchRes
if result == nil { if result == nil {
return return
} }
if sr.contentType == result.Type && sr.id == result.ID { if sr.contentType == result.Type && sr.id == result.ID && result.ID != "" {
return // nothing to do return // nothing to do
} }
sr.id = result.ID sr.id = result.ID
+152
View File
@@ -0,0 +1,152 @@
package dialogs
import (
// "fmt"
"fmt"
"log"
"strings"
"fyne.io/fyne/v2"
"fyne.io/fyne/v2/theme"
"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
allPlaylists []*mediaprovider.Playlist
SkipDuplicates bool
}
func NewSelectPlaylistDialog(mp mediaprovider.MediaProvider, im util.ImageFetcher, loggedInUser string) *SelectPlaylist {
sp := &SelectPlaylist{
mp: mp,
loggedInUser: loggedInUser,
SkipDuplicates: false,
}
sd := NewSearchDialog(
im,
"Select playlist",
sp.onSearched,
sp.onUpdateSearchResult,
sp.onInit,
)
sp.SearchDialog = sd
return sp
}
func (sp *SelectPlaylist) onInit() ([]*mediaprovider.SearchResult, *widget.Check) {
var results []*mediaprovider.SearchResult
playlists, err := sp.mp.GetPlaylists()
if err != nil {
// TODO: surface this error to user
log.Printf("error getting playlists: %s", err.Error())
return results, nil
}
sp.allPlaylists = sharedutil.FilterSlice(playlists, func(playlist *mediaprovider.Playlist) bool {
return playlist.Owner == sp.loggedInUser
})
for _, playlist := range sp.allPlaylists {
results = append(results, &mediaprovider.SearchResult{
Name: playlist.Name,
ID: playlist.ID,
CoverID: playlist.CoverArtID,
Type: mediaprovider.ContentTypePlaylist,
Size: playlist.TrackCount,
ArtistName: playlist.Name,
})
}
skipDuplicatesCheck := widget.NewCheck("Skip duplicates", func(checked bool) {
sp.SkipDuplicates = checked
})
return results, skipDuplicatesCheck
}
func (sp *SelectPlaylist) onSearched(query string) []*mediaprovider.SearchResult {
var results []*mediaprovider.SearchResult
var filteredPlaylists []*mediaprovider.Playlist
if query == "" {
filteredPlaylists = sp.allPlaylists
} else {
filteredPlaylists = sharedutil.FilterSlice(sp.allPlaylists, func(playlist *mediaprovider.Playlist) 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,
Query: query,
})
}
for _, playlist := range filteredPlaylists {
results = append(results, &mediaprovider.SearchResult{
Name: playlist.Name,
ID: playlist.ID,
CoverID: playlist.CoverArtID,
Type: mediaprovider.ContentTypePlaylist,
Size: playlist.TrackCount,
ArtistName: playlist.Name,
})
}
return results
}
func (sp *SelectPlaylist) onUpdateSearchResult(sr *searchResult, result *mediaprovider.SearchResult) {
if result.ID == "" {
sr.secondary.Segments = []widget.RichTextSegment{}
sr.secondary.Refresh()
return
}
maybePluralize := func(s string, size int) string {
if size != 1 {
return s + "s"
}
return s
}
secondaryText := fmt.Sprintf("%d %s", result.Size, maybePluralize("track", result.Size))
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 (sp *SelectPlaylist) SetOnDismiss(onDismiss func()) {
sp.SearchDialog.OnDismiss = onDismiss
}
func (sp *SelectPlaylist) SetOnNavigateTo(onNavigateTo func(mediaprovider.ContentType, string, string)) {
sp.SearchDialog.OnNavigateTo = onNavigateTo
}
func (sp *SelectPlaylist) MinSize() fyne.Size {
return sp.SearchDialog.MinSize()
}
func (sp *SelectPlaylist) GetSearchEntry() fyne.Focusable {
return sp.SearchDialog.SearchEntry
}