Merge pull request #382 from natilou/new-add-to-playlist
chore: Improve 'Add to playlists' dialog
This commit is contained in:
@@ -179,4 +179,5 @@ type SearchResult struct {
|
||||
|
||||
// Unset for ContentTypes Artist, Playlist, and Genre
|
||||
ArtistName string
|
||||
Query string
|
||||
}
|
||||
|
||||
+43
-36
@@ -11,6 +11,7 @@ import (
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"time"
|
||||
|
||||
"github.com/dweymouth/supersonic/backend"
|
||||
@@ -322,49 +323,54 @@ 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
|
||||
}
|
||||
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, query string) {
|
||||
pop.Hide()
|
||||
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
|
||||
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
|
||||
}
|
||||
}
|
||||
for _, track := range selectedPlaylist.Tracks {
|
||||
trackIDsInPaylist = append(trackIDsInPaylist, track.ID)
|
||||
}
|
||||
filterTrackIDs = sharedutil.FilterSlice(trackIDs, func(trackID string) bool {
|
||||
return !slices.Contains(trackIDsInPaylist, trackID)
|
||||
})
|
||||
|
||||
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)
|
||||
}
|
||||
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) {
|
||||
@@ -622,7 +628,7 @@ func (c *Controller) ShowQuickSearch() {
|
||||
pop.Hide()
|
||||
c.doModalClosed()
|
||||
})
|
||||
qs.SetOnNavigateTo(func(contentType mediaprovider.ContentType, id string) {
|
||||
qs.SetOnNavigateTo(func(contentType mediaprovider.ContentType, id string, query string) {
|
||||
pop.Hide()
|
||||
c.doModalClosed()
|
||||
switch contentType {
|
||||
@@ -642,6 +648,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())
|
||||
|
||||
@@ -27,6 +27,7 @@ func NewQuickSearch(mp mediaprovider.MediaProvider, im util.ImageFetcher) *Quick
|
||||
"Quick Search",
|
||||
q.onSearched,
|
||||
q.onUpdateSearchResult,
|
||||
nil,
|
||||
)
|
||||
q.SearchDialog = sd
|
||||
return q
|
||||
@@ -95,7 +96,7 @@ func (q *QuickSearch) SetOnDismiss(onDismiss func()) {
|
||||
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
|
||||
}
|
||||
|
||||
|
||||
+71
-28
@@ -33,20 +33,24 @@ type SearchDialog struct {
|
||||
list *widget.List
|
||||
selectedIndex int
|
||||
|
||||
content *fyne.Container
|
||||
placeholderTitle string
|
||||
content *fyne.Container
|
||||
|
||||
OnDismiss func()
|
||||
OnNavigateTo func(mediaprovider.ContentType, string)
|
||||
OnNavigateTo func(mediaprovider.ContentType, string, string)
|
||||
OnSearched func(string) []*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{
|
||||
imgSource: im,
|
||||
loadingDots: widgets.NewLoadingDots(),
|
||||
OnSearched: onSearched,
|
||||
OnUpdateSearchResults: onUpdateSearchResult,
|
||||
OnInit: onInit,
|
||||
placeholderTitle: placeholderTitle,
|
||||
}
|
||||
sd.ExtendBaseWidget(sd)
|
||||
|
||||
@@ -78,20 +82,14 @@ func NewSearchDialog(im util.ImageFetcher, placeholderTitle string, onSearched f
|
||||
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
|
||||
}
|
||||
|
||||
func (sd *SearchDialog) Show() {
|
||||
sd.onInit()
|
||||
sd.BaseWidget.Show()
|
||||
}
|
||||
|
||||
func (sd *SearchDialog) onDismiss() {
|
||||
if sd.OnDismiss != nil {
|
||||
sd.OnDismiss()
|
||||
@@ -109,8 +107,9 @@ func (sd *SearchDialog) onSelected(idx int) {
|
||||
}
|
||||
id := sd.searchResults[idx].ID
|
||||
typ := sd.searchResults[idx].Type
|
||||
query := sd.searchResults[idx].Query
|
||||
sd.resultsMutex.RUnlock()
|
||||
sd.OnNavigateTo(typ, id)
|
||||
sd.OnNavigateTo(typ, id, query)
|
||||
}
|
||||
|
||||
func (sd *SearchDialog) moveSelectionDown() {
|
||||
@@ -131,18 +130,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,6 +140,61 @@ func (sd *SearchDialog) onSearched(query string) {
|
||||
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 {
|
||||
return widget.NewSimpleRenderer(sd.content)
|
||||
}
|
||||
@@ -164,7 +207,7 @@ func (sd *SearchDialog) update(sr *searchResult, result *mediaprovider.SearchRes
|
||||
if result == nil {
|
||||
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
|
||||
}
|
||||
sr.id = result.ID
|
||||
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user