Files
supersonic/ui/controller/controller.go
T
natilou f190aaf732 Ability to download tracks
- Adding the option to download the selected track or tracks.
When the download is clicked it opens a dialog that lets you choose where
you want to save the track and the file name (by default
the file name is the track number, the track name and the extension, example "03 - my song.mp3")
If there are many tracks selected it will open one dialog for each track
- Next steps: download an album and a playlist using zip
2023-06-15 10:12:53 -03:00

569 lines
16 KiB
Go

package controller
import (
"fmt"
"image"
"io"
"log"
"os"
"strings"
"time"
"github.com/dweymouth/supersonic/backend"
"github.com/dweymouth/supersonic/backend/mediaprovider"
"github.com/dweymouth/supersonic/player"
"github.com/dweymouth/supersonic/sharedutil"
"github.com/dweymouth/supersonic/ui/dialogs"
"github.com/dweymouth/supersonic/ui/util"
"github.com/dweymouth/supersonic/ui/widgets"
"fyne.io/fyne/v2"
"fyne.io/fyne/v2/canvas"
"fyne.io/fyne/v2/dialog"
"fyne.io/fyne/v2/layout"
"fyne.io/fyne/v2/widget"
)
type NavigationHandler func(Route)
type ReloadFunc func()
type CurPageFunc func() Route
type Controller struct {
AppVersion string
MainWindow fyne.Window
App *backend.App
NavHandler NavigationHandler
CurPageFunc CurPageFunc
ReloadFunc ReloadFunc
escapablePopUp *widget.PopUp
haveModal bool
runOnModalClosed func()
}
func (m *Controller) NavigateTo(route Route) {
m.NavHandler(route)
}
func (m *Controller) ClosePopUpOnEscape(pop *widget.PopUp) {
m.escapablePopUp = pop
}
func (m *Controller) CloseEscapablePopUp() {
if m.escapablePopUp != nil {
m.escapablePopUp.Hide()
m.escapablePopUp = nil
m.doModalClosed()
}
}
// If there is currently no modal popup managed by the Controller visible,
// then run f (which should create and show a modal dialog) immediately.
// else run f when the current modal dialog workflow has ended.
func (m *Controller) QueueShowModalFunc(f func()) {
if m.haveModal {
m.runOnModalClosed = f
} else {
f()
}
}
func (m *Controller) ShowPopUpImage(img image.Image) {
im := canvas.NewImageFromImage(img)
im.FillMode = canvas.ImageFillContain
pop := widget.NewPopUp(im, m.MainWindow.Canvas())
s := m.MainWindow.Canvas().Size()
var popS fyne.Size
if asp := util.ImageAspect(img); s.Width/s.Height > asp {
// window height is limiting factor
h := s.Height * 0.8
popS = fyne.NewSize(h*asp, h)
} else {
w := s.Width * 0.8
popS = fyne.NewSize(w, w*(1/asp))
}
m.ClosePopUpOnEscape(pop)
pop.Resize(popS)
pop.ShowAtPosition(fyne.NewPos(
(s.Width-popS.Width)/2,
(s.Height-popS.Height)/2,
))
}
func (m *Controller) ConnectTracklistActions(tracklist *widgets.Tracklist) {
tracklist.OnAddToPlaylist = m.DoAddTracksToPlaylistWorkflow
tracklist.OnAddToQueue = func(tracks []*mediaprovider.Track) {
m.App.PlaybackManager.LoadTracks(tracks, true, false)
}
tracklist.OnPlayTrackAt = func(idx int) {
m.App.PlaybackManager.LoadTracks(tracklist.GetTracks(), false, false)
m.App.PlaybackManager.PlayTrackAt(idx)
}
tracklist.OnPlaySelection = func(tracks []*mediaprovider.Track, shuffle bool) {
m.App.PlaybackManager.LoadTracks(tracks, false, shuffle)
m.App.PlaybackManager.PlayFromBeginning()
}
tracklist.OnSetFavorite = m.SetTrackFavorites
tracklist.OnSetRating = m.SetTrackRatings
tracklist.OnShowAlbumPage = func(albumID string) {
m.NavigateTo(AlbumRoute(albumID))
}
tracklist.OnShowArtistPage = func(artistID string) {
m.NavigateTo(ArtistRoute(artistID))
}
tracklist.OnColumnVisibilityMenuShown = func(pop *widget.PopUp) {
m.ClosePopUpOnEscape(pop)
}
tracklist.OnShowDownloadDialog = m.ShowDownloadDialog
}
func (m *Controller) ConnectAlbumGridActions(grid *widgets.GridView) {
grid.OnAddToQueue = func(albumID string) {
m.App.PlaybackManager.LoadAlbum(albumID, true, false)
}
grid.OnPlay = func(albumID string, shuffle bool) {
m.App.PlaybackManager.PlayAlbum(albumID, 0, shuffle)
}
grid.OnShowItemPage = func(albumID string) {
m.NavigateTo(AlbumRoute(albumID))
}
grid.OnShowSecondaryPage = func(artistID string) {
m.NavigateTo(ArtistRoute(artistID))
}
grid.OnAddToPlaylist = func(albumID string) {
go func() {
album, err := m.App.ServerManager.Server.GetAlbum(albumID)
if err != nil {
log.Printf("error loading album: %s", err.Error())
return
}
m.DoAddTracksToPlaylistWorkflow(sharedutil.TracksToIDs(album.Tracks))
}()
}
}
func (m *Controller) ConnectArtistGridActions(grid *widgets.GridView) {
grid.OnShowItemPage = func(id string) { m.NavigateTo(ArtistRoute(id)) }
grid.OnPlay = func(artistID string, shuffle bool) { go m.PlayArtistDiscography(artistID, shuffle) }
grid.OnAddToQueue = func(artistID string) {
go m.App.PlaybackManager.LoadTracks(m.GetArtistTracks(artistID), true /*append*/, false /*shuffle*/)
}
grid.OnAddToPlaylist = func(artistID string) {
go m.DoAddTracksToPlaylistWorkflow(
sharedutil.TracksToIDs(m.GetArtistTracks(artistID)))
}
}
func (m *Controller) GetArtistTracks(artistID string) []*mediaprovider.Track {
server := m.App.ServerManager.Server
if server == nil {
log.Println("error playing artist discography: logged out")
return nil
}
artist, err := server.GetArtist(artistID)
if err != nil {
log.Printf("error getting artist discography: %v", err.Error())
return nil
}
var allTracks []*mediaprovider.Track
for _, album := range artist.Albums {
album, err := server.GetAlbum(album.ID)
if err != nil {
log.Printf("error loading album tracks: %v", err.Error())
return nil
}
allTracks = append(allTracks, album.Tracks...)
}
return allTracks
}
func (m *Controller) PlayArtistDiscography(artistID string, shuffle bool) {
m.App.PlaybackManager.LoadTracks(m.GetArtistTracks(artistID), false, shuffle)
m.App.PlaybackManager.PlayFromBeginning()
}
func (m *Controller) PromptForFirstServer() {
d := dialogs.NewAddEditServerDialog("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{
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 prompt for 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
}
plNames := make([]string, 0, len(pls))
for _, pl := range pls {
plNames = append(plNames, pl.Name)
}
dlg := dialogs.NewAddToPlaylistDialog("Add to Playlist", plNames)
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)
} else {
go m.App.ServerManager.Server.EditPlaylistTracks(
pls[playlistChoice].ID, trackIDs, nil /*tracksToRemove*/)
}
}
m.haveModal = true
pop.Show()
}()
}
func (m *Controller) DoEditPlaylistWorkflow(playlist *mediaprovider.Playlist) {
dlg := dialogs.NewEditPlaylistDialog(playlist)
pop := widget.NewModalPopUp(dlg, m.MainWindow.Canvas())
m.ClosePopUpOnEscape(pop)
dlg.OnCanceled = func() {
pop.Hide()
m.doModalClosed()
}
dlg.OnDeletePlaylist = func() {
pop.Hide()
dialog.ShowCustomConfirm("Confirm Delete Playlist", "OK", "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
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
m.ReloadFunc()
}
}()
}
m.haveModal = true
pop.Show()
}
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()
} else {
if err := c.tryConnectToServer(server, pass); err != nil {
dlg := dialog.NewError(err, c.MainWindow)
dlg.SetOnClosed(func() {
c.PromptForLoginAndConnect()
})
c.haveModal = true
dlg.Show()
}
}
}
func (m *Controller) PromptForLoginAndConnect() {
// TODO: this will need to be rewritten a bit when we support multi servers
// need to make sure the intended server is first in the list passed to NewLoginDialog
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("Testing connection...")
go func() {
err := m.App.ServerManager.TestConnectionAndAuth(server.ServerConnection, password, 5*time.Second)
if err == backend.ErrUnreachable {
d.SetErrorText("Server unreachable")
} else if err != nil {
d.SetErrorText("Authentication failed")
} else {
pop.Hide()
m.trySetPasswordAndConnectToServer(server, password)
m.doModalClosed()
}
d.EnableSubmit()
}()
}
d.OnEditServer = func(server *backend.ServerConfig) {
pop.Hide()
editD := dialogs.NewAddEditServerDialog("Edit server", true, server, m.MainWindow.Canvas().Focus)
editPop := widget.NewModalPopUp(editD, m.MainWindow.Canvas())
editD.OnSubmit = func() {
d.DisableSubmit()
go func() {
if m.testConnectionAndUpdateDialogText(editD) {
// 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("Add server", true, nil, m.MainWindow.Canvas().Focus)
newPop := widget.NewModalPopUp(newD, m.MainWindow.Canvas())
newD.OnSubmit = func() {
d.DisableSubmit()
go func() {
if m.testConnectionAndUpdateDialogText(newD) {
// connection is good
newPop.Hide()
conn := backend.ServerConnection{
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("Confirm delete server",
fmt.Sprintf("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())
dlg.OnDismiss = func() {
pop.Hide()
c.doModalClosed()
}
c.ClosePopUpOnEscape(pop)
c.haveModal = true
pop.Show()
}
func (c *Controller) ShowSettingsDialog(themeUpdateCallbk func()) {
devs, err := c.App.Player.ListAudioDevices()
if err != nil {
log.Printf("error listing audio devices: %v", err)
devs = []player.AudioDevice{{Name: "auto", Description: "Autoselect device"}}
}
dlg := dialogs.NewSettingsDialog(c.App.Config, devs, c.MainWindow)
dlg.OnReplayGainSettingsChanged = func() {
c.App.PlaybackManager.SetReplayGainOptions(c.App.Config.ReplayGain)
}
dlg.OnAudioExclusiveSettingChanged = func() {
c.App.Player.SetAudioExclusive(c.App.Config.LocalPlayback.AudioExclusive)
}
dlg.OnAudioDeviceSettingChanged = func() {
c.App.Player.SetAudioDevice(c.App.Config.LocalPlayback.AudioDeviceName)
}
dlg.OnThemeSettingChanged = themeUpdateCallbk
pop := widget.NewModalPopUp(dlg, c.MainWindow.Canvas())
dlg.OnDismiss = func() {
pop.Hide()
c.doModalClosed()
}
c.ClosePopUpOnEscape(pop)
c.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(server, password)
}
func (c *Controller) tryConnectToServer(server *backend.ServerConfig, password string) error {
if err := c.App.ServerManager.TestConnectionAndAuth(server.ServerConnection, password, 10*time.Second); 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("Testing connection...")
conn := backend.ServerConnection{
Hostname: dlg.Host,
AltHostname: dlg.AltHost,
Username: dlg.Username,
LegacyAuth: dlg.LegacyAuth,
}
err := c.App.ServerManager.TestConnectionAndAuth(conn, dlg.Password, 5*time.Second)
if err == backend.ErrUnreachable {
dlg.SetErrorText("Could not reach server (wrong hostname?)")
return false
} else if err != nil {
dlg.SetErrorText("Authentication failed (wrong username/password)")
return false
}
return true
}
func (c *Controller) doModalClosed() {
c.haveModal = false
if c.runOnModalClosed != nil {
c.runOnModalClosed()
c.runOnModalClosed = nil
}
}
func (c *Controller) SetTrackFavorites(trackIDs []string, favorite bool) {
go c.App.ServerManager.Server.SetFavorite(mediaprovider.RatingFavoriteParameters{
TrackIDs: trackIDs,
}, favorite)
for _, id := range trackIDs {
c.App.PlaybackManager.OnTrackFavoriteStatusChanged(id, favorite)
}
}
func (c *Controller) SetTrackRatings(trackIDs []string, rating int) {
go c.App.ServerManager.Server.SetRating(mediaprovider.RatingFavoriteParameters{
TrackIDs: trackIDs,
}, rating)
// Notify PlaybackManager of rating change to update
// the in-memory track models
for _, id := range trackIDs {
c.App.PlaybackManager.OnTrackRatingChanged(id, rating)
}
}
func (c *Controller) ShowDownloadDialog(track *mediaprovider.Track) {
parts := strings.Split(track.FilePath, "/")
fileName := parts[len(parts)-1]
dg := dialog.NewFileSave(
func(file fyne.URIWriteCloser, err error) {
if err != nil {
log.Println(err)
return
}
if file == nil {
return
}
filePath := file.URI().Path()
go c.downloadTrack(track.ID, filePath)
},
c.MainWindow)
dg.SetFileName(fileName)
dg.Show()
}
func (c *Controller) downloadTrack(trackID, filePath string) {
reader, err := c.App.ServerManager.Server.DownloadTrack(trackID)
if err != nil {
log.Println(err)
return
}
saveDir, err := os.Create(filePath)
if err != nil {
log.Println(err)
return
}
defer saveDir.Close()
_, err = io.Copy(saveDir, reader)
if err != nil {
log.Println(err)
return
}
log.Printf("Saved song to: %s\n", saveDir.Name())
}