diff --git a/backend/mediaprovider/jellyfin/searchall.go b/backend/mediaprovider/jellyfin/searchall.go index 09908d2..1494818 100644 --- a/backend/mediaprovider/jellyfin/searchall.go +++ b/backend/mediaprovider/jellyfin/searchall.go @@ -11,7 +11,7 @@ import ( "github.com/dweymouth/supersonic/sharedutil" ) -func (s *jellyfinMediaProvider) SearchAll(searchQuery string, maxResults int) ([]*mediaprovider.SearchResult, error) { +func (j *jellyfinMediaProvider) SearchAll(searchQuery string, maxResults int) ([]*mediaprovider.SearchResult, error) { limit := maxResults / 3 var wg sync.WaitGroup var albums []*jellyfin.Album @@ -22,19 +22,19 @@ func (s *jellyfinMediaProvider) SearchAll(searchQuery string, maxResults int) ([ wg.Add(1) go func() { - albumResult, _ := s.client.Search(searchQuery, jellyfin.TypeAlbum, jellyfin.Paging{Limit: limit}) + albumResult, _ := j.client.Search(searchQuery, jellyfin.TypeAlbum, jellyfin.Paging{Limit: limit}) albums = albumResult.Albums wg.Done() }() wg.Add(1) go func() { - artistResult, _ := s.client.Search(searchQuery, jellyfin.TypeArtist, jellyfin.Paging{Limit: limit}) + artistResult, _ := j.client.Search(searchQuery, jellyfin.TypeArtist, jellyfin.Paging{Limit: limit}) artists = artistResult.Artists wg.Done() }() wg.Add(1) go func() { - songResult, _ := s.client.Search(searchQuery, jellyfin.TypeSong, jellyfin.Paging{Limit: limit}) + songResult, _ := j.client.Search(searchQuery, jellyfin.TypeSong, jellyfin.Paging{Limit: limit}) songs = songResult.Songs wg.Done() }() @@ -44,7 +44,7 @@ func (s *jellyfinMediaProvider) SearchAll(searchQuery string, maxResults int) ([ wg.Add(1) go func() { - p, e := s.client.GetPlaylists() + p, e := j.client.GetPlaylists() if e == nil { playlists = sharedutil.FilterSlice(p, func(p *jellyfin.Playlist) bool { return helpers.AllTermsMatch(strings.ToLower(sanitize.Accents(p.Name)), queryLowerWords) @@ -55,7 +55,7 @@ func (s *jellyfinMediaProvider) SearchAll(searchQuery string, maxResults int) ([ wg.Add(1) go func() { - g, e := s.client.GetGenres(jellyfin.Paging{}) + g, e := j.client.GetGenres(jellyfin.Paging{}) if e == nil { genres = sharedutil.FilterSlice(g, func(g jellyfin.NameID) bool { return helpers.AllTermsMatch(strings.ToLower(sanitize.Accents(g.Name)), queryLowerWords) @@ -66,13 +66,13 @@ func (s *jellyfinMediaProvider) SearchAll(searchQuery string, maxResults int) ([ wg.Wait() - results := mergeResults(albums, artists, songs, playlists, genres) + results := j.mergeResults(albums, artists, songs, playlists, genres) helpers.RankSearchResults(results, searchQuery, queryLowerWords) return results, nil } -func mergeResults( +func (j *jellyfinMediaProvider) mergeResults( albums []*jellyfin.Album, artists []*jellyfin.Artist, songs []*jellyfin.Song, @@ -93,6 +93,7 @@ func mergeResults( Name: al.Name, ArtistName: strings.Join(sharedutil.MapSlice(al.Artists, getArtistNames), ","), Size: al.ChildCount, + Item: toAlbum(al), }) } @@ -103,6 +104,7 @@ func mergeResults( CoverID: ar.ID, Name: ar.Name, Size: ar.AlbumCount, + Item: toArtist(ar), }) } @@ -114,6 +116,7 @@ func mergeResults( Name: tr.Name, ArtistName: strings.Join(sharedutil.MapSlice(tr.Artists, getArtistNames), ","), Size: int(tr.RunTimeTicks / 10_000_000), + Item: toTrack(tr), }) } @@ -124,6 +127,7 @@ func mergeResults( CoverID: pl.ID, Name: pl.Name, Size: pl.SongCount, + Item: j.toPlaylist(pl), }) } diff --git a/backend/mediaprovider/model.go b/backend/mediaprovider/model.go index 4c65c80..a39477d 100644 --- a/backend/mediaprovider/model.go +++ b/backend/mediaprovider/model.go @@ -279,4 +279,8 @@ type SearchResult struct { // Unset for ContentTypes Artist, Playlist, Genre, and RadioStation ArtistName string + + // The actual item corresponding to this search result + // *mediaprovider.Artist for ContentTypeArtist, etc + Item any } diff --git a/backend/mediaprovider/subsonic/searchall.go b/backend/mediaprovider/subsonic/searchall.go index ffb099f..e7f7d78 100644 --- a/backend/mediaprovider/subsonic/searchall.go +++ b/backend/mediaprovider/subsonic/searchall.go @@ -101,6 +101,7 @@ func mergeResults( Name: al.Name, ArtistName: getNameString(al.Artist, al.Artists), Size: al.SongCount, + Item: toAlbum(al), }) } @@ -111,6 +112,7 @@ func mergeResults( CoverID: ar.CoverArt, Name: ar.Name, Size: ar.AlbumCount, + Item: toArtistFromID3(ar), }) } @@ -122,6 +124,7 @@ func mergeResults( Name: tr.Title, ArtistName: getNameString(tr.Artist, tr.Artists), Size: tr.Duration, + Item: toTrack(tr), }) } @@ -132,6 +135,7 @@ func mergeResults( CoverID: pl.CoverArt, Name: pl.Name, Size: pl.SongCount, + Item: toPlaylist(pl), }) } @@ -149,6 +153,7 @@ func mergeResults( Type: mediaprovider.ContentTypeRadioStation, ID: r.ID, Name: r.Name, + Item: r, }) } diff --git a/ui/controller/controller.go b/ui/controller/controller.go index b6ab0b2..246f61b 100644 --- a/ui/controller/controller.go +++ b/ui/controller/controller.go @@ -2,7 +2,6 @@ package controller import ( "archive/zip" - "context" "fmt" "image" "image/color" @@ -11,7 +10,6 @@ import ( "net/url" "os" "path/filepath" - "strconv" "sync" "time" @@ -30,7 +28,6 @@ import ( "fyne.io/fyne/v2/container" "fyne.io/fyne/v2/dialog" "fyne.io/fyne/v2/lang" - "fyne.io/fyne/v2/layout" "fyne.io/fyne/v2/theme" "fyne.io/fyne/v2/widget" ) @@ -260,327 +257,6 @@ func (m *Controller) GetArtistTracks(artistID string) []*mediaprovider.Track { return nil } -func (m *Controller) PromptForFirstServer() { - d := dialogs.NewAddEditServerDialog(lang.L("Connect to Server"), false, nil, m.MainWindow.Canvas().Focus) - pop := widget.NewModalPopUp(d, m.MainWindow.Canvas()) - d.OnSubmit = func() { - d.DisableSubmit() - go func() { - if m.testConnectionAndUpdateDialogText(d) { - // connection is good - pop.Hide() - m.doModalClosed() - conn := backend.ServerConnection{ - ServerType: d.ServerType, - Hostname: d.Host, - AltHostname: d.AltHost, - Username: d.Username, - LegacyAuth: d.LegacyAuth, - } - server := m.App.ServerManager.AddServer(d.Nickname, conn) - if err := m.trySetPasswordAndConnectToServer(server, d.Password); err != nil { - log.Printf("error connecting to server: %s", err.Error()) - } - } - d.EnableSubmit() - }() - } - m.haveModal = true - pop.Show() -} - -// 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) { - sp := dialogs.NewSelectPlaylistDialog(m.App.ServerManager.Server, m.App.ImageManager, - m.App.ServerManager.LoggedInUser, m.App.Config.Application.AddToPlaylistSkipDuplicates) - pop := widget.NewModalPopUp(sp.SearchDialog, m.MainWindow.Canvas()) - sp.SetOnDismiss(func() { - pop.Hide() - m.doModalClosed() - }) - sp.SetOnNavigateTo(func(contentType mediaprovider.ContentType, id string) { - notifySuccess := func(n int) { - fyne.Do(func() { - msg := lang.LocalizePluralKey("playlist.addedtracks", - "Added tracks to playlist", n, map[string]string{"trackCount": strconv.Itoa(n)}) - m.ToastProvider.ShowSuccessToast(msg) - }) - } - notifyError := func() { - fyne.Do(func() { - m.ToastProvider.ShowErrorToast( - lang.L("An error occurred adding tracks to the playlist"), - ) - }) - } - pop.Hide() - m.App.Config.Application.AddToPlaylistSkipDuplicates = sp.SkipDuplicates - if id == "" /* creating new playlist */ { - go func() { - err := m.App.ServerManager.Server.CreatePlaylist(sp.SearchDialog.SearchQuery(), trackIDs) - if err == nil { - notifySuccess(len(trackIDs)) - } else { - log.Printf("error adding tracks to playlist: %s", err.Error()) - notifyError() - } - }() - } 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()) - notifyError() - } else { - for _, track := range selectedPlaylist.Tracks { - currentTrackIDs[track.ID] = struct{}{} - } - filterTrackIDs := sharedutil.FilterSlice(trackIDs, func(trackID string) bool { - _, ok := currentTrackIDs[trackID] - return !ok - }) - err := m.App.ServerManager.Server.AddPlaylistTracks(id, filterTrackIDs) - if err == nil { - notifySuccess(len(filterTrackIDs)) - } else { - log.Printf("error adding tracks to playlist: %s", err.Error()) - notifyError() - } - } - }() - } else { - go func() { - err := m.App.ServerManager.Server.AddPlaylistTracks(id, trackIDs) - if err == nil { - notifySuccess(len(trackIDs)) - } else { - log.Printf("error adding tracks to playlist: %s", err.Error()) - notifyError() - } - }() - } - } - - }) - 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) { - canMakePublic := m.App.ServerManager.Server.CanMakePublicPlaylist() - dlg := dialogs.NewEditPlaylistDialog(playlist, canMakePublic) - pop := widget.NewModalPopUp(dlg, m.MainWindow.Canvas()) - m.ClosePopUpOnEscape(pop) - dlg.OnCanceled = func() { - pop.Hide() - m.doModalClosed() - } - dlg.OnDeletePlaylist = func() { - pop.Hide() - dialog.ShowCustomConfirm(lang.L("Confirm Delete Playlist"), lang.L("OK"), lang.L("Cancel"), layout.NewSpacer(), /*custom content*/ - func(ok bool) { - if !ok { - pop.Show() - } else { - m.doModalClosed() - go func() { - if err := m.App.ServerManager.Server.DeletePlaylist(playlist.ID); err != nil { - log.Printf("error deleting playlist: %s", err.Error()) - } else if rte := m.CurPageFunc(); rte.Page == Playlist && rte.Arg == playlist.ID { - // navigate to playlists page if user is still on the page of the deleted playlist - fyne.Do(func() { m.NavigateTo(PlaylistsRoute()) }) - } - }() - } - }, m.MainWindow) - } - dlg.OnUpdateMetadata = func() { - pop.Hide() - m.doModalClosed() - go func() { - err := m.App.ServerManager.Server.EditPlaylist(playlist.ID, dlg.Name, dlg.Description, dlg.IsPublic) - if err != nil { - log.Printf("error updating playlist: %s", err.Error()) - } else if rte := m.CurPageFunc(); rte.Page == Playlist && rte.Arg == playlist.ID { - // if user is on playlist page, reload to get the updates - fyne.Do(m.ReloadFunc) - } - }() - } - m.haveModal = true - pop.Show() -} - -// DoConnectToServerWorkflow does the workflow for connecting to the last active server on startup -func (c *Controller) DoConnectToServerWorkflow(server *backend.ServerConfig) { - pass, err := c.App.ServerManager.GetServerPassword(server.ID) - if err != nil { - log.Printf("error getting password from keyring: %v", err) - c.PromptForLoginAndConnect() - return - } - - // try connecting to last used server - set up cancelable modal dialog - canceled := false - ctx, cancel := context.WithCancel(context.Background()) - dlg := dialog.NewCustom(lang.L("Connecting"), lang.L("Cancel"), - widget.NewLabel(fmt.Sprintf(lang.L("Connecting to")+" %s", server.Nickname)), c.MainWindow) - dlg.SetOnClosed(func() { - canceled = true - cancel() - }) - c.haveModal = true - dlg.Show() - - // try to connect - go func() { - defer cancel() // make sure to free up ctx resources if user does not cancel - - if err := c.tryConnectToServer(ctx, server, pass); err != nil { - fyne.Do(func() { - dlg.Hide() - c.haveModal = false - if canceled { - c.PromptForLoginAndConnect() - } else { - // connection failure - dlg := dialog.NewError(err, c.MainWindow) - dlg.SetOnClosed(func() { - c.PromptForLoginAndConnect() - }) - c.haveModal = true - dlg.Show() - } - }) - } else { - fyne.Do(func() { - dlg.Hide() - c.haveModal = false - }) - } - }() -} - -func (m *Controller) PromptForLoginAndConnect() { - d := dialogs.NewLoginDialog(m.App.Config.Servers, m.App.ServerManager.GetServerPassword) - pop := widget.NewModalPopUp(d, m.MainWindow.Canvas()) - d.OnSubmit = func(server *backend.ServerConfig, password string) { - d.DisableSubmit() - d.SetInfoText(lang.L("Testing connection") + "...") - go func() { - ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) - defer cancel() - - err := m.App.ServerManager.TestConnectionAndAuth(ctx, server.ServerConnection, password) - fyne.Do(func() { - if err == backend.ErrUnreachable { - d.SetErrorText(lang.L("Server unreachable")) - } else if err != nil { - d.SetErrorText(lang.L("Authentication failed")) - } else { - pop.Hide() - m.trySetPasswordAndConnectToServer(server, password) - m.doModalClosed() - } - d.EnableSubmit() - }) - }() - } - d.OnEditServer = func(server *backend.ServerConfig) { - pop.Hide() - editD := dialogs.NewAddEditServerDialog(lang.L("Edit server"), true, server, m.MainWindow.Canvas().Focus) - editPop := widget.NewModalPopUp(editD, m.MainWindow.Canvas()) - editD.OnSubmit = func() { - d.DisableSubmit() - go func() { - success := m.testConnectionAndUpdateDialogText(editD) - fyne.Do(func() { - if success { - // connection is good - editPop.Hide() - server.Hostname = editD.Host - server.AltHostname = editD.AltHost - server.Nickname = editD.Nickname - server.Username = editD.Username - server.LegacyAuth = editD.LegacyAuth - m.trySetPasswordAndConnectToServer(server, editD.Password) - m.doModalClosed() - } - d.EnableSubmit() - }) - }() - } - editD.OnCancel = func() { - editPop.Hide() - pop.Show() - } - editPop.Show() - } - d.OnNewServer = func() { - pop.Hide() - newD := dialogs.NewAddEditServerDialog(lang.L("Add Server"), true, nil, m.MainWindow.Canvas().Focus) - newPop := widget.NewModalPopUp(newD, m.MainWindow.Canvas()) - newD.OnSubmit = func() { - d.DisableSubmit() - go func() { - success := m.testConnectionAndUpdateDialogText(newD) - fyne.Do(func() { - if success { - // connection is good - newPop.Hide() - conn := backend.ServerConnection{ - ServerType: newD.ServerType, - Hostname: newD.Host, - AltHostname: newD.AltHost, - Username: newD.Username, - LegacyAuth: newD.LegacyAuth, - } - server := m.App.ServerManager.AddServer(newD.Nickname, conn) - m.trySetPasswordAndConnectToServer(server, newD.Password) - m.doModalClosed() - } - d.EnableSubmit() - }) - }() - } - newD.OnCancel = func() { - newPop.Hide() - pop.Show() - } - newPop.Show() - } - d.OnDeleteServer = func(server *backend.ServerConfig) { - pop.Hide() - dialog.ShowConfirm(lang.L("Confirm Delete Server"), - fmt.Sprintf(lang.L("Are you sure you want to delete the server")+" %q?", server.Nickname), - func(ok bool) { - if ok { - m.App.ServerManager.DeleteServer(server.ID) - m.App.DeleteServerCacheDir(server.ID) - d.SetServers(m.App.Config.Servers) - } - if len(m.App.Config.Servers) == 0 { - m.PromptForFirstServer() - } else { - pop.Show() - } - }, m.MainWindow) - - } - m.haveModal = true - pop.Show() -} - func (c *Controller) ShowAboutDialog() { dlg := dialogs.NewAboutDialog(c.AppVersion) pop := widget.NewModalPopUp(dlg, c.MainWindow.Canvas()) @@ -643,91 +319,6 @@ func (c *Controller) ShowSettingsDialog(themeUpdateCallbk func(), themeFiles map pop.Show() } -func (c *Controller) ShowQuickSearch() { - qs := dialogs.NewQuickSearch(c.App.ServerManager.Server, c.App.ImageManager) - pop := widget.NewModalPopUp(qs.SearchDialog, c.MainWindow.Canvas()) - qs.SetOnDismiss(func() { - pop.Hide() - c.doModalClosed() - }) - qs.SetOnNavigateTo(func(contentType mediaprovider.ContentType, id string) { - pop.Hide() - c.doModalClosed() - switch contentType { - case mediaprovider.ContentTypeAlbum: - c.NavigateTo(AlbumRoute(id)) - case mediaprovider.ContentTypeArtist: - c.NavigateTo(ArtistRoute(id)) - case mediaprovider.ContentTypeTrack: - go c.App.PlaybackManager.PlayTrack(id) - case mediaprovider.ContentTypePlaylist: - c.NavigateTo(PlaylistRoute(id)) - case mediaprovider.ContentTypeGenre: - c.NavigateTo(GenreRoute(id)) - case mediaprovider.ContentTypeRadioStation: - if rp, ok := c.App.ServerManager.Server.(mediaprovider.RadioProvider); ok { - if radio, err := rp.GetRadioStation(id); err == nil { - go c.App.PlaybackManager.PlayRadioStation(radio) - } - } - } - }) - c.ClosePopUpOnEscape(pop) - 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()) -} - -func (c *Controller) trySetPasswordAndConnectToServer(server *backend.ServerConfig, password string) error { - if err := c.App.ServerManager.SetServerPassword(server, password); err != nil { - log.Printf("error setting keyring credentials: %v", err) - // Don't return an error; fall back to just using the password in-memory - // User will need to log in with the password on subsequent runs. - } - return c.tryConnectToServer(context.Background(), server, password) -} - -// try to connect to the given server, with the configured timeout added to the context -func (c *Controller) tryConnectToServer(ctx context.Context, server *backend.ServerConfig, password string) error { - timeout := time.Duration(c.App.Config.Application.RequestTimeoutSeconds) * time.Second - ctx, cancel := context.WithTimeout(ctx, timeout) - defer cancel() - if err := c.App.ServerManager.TestConnectionAndAuth(ctx, server.ServerConnection, password); err != nil { - return err - } - if err := c.App.ServerManager.ConnectToServer(server, password); err != nil { - log.Printf("error connecting to server: %v", err) - return err - } - return nil -} - -func (c *Controller) testConnectionAndUpdateDialogText(dlg *dialogs.AddEditServerDialog) bool { - dlg.SetInfoText(lang.L("Testing connection") + "...") - conn := backend.ServerConnection{ - ServerType: dlg.ServerType, - Hostname: dlg.Host, - AltHostname: dlg.AltHost, - Username: dlg.Username, - LegacyAuth: dlg.LegacyAuth, - } - ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) - defer cancel() - err := c.App.ServerManager.TestConnectionAndAuth(ctx, conn, dlg.Password) - if err == backend.ErrUnreachable { - dlg.SetErrorText(lang.L("Could not reach server") + fmt.Sprintf(" (%s?)", lang.L("wrong URL"))) - return false - } else if err != nil { - dlg.SetErrorText(lang.L("Authentication failed") + fmt.Sprintf(" (%s)", lang.L("wrong username/password"))) - return false - } - return true -} - func (c *Controller) doModalClosed() { c.haveModal = false if c.runOnModalClosed != nil { diff --git a/ui/controller/playlist.go b/ui/controller/playlist.go new file mode 100644 index 0000000..1d666f2 --- /dev/null +++ b/ui/controller/playlist.go @@ -0,0 +1,147 @@ +package controller + +import ( + "log" + "strconv" + + "fyne.io/fyne/v2" + "fyne.io/fyne/v2/dialog" + "fyne.io/fyne/v2/lang" + "fyne.io/fyne/v2/layout" + "fyne.io/fyne/v2/widget" + "github.com/dweymouth/supersonic/backend/mediaprovider" + "github.com/dweymouth/supersonic/sharedutil" + "github.com/dweymouth/supersonic/ui/dialogs" +) + +// 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) { + sp := dialogs.NewSelectPlaylistDialog(m.App.ServerManager.Server, m.App.ImageManager, + m.App.ServerManager.LoggedInUser, m.App.Config.Application.AddToPlaylistSkipDuplicates) + pop := widget.NewModalPopUp(sp.SearchDialog, m.MainWindow.Canvas()) + sp.SetOnDismiss(func() { + pop.Hide() + m.doModalClosed() + }) + sp.SetOnNavigateTo(func(contentType mediaprovider.ContentType, id string) { + notifySuccess := func(n int) { + fyne.Do(func() { + msg := lang.LocalizePluralKey("playlist.addedtracks", + "Added tracks to playlist", n, map[string]string{"trackCount": strconv.Itoa(n)}) + m.ToastProvider.ShowSuccessToast(msg) + }) + } + notifyError := func() { + fyne.Do(func() { + m.ToastProvider.ShowErrorToast( + lang.L("An error occurred adding tracks to the playlist"), + ) + }) + } + pop.Hide() + m.App.Config.Application.AddToPlaylistSkipDuplicates = sp.SkipDuplicates + if id == "" /* creating new playlist */ { + go func() { + err := m.App.ServerManager.Server.CreatePlaylist(sp.SearchDialog.SearchQuery(), trackIDs) + if err == nil { + notifySuccess(len(trackIDs)) + } else { + log.Printf("error adding tracks to playlist: %s", err.Error()) + notifyError() + } + }() + } 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()) + notifyError() + } else { + for _, track := range selectedPlaylist.Tracks { + currentTrackIDs[track.ID] = struct{}{} + } + filterTrackIDs := sharedutil.FilterSlice(trackIDs, func(trackID string) bool { + _, ok := currentTrackIDs[trackID] + return !ok + }) + err := m.App.ServerManager.Server.AddPlaylistTracks(id, filterTrackIDs) + if err == nil { + notifySuccess(len(filterTrackIDs)) + } else { + log.Printf("error adding tracks to playlist: %s", err.Error()) + notifyError() + } + } + }() + } else { + go func() { + err := m.App.ServerManager.Server.AddPlaylistTracks(id, trackIDs) + if err == nil { + notifySuccess(len(trackIDs)) + } else { + log.Printf("error adding tracks to playlist: %s", err.Error()) + notifyError() + } + }() + } + } + + }) + 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) { + canMakePublic := m.App.ServerManager.Server.CanMakePublicPlaylist() + dlg := dialogs.NewEditPlaylistDialog(playlist, canMakePublic) + pop := widget.NewModalPopUp(dlg, m.MainWindow.Canvas()) + m.ClosePopUpOnEscape(pop) + dlg.OnCanceled = func() { + pop.Hide() + m.doModalClosed() + } + dlg.OnDeletePlaylist = func() { + pop.Hide() + dialog.ShowCustomConfirm(lang.L("Confirm Delete Playlist"), lang.L("OK"), lang.L("Cancel"), layout.NewSpacer(), /*custom content*/ + func(ok bool) { + if !ok { + pop.Show() + } else { + m.doModalClosed() + go func() { + if err := m.App.ServerManager.Server.DeletePlaylist(playlist.ID); err != nil { + log.Printf("error deleting playlist: %s", err.Error()) + } else if rte := m.CurPageFunc(); rte.Page == Playlist && rte.Arg == playlist.ID { + // navigate to playlists page if user is still on the page of the deleted playlist + fyne.Do(func() { m.NavigateTo(PlaylistsRoute()) }) + } + }() + } + }, m.MainWindow) + } + dlg.OnUpdateMetadata = func() { + pop.Hide() + m.doModalClosed() + go func() { + err := m.App.ServerManager.Server.EditPlaylist(playlist.ID, dlg.Name, dlg.Description, dlg.IsPublic) + if err != nil { + log.Printf("error updating playlist: %s", err.Error()) + } else if rte := m.CurPageFunc(); rte.Page == Playlist && rte.Arg == playlist.ID { + // if user is on playlist page, reload to get the updates + fyne.Do(m.ReloadFunc) + } + }() + } + m.haveModal = true + pop.Show() +} diff --git a/ui/controller/searchdialog.go b/ui/controller/searchdialog.go new file mode 100644 index 0000000..b7c44d3 --- /dev/null +++ b/ui/controller/searchdialog.go @@ -0,0 +1,178 @@ +package controller + +import ( + "log" + + "fyne.io/fyne/v2" + "fyne.io/fyne/v2/widget" + "github.com/dweymouth/supersonic/backend" + "github.com/dweymouth/supersonic/backend/mediaprovider" + "github.com/dweymouth/supersonic/sharedutil" + "github.com/dweymouth/supersonic/ui/dialogs" +) + +func (c *Controller) ShowQuickSearch() { + qs := dialogs.NewQuickSearch(c.App.ServerManager.Server, c.App.ImageManager) + pop := widget.NewModalPopUp(qs.SearchDialog, c.MainWindow.Canvas()) + qs.SetOnDismiss(func() { + pop.Hide() + c.doModalClosed() + }) + + qs.SetOnNavigateTo(func(contentType mediaprovider.ContentType, id string) { + pop.Hide() + c.doModalClosed() + switch contentType { + case mediaprovider.ContentTypeAlbum: + c.NavigateTo(AlbumRoute(id)) + case mediaprovider.ContentTypeArtist: + c.NavigateTo(ArtistRoute(id)) + case mediaprovider.ContentTypeTrack: + go c.App.PlaybackManager.PlayTrack(id) + case mediaprovider.ContentTypePlaylist: + c.NavigateTo(PlaylistRoute(id)) + case mediaprovider.ContentTypeGenre: + c.NavigateTo(GenreRoute(id)) + case mediaprovider.ContentTypeRadioStation: + if rp, ok := c.App.ServerManager.Server.(mediaprovider.RadioProvider); ok { + if radio, err := rp.GetRadioStation(id); err == nil { + go c.App.PlaybackManager.PlayRadioStation(radio) + } + } + } + }) + qs.OnPlay = func(t mediaprovider.ContentType, id string, item any, shuffle bool) { + switch t { + case mediaprovider.ContentTypeTrack: + c.App.PlaybackManager.LoadTracks( + []*mediaprovider.Track{item.(*mediaprovider.Track)}, + backend.Replace, false /*shuffle*/) + c.App.PlaybackManager.PlayFromBeginning() + case mediaprovider.ContentTypeAlbum: + go c.App.PlaybackManager.PlayAlbum(id, 0, shuffle) + case mediaprovider.ContentTypeArtist: + go c.App.PlaybackManager.PlayArtistDiscography(id, shuffle) + case mediaprovider.ContentTypePlaylist: + go c.App.PlaybackManager.PlayPlaylist(id, 0, shuffle) + case mediaprovider.ContentTypeGenre: + go c.App.PlaybackManager.PlayRandomSongs(id /*genre name*/) + case mediaprovider.ContentTypeRadioStation: + go c.App.PlaybackManager.PlayRadioStation(item.(*mediaprovider.RadioStation)) + } + } + qs.OnAddToQueue = c.handleSearchDialogOnAddToQueue + qs.OnAddToPlaylist = c.handleSearchDialogOnAddToPlaylist + qs.OnDownload = func(track *mediaprovider.Track) { + c.ShowDownloadDialog([]*mediaprovider.Track{track}, track.Metadata().Name) + } + qs.OnPlaySongRadio = func(track *mediaprovider.Track) { + go func() { + tracks, err := c.GetSongRadioTracks(track) + if err != nil { + c.App.PlaybackManager.LoadTracks(tracks, backend.Replace, false) + c.App.PlaybackManager.PlayFromBeginning() + } + }() + } + qs.OnSetFavorite = func(trackID string, fav bool) { + go c.SetTrackFavorites([]string{trackID}, fav) + } + qs.OnSetRating = func(trackID string, rating int) { + go c.SetTrackRatings([]string{trackID}, rating) + } + qs.OnShare = func(trackID string) { + c.ShowShareDialog(trackID) + } + qs.OnShowTrackInfo = func(track *mediaprovider.Track) { + c.ShowTrackInfoDialog(track) + } + + c.ClosePopUpOnEscape(pop) + 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()) +} + +func (c *Controller) handleSearchDialogOnAddToQueue(t mediaprovider.ContentType, id string, item any, next bool) { + insertMode := backend.Append + if next { + insertMode = backend.InsertNext + } + switch t { + case mediaprovider.ContentTypeTrack: + c.App.PlaybackManager.LoadTracks( + []*mediaprovider.Track{item.(*mediaprovider.Track)}, + insertMode, false /*shuffle*/) + case mediaprovider.ContentTypeAlbum: + go c.App.PlaybackManager.LoadAlbum(id, insertMode, false) + case mediaprovider.ContentTypeArtist: + go func() { + tracks := c.GetArtistTracks(id) + c.App.PlaybackManager.LoadTracks(tracks, insertMode, false) + }() + case mediaprovider.ContentTypePlaylist: + go c.App.PlaybackManager.LoadPlaylist(id, insertMode, false) + case mediaprovider.ContentTypeGenre: + go func() { + tr, err := c.App.ServerManager.Server.GetRandomTracks(id /*genre name*/, c.App.Config.Application.EnqueueBatchSize) + if err != nil { + c.App.PlaybackManager.LoadTracks(tr, insertMode, false /*shuffle*/) + } + }() + case mediaprovider.ContentTypeRadioStation: + c.App.PlaybackManager.LoadRadioStation(item.(*mediaprovider.RadioStation), insertMode) + } +} + +func (c *Controller) handleSearchDialogOnAddToPlaylist(t mediaprovider.ContentType, id string, item any) { + switch t { + case mediaprovider.ContentTypeTrack: + c.DoAddTracksToPlaylistWorkflow([]string{id}) + case mediaprovider.ContentTypeAlbum: + go func() { + album, err := c.App.ServerManager.Server.GetAlbum(id) + if err == nil && len(album.Tracks) > 0 { + trackIDs := sharedutil.MapSlice(album.Tracks, func(t *mediaprovider.Track) string { + return t.ID + }) + fyne.Do(func() { c.DoAddTracksToPlaylistWorkflow(trackIDs) }) + } + }() + case mediaprovider.ContentTypeArtist: + go func() { + tracks := c.GetArtistTracks(id) + if len(tracks) > 0 { + trackIDs := sharedutil.MapSlice(tracks, func(t *mediaprovider.Track) string { + return t.ID + }) + fyne.Do(func() { c.DoAddTracksToPlaylistWorkflow(trackIDs) }) + } + }() + case mediaprovider.ContentTypePlaylist: + go func() { + playlist, err := c.App.ServerManager.Server.GetPlaylist(id) + if err == nil && len(playlist.Tracks) > 0 { + trackIDs := sharedutil.MapSlice(playlist.Tracks, func(t *mediaprovider.Track) string { + return t.ID + }) + fyne.Do(func() { c.DoAddTracksToPlaylistWorkflow(trackIDs) }) + } + }() + case mediaprovider.ContentTypeGenre: + go func() { + tracks, err := c.App.ServerManager.Server.GetRandomTracks(id /*genre name*/, c.App.Config.Application.EnqueueBatchSize) + if err == nil && len(tracks) > 0 { + trackIDs := sharedutil.MapSlice(tracks, func(t *mediaprovider.Track) string { + return t.ID + }) + fyne.Do(func() { c.DoAddTracksToPlaylistWorkflow(trackIDs) }) + } + }() + case mediaprovider.ContentTypeRadioStation: + log.Println("Cannot add radio station to playlist") + } +} diff --git a/ui/controller/serverconnection.go b/ui/controller/serverconnection.go new file mode 100644 index 0000000..480ff90 --- /dev/null +++ b/ui/controller/serverconnection.go @@ -0,0 +1,250 @@ +package controller + +import ( + "context" + "fmt" + "log" + "time" + + "fyne.io/fyne/v2" + "fyne.io/fyne/v2/dialog" + "fyne.io/fyne/v2/lang" + "fyne.io/fyne/v2/widget" + "github.com/dweymouth/supersonic/backend" + "github.com/dweymouth/supersonic/ui/dialogs" +) + +func (m *Controller) PromptForFirstServer() { + d := dialogs.NewAddEditServerDialog(lang.L("Connect to Server"), false, nil, m.MainWindow.Canvas().Focus) + pop := widget.NewModalPopUp(d, m.MainWindow.Canvas()) + d.OnSubmit = func() { + d.DisableSubmit() + go func() { + if m.testConnectionAndUpdateDialogText(d) { + // connection is good + pop.Hide() + m.doModalClosed() + conn := backend.ServerConnection{ + ServerType: d.ServerType, + Hostname: d.Host, + AltHostname: d.AltHost, + Username: d.Username, + LegacyAuth: d.LegacyAuth, + } + server := m.App.ServerManager.AddServer(d.Nickname, conn) + if err := m.trySetPasswordAndConnectToServer(server, d.Password); err != nil { + log.Printf("error connecting to server: %s", err.Error()) + } + } + d.EnableSubmit() + }() + } + m.haveModal = true + pop.Show() +} + +// DoConnectToServerWorkflow does the workflow for connecting to the last active server on startup +func (c *Controller) DoConnectToServerWorkflow(server *backend.ServerConfig) { + pass, err := c.App.ServerManager.GetServerPassword(server.ID) + if err != nil { + log.Printf("error getting password from keyring: %v", err) + c.PromptForLoginAndConnect() + return + } + + // try connecting to last used server - set up cancelable modal dialog + canceled := false + ctx, cancel := context.WithCancel(context.Background()) + dlg := dialog.NewCustom(lang.L("Connecting"), lang.L("Cancel"), + widget.NewLabel(fmt.Sprintf(lang.L("Connecting to")+" %s", server.Nickname)), c.MainWindow) + dlg.SetOnClosed(func() { + canceled = true + cancel() + }) + c.haveModal = true + dlg.Show() + + // try to connect + go func() { + defer cancel() // make sure to free up ctx resources if user does not cancel + + if err := c.tryConnectToServer(ctx, server, pass); err != nil { + fyne.Do(func() { + dlg.Hide() + c.haveModal = false + if canceled { + c.PromptForLoginAndConnect() + } else { + // connection failure + dlg := dialog.NewError(err, c.MainWindow) + dlg.SetOnClosed(func() { + c.PromptForLoginAndConnect() + }) + c.haveModal = true + dlg.Show() + } + }) + } else { + fyne.Do(func() { + dlg.Hide() + c.haveModal = false + }) + } + }() +} + +func (m *Controller) PromptForLoginAndConnect() { + d := dialogs.NewLoginDialog(m.App.Config.Servers, m.App.ServerManager.GetServerPassword) + pop := widget.NewModalPopUp(d, m.MainWindow.Canvas()) + d.OnSubmit = func(server *backend.ServerConfig, password string) { + d.DisableSubmit() + d.SetInfoText(lang.L("Testing connection") + "...") + go func() { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + err := m.App.ServerManager.TestConnectionAndAuth(ctx, server.ServerConnection, password) + fyne.Do(func() { + if err == backend.ErrUnreachable { + d.SetErrorText(lang.L("Server unreachable")) + } else if err != nil { + d.SetErrorText(lang.L("Authentication failed")) + } else { + pop.Hide() + m.trySetPasswordAndConnectToServer(server, password) + m.doModalClosed() + } + d.EnableSubmit() + }) + }() + } + d.OnEditServer = func(server *backend.ServerConfig) { + pop.Hide() + editD := dialogs.NewAddEditServerDialog(lang.L("Edit server"), true, server, m.MainWindow.Canvas().Focus) + editPop := widget.NewModalPopUp(editD, m.MainWindow.Canvas()) + editD.OnSubmit = func() { + d.DisableSubmit() + go func() { + success := m.testConnectionAndUpdateDialogText(editD) + fyne.Do(func() { + if success { + // connection is good + editPop.Hide() + server.Hostname = editD.Host + server.AltHostname = editD.AltHost + server.Nickname = editD.Nickname + server.Username = editD.Username + server.LegacyAuth = editD.LegacyAuth + m.trySetPasswordAndConnectToServer(server, editD.Password) + m.doModalClosed() + } + d.EnableSubmit() + }) + }() + } + editD.OnCancel = func() { + editPop.Hide() + pop.Show() + } + editPop.Show() + } + d.OnNewServer = func() { + pop.Hide() + newD := dialogs.NewAddEditServerDialog(lang.L("Add Server"), true, nil, m.MainWindow.Canvas().Focus) + newPop := widget.NewModalPopUp(newD, m.MainWindow.Canvas()) + newD.OnSubmit = func() { + d.DisableSubmit() + go func() { + success := m.testConnectionAndUpdateDialogText(newD) + fyne.Do(func() { + if success { + // connection is good + newPop.Hide() + conn := backend.ServerConnection{ + ServerType: newD.ServerType, + Hostname: newD.Host, + AltHostname: newD.AltHost, + Username: newD.Username, + LegacyAuth: newD.LegacyAuth, + } + server := m.App.ServerManager.AddServer(newD.Nickname, conn) + m.trySetPasswordAndConnectToServer(server, newD.Password) + m.doModalClosed() + } + d.EnableSubmit() + }) + }() + } + newD.OnCancel = func() { + newPop.Hide() + pop.Show() + } + newPop.Show() + } + d.OnDeleteServer = func(server *backend.ServerConfig) { + pop.Hide() + dialog.ShowConfirm(lang.L("Confirm Delete Server"), + fmt.Sprintf(lang.L("Are you sure you want to delete the server")+" %q?", server.Nickname), + func(ok bool) { + if ok { + m.App.ServerManager.DeleteServer(server.ID) + m.App.DeleteServerCacheDir(server.ID) + d.SetServers(m.App.Config.Servers) + } + if len(m.App.Config.Servers) == 0 { + m.PromptForFirstServer() + } else { + pop.Show() + } + }, m.MainWindow) + + } + m.haveModal = true + pop.Show() +} + +func (c *Controller) trySetPasswordAndConnectToServer(server *backend.ServerConfig, password string) error { + if err := c.App.ServerManager.SetServerPassword(server, password); err != nil { + log.Printf("error setting keyring credentials: %v", err) + // Don't return an error; fall back to just using the password in-memory + // User will need to log in with the password on subsequent runs. + } + return c.tryConnectToServer(context.Background(), server, password) +} + +// try to connect to the given server, with the configured timeout added to the context +func (c *Controller) tryConnectToServer(ctx context.Context, server *backend.ServerConfig, password string) error { + timeout := time.Duration(c.App.Config.Application.RequestTimeoutSeconds) * time.Second + ctx, cancel := context.WithTimeout(ctx, timeout) + defer cancel() + if err := c.App.ServerManager.TestConnectionAndAuth(ctx, server.ServerConnection, password); err != nil { + return err + } + if err := c.App.ServerManager.ConnectToServer(server, password); err != nil { + log.Printf("error connecting to server: %v", err) + return err + } + return nil +} + +func (c *Controller) testConnectionAndUpdateDialogText(dlg *dialogs.AddEditServerDialog) bool { + dlg.SetInfoText(lang.L("Testing connection") + "...") + conn := backend.ServerConnection{ + ServerType: dlg.ServerType, + Hostname: dlg.Host, + AltHostname: dlg.AltHost, + Username: dlg.Username, + LegacyAuth: dlg.LegacyAuth, + } + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + err := c.App.ServerManager.TestConnectionAndAuth(ctx, conn, dlg.Password) + if err == backend.ErrUnreachable { + dlg.SetErrorText(lang.L("Could not reach server") + fmt.Sprintf(" (%s?)", lang.L("wrong URL"))) + return false + } else if err != nil { + dlg.SetErrorText(lang.L("Authentication failed") + fmt.Sprintf(" (%s)", lang.L("wrong username/password"))) + return false + } + return true +} diff --git a/ui/dialogs/quicksearch.go b/ui/dialogs/quicksearch.go index 8a557e5..96113da 100644 --- a/ui/dialogs/quicksearch.go +++ b/ui/dialogs/quicksearch.go @@ -5,31 +5,115 @@ import ( "fyne.io/fyne/v2" "fyne.io/fyne/v2/lang" + "fyne.io/fyne/v2/theme" + "fyne.io/fyne/v2/widget" "github.com/dweymouth/supersonic/backend/mediaprovider" + myTheme "github.com/dweymouth/supersonic/ui/theme" "github.com/dweymouth/supersonic/ui/util" ) type QuickSearch struct { SearchDialog *SearchDialog + results []*mediaprovider.SearchResult mp mediaprovider.MediaProvider + + OnPlay func(t mediaprovider.ContentType, id string, item any, shuffle bool) + OnAddToQueue func(t mediaprovider.ContentType, id string, item any, next bool) + OnAddToPlaylist func(t mediaprovider.ContentType, id string, item any) + OnSetFavorite func(trackID string, fav bool) + OnSetRating func(trackID string, rating int) + OnDownload func(track *mediaprovider.Track) + OnShare func(trackID string) + OnPlaySongRadio func(track *mediaprovider.Track) + OnShowTrackInfo func(track *mediaprovider.Track) } func NewQuickSearch(mp mediaprovider.MediaProvider, im util.ImageFetcher) *QuickSearch { q := &QuickSearch{mp: mp} q.SearchDialog = NewSearchDialog(im, lang.L("Search Everywhere"), lang.L("Close"), q.onSearched) + q.SearchDialog.OnShowContextMenu = q.showMenu return q } func (q *QuickSearch) onSearched(query string) []*mediaprovider.SearchResult { - var results []*mediaprovider.SearchResult if query != "" { if res, err := q.mp.SearchAll(query, 20); err != nil { + q.results = nil log.Printf("Error searching: %s", err.Error()) } else { - results = res + q.results = res } } - return results + return q.results +} + +func (q *QuickSearch) showMenu(idx int, pos fyne.Position) { + cType := q.results[idx].Type + id := q.results[idx].ID + item := q.results[idx].Item + + canvas := fyne.CurrentApp().Driver().CanvasForObject(q.SearchDialog) + + switch cType { + case mediaprovider.ContentTypeTrack: + menu := util.NewTrackContextMenu(false, nil) + menu.OnPlay = func(shuffle bool) { + q.OnPlay(cType, id, item, shuffle) + } + menu.OnAddToQueue = func(next bool) { + q.OnAddToQueue(cType, id, item, next) + } + menu.OnAddToPlaylist = func() { + q.OnAddToPlaylist(cType, id, item) + } + menu.OnDownload = func() { + q.OnDownload(item.(*mediaprovider.Track)) + } + menu.OnFavorite = func(fav bool) { + q.OnSetFavorite(id, fav) + } + menu.OnSetRating = func(rating int) { + q.OnSetRating(id, rating) + } + menu.OnPlaySongRadio = func() { + q.OnPlaySongRadio(item.(*mediaprovider.Track)) + } + menu.OnShowInfo = func() { + q.OnShowTrackInfo(item.(*mediaprovider.Track)) + } + menu.OnShare = func() { + q.OnShare(id) + } + menu.ShowAtPosition(pos, canvas) + default: + play := fyne.NewMenuItem(lang.L("Play"), func() { + q.OnPlay(cType, id, item, false) + }) + play.Icon = theme.MediaPlayIcon() + shuffle := fyne.NewMenuItem(lang.L("Shuffle"), func() { + q.OnPlay(cType, id, item, true) + }) + shuffle.Icon = myTheme.ShuffleIcon + playNext := fyne.NewMenuItem(lang.L("Play next"), func() { + q.OnAddToQueue(cType, id, item, true) + }) + playNext.Icon = myTheme.PlayNextIcon + add := fyne.NewMenuItem(lang.L("Add to queue"), func() { + q.OnAddToQueue(cType, id, item, false) + }) + add.Icon = theme.ContentAddIcon() + menu := fyne.NewMenu("", play, shuffle, playNext, add) + + if cType != mediaprovider.ContentTypeRadioStation { + playlist := fyne.NewMenuItem(lang.L("Add to playlist")+"...", func() { + q.OnAddToPlaylist(cType, id, item) + }) + playlist.Icon = myTheme.PlaylistIcon + menu.Items = append(menu.Items, playlist) + } + + widget.ShowPopUpMenuAtPosition(menu, canvas, pos) + } } func (q *QuickSearch) SetOnDismiss(onDismiss func()) { diff --git a/ui/dialogs/searchdialog.go b/ui/dialogs/searchdialog.go index 58801c9..411c974 100644 --- a/ui/dialogs/searchdialog.go +++ b/ui/dialogs/searchdialog.go @@ -30,9 +30,10 @@ type SearchDialog struct { // of the dismiss buttons ActionItem fyne.CanvasObject - OnDismiss func() - OnNavigateTo func(mediaprovider.ContentType, string) - OnSearched func(string) []*mediaprovider.SearchResult + OnDismiss func() + OnNavigateTo func(mediaprovider.ContentType, string) + OnShowContextMenu func(itemIdx int, pos fyne.Position) + OnSearched func(string) []*mediaprovider.SearchResult imgSource util.ImageFetcher resultsMutex sync.RWMutex @@ -326,6 +327,12 @@ func (q *searchResult) Tapped(_ *fyne.PointEvent) { q.parent.onSelected(q.index) } +func (q *searchResult) TappedSecondary(e *fyne.PointEvent) { + if q.parent.OnShowContextMenu != nil { + q.parent.OnShowContextMenu(q.index, e.AbsolutePosition) + } +} + func (q *searchResult) CreateRenderer() fyne.WidgetRenderer { if q.content == nil { q.content = container.NewBorder(nil, nil, container.NewCenter(q.image), nil,