package controller import ( "archive/zip" "fmt" "image" "image/color" "io" "log" "net/url" "os" "path/filepath" "sync" "time" fynetooltip "github.com/dweymouth/fyne-tooltip" "github.com/dweymouth/supersonic/backend" "github.com/dweymouth/supersonic/backend/mediaprovider" "github.com/dweymouth/supersonic/backend/player" "github.com/dweymouth/supersonic/backend/player/mpv" "github.com/dweymouth/supersonic/sharedutil" "github.com/dweymouth/supersonic/ui/dialogs" myTheme "github.com/dweymouth/supersonic/ui/theme" "github.com/dweymouth/supersonic/ui/util" "github.com/dweymouth/supersonic/ui/widgets" "fyne.io/fyne/v2" "fyne.io/fyne/v2/container" "fyne.io/fyne/v2/dialog" "fyne.io/fyne/v2/lang" "fyne.io/fyne/v2/theme" "fyne.io/fyne/v2/widget" ) type NavigationHandler func(Route) type CurPageFunc func() Route type ToastProvider interface { ShowSuccessToast(string) ShowErrorToast(string) } type Controller struct { visualizationData AppVersion string App *backend.App MainWindow fyne.Window // dependencies injected from MainWindow NavHandler NavigationHandler CurPageFunc CurPageFunc ReloadFunc func() RefreshPageFunc func() SelectAllPageFunc func() UnselectAllPageFunc func() ToastProvider ToastProvider popUpQueueMutex sync.Mutex popUpQueue *widget.PopUp popUpQueueList *widgets.PlayQueueList popUpQueueLastUsed int64 escapablePopUp fyne.CanvasObject haveModal bool runOnModalClosed func() } func New(app *backend.App, appVersion string, mainWindow fyne.Window) *Controller { c := &Controller{ AppVersion: appVersion, MainWindow: mainWindow, App: app, } c.initVisualizations() c.App.PlaybackManager.OnQueueChange(func() { c.popUpQueueMutex.Lock() defer c.popUpQueueMutex.Unlock() if c.popUpQueue != nil { c.popUpQueueList.SetItems(c.App.PlaybackManager.GetPlayQueue()) } }) c.App.PlaybackManager.OnSongChange(func(track mediaprovider.MediaItem, _ *mediaprovider.Track) { c.popUpQueueMutex.Lock() defer c.popUpQueueMutex.Unlock() if c.popUpQueue == nil { return } if track == nil { c.popUpQueueList.SetNowPlaying("") } else { c.popUpQueueList.SetNowPlaying(track.Metadata().ID) } }) return c } func (m *Controller) SelectAll() { m.popUpQueueMutex.Lock() if m.popUpQueue != nil && m.popUpQueue.Visible() { m.popUpQueueList.SelectAll() m.popUpQueueMutex.Unlock() return } m.popUpQueueMutex.Unlock() if m.SelectAllPageFunc != nil { m.SelectAllPageFunc() } } func (m *Controller) UnselectAll() { m.popUpQueueMutex.Lock() if m.popUpQueue != nil && m.popUpQueue.Visible() { m.popUpQueueList.UnselectAll() m.popUpQueueMutex.Unlock() return } m.popUpQueueMutex.Unlock() if m.SelectAllPageFunc != nil { m.UnselectAllPageFunc() } } func (m *Controller) NavigateTo(route Route) { m.NavHandler(route) } func (m *Controller) ClosePopUpOnEscape(pop fyne.CanvasObject) { 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) HaveModal() bool { return m.haveModal } func (m *Controller) ShowCastMenu() { devices := m.App.PlaybackManager.RemotePlayers() menu := fyne.NewMenu("") menu.Items = append(menu.Items, fyne.NewMenuItem(lang.L("Local player"), func() { m.App.PlaybackManager.SetRemotePlayer(nil) })) for _, d := range devices { _d := d menu.Items = append(menu.Items, fyne.NewMenuItem(d.Name, func() { m.App.PlaybackManager.SetRemotePlayer(&_d) })) } pop := widget.NewPopUpMenu(menu, m.MainWindow.Canvas()) canvasSize := m.MainWindow.Canvas().Size() pop.ShowAtPosition(fyne.NewPos( canvasSize.Width-pop.MinSize().Width-10, canvasSize.Height-pop.MinSize().Height-100, )) } func (m *Controller) ShowPopUpPlayQueue() { m.popUpQueueMutex.Lock() if m.popUpQueue == nil { m.popUpQueueList = widgets.NewPlayQueueList(m.App.ImageManager, false) m.popUpQueueList.Reorderable = true m.popUpQueueList.SetItems(m.App.PlaybackManager.GetPlayQueue()) m.ConnectPlayQueuelistActions(m.popUpQueueList) title := widget.NewRichTextWithText(lang.L("Play Queue")) title.Segments[0].(*widget.TextSegment).Style.Alignment = fyne.TextAlignCenter title.Segments[0].(*widget.TextSegment).Style.TextStyle.Bold = true ctr := container.NewBorder(title, nil, nil, nil, container.NewPadded(m.popUpQueueList), ) m.popUpQueue = widget.NewPopUp(ctr, m.MainWindow.Canvas()) fynetooltip.AddPopUpToolTipLayer(m.popUpQueue) container.NewThemeOverride(m.popUpQueue, myTheme.WithColorTransformOverride( theme.ColorNameOverlayBackground, func(c color.Color) color.Color { if nrgba, ok := c.(color.NRGBA); ok { nrgba.A = 245 return nrgba } c_ := c.(color.RGBA) c_.A = 245 return c_ }, )) // free popUpQueue if it hasn't been used in awhile go func() { t := time.NewTicker(1 * time.Minute) for range t.C { m.popUpQueueMutex.Lock() now := time.Now().UnixMilli() if m.popUpQueueLastUsed < now-120_000 /*2 min*/ { fynetooltip.DestroyPopUpToolTipLayer(m.popUpQueue) m.popUpQueue = nil m.popUpQueueList = nil m.popUpQueueLastUsed = 0 m.popUpQueueMutex.Unlock() t.Stop() return } m.popUpQueueMutex.Unlock() } }() } m.popUpQueueLastUsed = time.Now().UnixMilli() popUpQueueList := m.popUpQueueList pop := m.popUpQueue m.popUpQueueMutex.Unlock() npID := "" if np := m.App.PlaybackManager.NowPlaying(); np != nil { npID = np.Metadata().ID } popUpQueueList.SetNowPlaying(npID) popUpQueueList.UnselectAll() m.ClosePopUpOnEscape(pop) minSize := fyne.NewSize(300, 400) maxSize := fyne.NewSize(800, 1000) canvasSize := m.MainWindow.Canvas().Size() size := minSize.Max(maxSize.Min( fyne.NewSize(canvasSize.Width*0.4, canvasSize.Height*0.5), )) pop.Resize(size) popUpQueueList.ScrollToNowPlaying() // must come after resize pop.ShowAtPosition(fyne.NewPos( canvasSize.Width-size.Width-10, canvasSize.Height-size.Height-100, )) } func (m *Controller) ShowPopUpImage(img image.Image) { 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)) } pop := widgets.NewImagePopUp(img, m.MainWindow.Canvas(), popS) m.ClosePopUpOnEscape(pop) pop.Show() } func (m *Controller) GetArtistTracks(artistID string) []*mediaprovider.Track { if mp := m.App.ServerManager.Server; mp != nil { if tr, err := mp.GetArtistTracks(artistID); err != nil { log.Println(err.Error()) return nil } else { return tr } } return nil } 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(), themeFiles map[string]string) { devs, err := c.App.LocalPlayer.ListAudioDevices() if err != nil { log.Printf("error listing audio devices: %v", err) devs = []mpv.AudioDevice{{Name: "auto", Description: lang.L("Autoselect device")}} } curPlayer := c.App.PlaybackManager.CurrentPlayer() _, isReplayGainPlayer := curPlayer.(player.ReplayGainPlayer) _, isEqualizerPlayer := curPlayer.(*mpv.Player) _, canSavePlayQueue := c.App.ServerManager.Server.(mediaprovider.CanSavePlayQueue) isLocalPlayer := isEqualizerPlayer bands := c.App.LocalPlayer.Equalizer().BandFrequencies() dlg := dialogs.NewSettingsDialog(c.App.Config, devs, themeFiles, bands, c.App.ServerManager.Server.ClientDecidesScrobble(), isLocalPlayer, isReplayGainPlayer, isEqualizerPlayer, canSavePlayQueue, c.MainWindow) dlg.OnReplayGainSettingsChanged = func() { c.App.PlaybackManager.SetReplayGainOptions(c.App.Config.ReplayGain) } dlg.OnAudioExclusiveSettingChanged = func() { c.App.LocalPlayer.SetAudioExclusive(c.App.Config.LocalPlayback.AudioExclusive) } dlg.OnAudioDeviceSettingChanged = func() { c.App.LocalPlayer.SetAudioDevice(c.App.Config.LocalPlayback.AudioDeviceName) } dlg.OnThemeSettingChanged = themeUpdateCallbk dlg.OnEqualizerSettingsChanged = func() { // currently we only have one equalizer type eq := c.App.LocalPlayer.Equalizer().(*mpv.ISO15BandEqualizer) eq.Disabled = !c.App.Config.LocalPlayback.EqualizerEnabled eq.EQPreamp = c.App.Config.LocalPlayback.EqualizerPreamp copy(eq.BandGains[:], c.App.Config.LocalPlayback.GraphicEqualizerBands) c.App.LocalPlayer.SetEqualizer(eq) } dlg.OnPageNeedsRefresh = c.RefreshPageFunc pop := widget.NewModalPopUp(dlg, c.MainWindow.Canvas()) fynetooltip.AddPopUpToolTipLayer(pop) dlg.OnDismiss = func() { pop.Hide() fynetooltip.DestroyPopUpToolTipLayer(pop) c.doModalClosed() c.App.SaveConfigFile() } c.ClosePopUpOnEscape(pop) c.haveModal = true pop.Show() } 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) { r, ok := c.App.ServerManager.Server.(mediaprovider.SupportsRating) if !ok { return } go r.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) ShowShareDialog(id string) { go func() { shareUrl, err := c.createShareURL(id) if err != nil { return } fyne.Do(func() { hyperlink := widget.NewHyperlink(shareUrl.String(), shareUrl) dlg := dialog.NewCustom(lang.L("Share content"), lang.L("OK"), container.NewHBox( hyperlink, widget.NewButtonWithIcon("", theme.ContentCopyIcon(), func() { c.MainWindow.Clipboard().SetContent(hyperlink.Text) }), widget.NewButtonWithIcon("", theme.ViewRefreshIcon(), func() { if shareUrl, err := c.createShareURL(id); err == nil { hyperlink.Text = shareUrl.String() hyperlink.URL = shareUrl hyperlink.Refresh() } }), ), c.MainWindow, ) dlg.Show() }) }() } func (c *Controller) createShareURL(id string) (*url.URL, error) { r, ok := c.App.ServerManager.Server.(mediaprovider.SupportsSharing) if !ok { return nil, fmt.Errorf("server does not support sharing") } shareUrl, err := r.CreateShareURL(id) if err != nil { log.Printf("error creating share URL: %v", err) c.showError( "Failed to share content. This commonly occurs when the server does not support sharing, " + "or has the feature disabled.\nPlease check the server's settings and try again.", ) return nil, err } return shareUrl, nil } func (c *Controller) ShowDownloadDialog(tracks []*mediaprovider.Track, downloadName string) { numTracks := len(tracks) var fileName string if numTracks == 1 { fileName = filepath.Base(tracks[0].FilePath) } else { fileName = "downloaded_tracks.zip" } dg := dialog.NewFileSave( func(file fyne.URIWriteCloser, err error) { if err != nil { log.Println(err) return } if file == nil { return } if numTracks == 1 { go c.downloadTrack(tracks[0], file.URI().Path()) } else { go c.downloadTracks(tracks, file.URI().Path(), downloadName) } }, c.MainWindow) dg.SetFileName(fileName) dg.Show() } func (c *Controller) downloadTrack(track *mediaprovider.Track, filePath string) { reader, err := c.App.ServerManager.Server.DownloadTrack(track.ID) if err != nil { log.Println(err) return } file, err := os.Create(filePath) if err != nil { log.Println(err) return } defer file.Close() _, err = io.Copy(file, reader) if err != nil { log.Println(err) return } log.Printf("Saved song %s to: %s\n", track.Title, filePath) c.sendNotification(fmt.Sprintf(lang.L("Download completed")+": %s", track.Title), fmt.Sprintf(lang.L("Saved at")+": %s", filePath)) } func (c *Controller) downloadTracks(tracks []*mediaprovider.Track, filePath, downloadName string) { zipFile, err := os.Create(filePath) if err != nil { log.Println(err) return } defer zipFile.Close() zipWriter := zip.NewWriter(zipFile) defer zipWriter.Close() for _, track := range tracks { reader, err := c.App.ServerManager.Server.DownloadTrack(track.ID) if err != nil { log.Println(err) continue } fileName := filepath.Base(track.FilePath) fileWriter, err := zipWriter.Create(fileName) if err != nil { log.Println(err) continue } _, err = io.Copy(fileWriter, reader) if err != nil { log.Println(err) continue } log.Printf("Saved song %s to: %s\n", track.Title, filePath) } c.sendNotification(fmt.Sprintf(lang.L("Download completed")+": %s", downloadName), fmt.Sprintf("Saved at: %s", filePath)) } func (c *Controller) sendNotification(title, content string) { fyne.CurrentApp().SendNotification(&fyne.Notification{ Title: title, Content: content, }) } func (c *Controller) showError(content string) { // TODO: display an in-app toast message instead of a dialog. dialog.ShowError(fmt.Errorf(content), c.MainWindow) } func (c *Controller) ShowAlbumInfoDialog(albumID, albumName string, albumCover image.Image) { go func() { albumInfo, err := c.App.ServerManager.Server.GetAlbumInfo(albumID) if err != nil { log.Print("Error getting album info: ", err) return } fyne.Do(func() { dlg := dialogs.NewAlbumInfoDialog(albumInfo, albumName, albumCover) 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) ShowTrackInfoDialog(track *mediaprovider.Track) { info := dialogs.NewTrackInfoDialog(track) pop := widget.NewModalPopUp(info, c.MainWindow.Canvas()) info.OnDismiss = func() { pop.Hide() c.doModalClosed() } info.OnNavigateToAlbum = func(albumID string) { info.OnDismiss() c.NavigateTo(AlbumRoute(albumID)) } info.OnNavigateToArtist = func(artistID string) { info.OnDismiss() c.NavigateTo(ArtistRoute(artistID)) } info.OnNavigateToGenre = func(genre string) { info.OnDismiss() c.NavigateTo(GenreRoute(genre)) } info.OnCopyFilePath = func() { c.MainWindow.Clipboard().SetContent(track.FilePath) } c.ClosePopUpOnEscape(pop) winSize := c.MainWindow.Canvas().Size() popMin := pop.MinSize() width := fyne.Min(750, fyne.Max(popMin.Width, winSize.Width*0.8)) height := fyne.Min(650, fyne.Max(popMin.Height, winSize.Height*0.8)) pop.Resize(fyne.NewSize(width, height)) c.haveModal = true pop.Show() } func (c *Controller) GetSongRadioTracks(sourceTrack *mediaprovider.Track) ([]*mediaprovider.Track, error) { radioTracks, err := c.App.ServerManager.Server.GetSongRadio(sourceTrack.ID, 100) if err != nil { return nil, fmt.Errorf("error getting song radio: %s", err.Error()) } // The goal of this implementation is to place the source track first in the queue. filteredTracks := sharedutil.FilterSlice(radioTracks, func(track *mediaprovider.Track) bool { return track.ID != sourceTrack.ID }) tracks := []*mediaprovider.Track{sourceTrack} tracks = append(tracks, filteredTracks...) return tracks, nil }