add context menu actions to search dialog, refactor controller

This commit is contained in:
Drew Weymouth
2025-03-29 13:25:56 -07:00
parent 9ddb5e6e69
commit 31c23490d1
9 changed files with 693 additions and 423 deletions
-409
View File
@@ -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 {
+147
View File
@@ -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()
}
+178
View File
@@ -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")
}
}
+250
View File
@@ -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
}
+87 -3
View File
@@ -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()) {
+10 -3
View File
@@ -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,