diff --git a/CHANGELOG.md b/CHANGELOG.md index f0f7af6..23c8725 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ - [#21](https://github.com/dweymouth/supersonic/issues/21) Add ability to reorder tracks within a playlist (via context menu) - [#99](https://github.com/dweymouth/supersonic/issues/99) Add Year (asc + desc) sort orders to album page - [#102](https://github.com/dweymouth/supersonic/issues/102) Add ReplayGain support (requires files to be tagged on server and transcoding to preserve tags) +- [#106](https://github.com/dweymouth/supersonic/issues/106) Automatically check for updates ### Fixed - [#90](https://github.com/dweymouth/supersonic/issues/90) Wrong covers get loaded for albums if server has different IDs for album and cover art diff --git a/backend/app.go b/backend/app.go index 77a92fc..d26bd00 100644 --- a/backend/app.go +++ b/backend/app.go @@ -10,16 +10,12 @@ import ( "supersonic/backend/util" "supersonic/player" "supersonic/sharedutil" + "time" "github.com/20after4/configdir" "github.com/zalando/go-keyring" ) -const ( - AppName = "supersonic" - configFile = "config.toml" -) - var ( ErrNoServers = errors.New("no servers set up") ) @@ -31,21 +27,32 @@ type App struct { LibraryManager *LibraryManager PlaybackManager *PlaybackManager Player *player.Player + UpdateChecker UpdateChecker - bgrndCtx context.Context - cancel context.CancelFunc + appName string + appVersionTag string + configFile string + bgrndCtx context.Context + cancel context.CancelFunc } -func StartupApp() (*App, error) { - a := &App{} +func (a *App) VersionTag() string { + return a.appVersionTag +} + +func StartupApp(appName, appVersionTag, configFile, latestReleaseURL string) (*App, error) { + a := &App{appName: appName, appVersionTag: appVersionTag, configFile: configFile} a.bgrndCtx, a.cancel = context.WithCancel(context.Background()) - log.Printf("Starting %s...", AppName) - log.Printf("Using config dir: %s", configdir.LocalConfig(AppName)) - log.Printf("Using cache dir: %s", configdir.LocalCache(AppName)) + log.Printf("Starting %s...", appName) + log.Printf("Using config dir: %s", configdir.LocalConfig(appName)) + log.Printf("Using cache dir: %s", configdir.LocalCache(appName)) a.readConfig() + a.UpdateChecker = NewUpdateChecker(appVersionTag, latestReleaseURL, &a.Config.Application.LastCheckedVersion) + a.UpdateChecker.Start(a.bgrndCtx, 24*time.Hour) + if err := a.initMPV(); err != nil { return nil, err } @@ -62,10 +69,10 @@ func StartupApp() (*App, error) { PreampGain: a.Config.ReplayGain.PreampGainDB, }) - a.ServerManager = NewServerManager() + a.ServerManager = NewServerManager(appName) a.PlaybackManager = NewPlaybackManager(a.bgrndCtx, a.ServerManager, a.Player, &a.Config.Scrobbling) a.LibraryManager = NewLibraryManager(a.ServerManager) - a.ImageManager = NewImageManager(a.bgrndCtx, a.ServerManager, configdir.LocalCache(AppName)) + a.ImageManager = NewImageManager(a.bgrndCtx, a.ServerManager, configdir.LocalCache(a.appName)) a.LibraryManager.PreCacheCoverFn = func(coverID string) { _, _ = a.ImageManager.GetAlbumThumbnail(coverID) } @@ -74,23 +81,23 @@ func StartupApp() (*App, error) { } func (a *App) readConfig() { - configdir.MakePath(configdir.LocalConfig(AppName)) - cfgPath := configPath() - cfg, err := ReadConfigFile(cfgPath) + configdir.MakePath(configdir.LocalConfig(a.appName)) + cfgPath := a.configPath() + cfg, err := ReadConfigFile(cfgPath, a.appVersionTag) if err != nil { log.Printf("Error reading app config file: %v", err) - cfg = DefaultConfig() + cfg = DefaultConfig(a.appVersionTag) if _, err := os.Stat(cfgPath); err == nil { - backupCfgName := fmt.Sprintf("%s.bak", configFile) + backupCfgName := fmt.Sprintf("%s.bak", a.configFile) log.Printf("Config file may be malformed: copying to %s", backupCfgName) - _ = util.CopyFile(cfgPath, path.Join(configdir.LocalConfig(AppName), backupCfgName)) + _ = util.CopyFile(cfgPath, path.Join(configdir.LocalConfig(a.appName), backupCfgName)) } } a.Config = cfg } func (a *App) initMPV() error { - p := player.NewWithClientName(AppName) + p := player.NewWithClientName(a.appName) c := a.Config.LocalPlayback c.InMemoryCacheSizeMB = clamp(c.InMemoryCacheSizeMB, 10, 500) if err := p.Init(c.AudioExclusive, c.InMemoryCacheSizeMB); err != nil { @@ -100,12 +107,12 @@ func (a *App) initMPV() error { return nil } -func (a *App) LoginToDefaultServer() error { +func (a *App) LoginToDefaultServer(string) error { serverCfg := a.Config.GetDefaultServer() if serverCfg == nil { return ErrNoServers } - pass, err := keyring.Get(AppName, serverCfg.ID.String()) + pass, err := keyring.Get(a.appName, serverCfg.ID.String()) if err != nil { return fmt.Errorf("error reading keyring credentials: %v", err) } @@ -117,11 +124,11 @@ func (a *App) Shutdown() { a.Config.LocalPlayback.Volume = a.Player.GetVolume() a.cancel() a.Player.Destroy() - a.Config.WriteConfigFile(configPath()) + a.Config.WriteConfigFile(a.configPath()) } -func configPath() string { - return path.Join(configdir.LocalConfig(AppName), configFile) +func (a *App) configPath() string { + return path.Join(configdir.LocalConfig(a.appName), a.configFile) } func clamp(i, min, max int) int { diff --git a/backend/config.go b/backend/config.go index da2455f..c9a29ea 100644 --- a/backend/config.go +++ b/backend/config.go @@ -17,8 +17,9 @@ type ServerConfig struct { } type AppConfig struct { - WindowWidth int - WindowHeight int + WindowWidth int + WindowHeight int + LastCheckedVersion string } type AlbumPageConfig struct { @@ -79,11 +80,12 @@ type Config struct { ReplayGain ReplayGainConfig } -func DefaultConfig() *Config { +func DefaultConfig(appVersionTag string) *Config { return &Config{ Application: AppConfig{ - WindowWidth: 1000, - WindowHeight: 800, + WindowWidth: 1000, + WindowHeight: 800, + LastCheckedVersion: appVersionTag, }, AlbumPage: AlbumPageConfig{ TracklistColumns: []string{"Artist", "Time", "Plays", "Favorite"}, @@ -123,14 +125,14 @@ func DefaultConfig() *Config { } } -func ReadConfigFile(filepath string) (*Config, error) { +func ReadConfigFile(filepath, appVersionTag string) (*Config, error) { f, err := os.Open(filepath) if err != nil { return nil, err } defer f.Close() - c := DefaultConfig() + c := DefaultConfig(appVersionTag) if err := toml.NewDecoder(f).Decode(c); err != nil { return nil, err } diff --git a/backend/servermanager.go b/backend/servermanager.go index bce9938..216207b 100644 --- a/backend/servermanager.go +++ b/backend/servermanager.go @@ -14,14 +14,15 @@ type ServerManager struct { ServerID uuid.UUID Server *subsonic.Client + appName string onServerConnected []func() onLogout []func() } var ErrUnreachable = errors.New("server is unreachable") -func NewServerManager() *ServerManager { - return &ServerManager{} +func NewServerManager(appName string) *ServerManager { + return &ServerManager{appName: appName} } func (s *ServerManager) ConnectToServer(conf *ServerConfig, password string) error { @@ -75,7 +76,7 @@ func (s *ServerManager) testConnectionAndCreateClient(hostname, username, passwo func (s *ServerManager) Logout() { if s.Server != nil { - keyring.Delete(AppName, s.ServerID.String()) + keyring.Delete(s.appName, s.ServerID.String()) for _, cb := range s.onLogout { cb() } @@ -93,9 +94,9 @@ func (s *ServerManager) OnLogout(cb func()) { } func (s *ServerManager) GetServerPassword(server *ServerConfig) (string, error) { - return keyring.Get(AppName, server.ID.String()) + return keyring.Get(s.appName, server.ID.String()) } func (s *ServerManager) SetServerPassword(server *ServerConfig, password string) error { - return keyring.Set(AppName, server.ID.String(), password) + return keyring.Set(s.appName, server.ID.String(), password) } diff --git a/backend/updatechecker.go b/backend/updatechecker.go new file mode 100644 index 0000000..21e8236 --- /dev/null +++ b/backend/updatechecker.go @@ -0,0 +1,77 @@ +package backend + +import ( + "context" + "log" + "net/http" + "net/url" + "strings" + "time" +) + +type UpdateChecker struct { + OnUpdatedVersionFound func() + + versionTagFound string + latestReleaseURL string + appVersionTag string + lastCheckedTag *string +} + +func NewUpdateChecker(appVersionTag, latestReleaseURL string, lastCheckedTag *string) UpdateChecker { + return UpdateChecker{ + appVersionTag: appVersionTag, + latestReleaseURL: latestReleaseURL, + lastCheckedTag: lastCheckedTag, + } +} + +func (u *UpdateChecker) Start(ctx context.Context, interval time.Duration) { + go func() { + u.checkForUpdate() // check once at startup + t := time.NewTicker(interval) + for { + select { + case <-ctx.Done(): + t.Stop() + return + case <-t.C: + u.checkForUpdate() + } + } + }() +} + +func (u *UpdateChecker) VersionTagFound() string { + return u.versionTagFound +} + +func (u *UpdateChecker) LatestReleaseURL() *url.URL { + url, _ := url.Parse(u.latestReleaseURL) + return url +} + +func (u *UpdateChecker) checkForUpdate() { + t := u.CheckLatestVersionTag() + if t != "" && t != *u.lastCheckedTag { + u.versionTagFound = t + if u.OnUpdatedVersionFound != nil { + u.OnUpdatedVersionFound() + } + } +} + +func (u *UpdateChecker) CheckLatestVersionTag() string { + resp, err := http.Head(u.latestReleaseURL) + if err != nil { + log.Printf("failed to check for newest version: %s", err.Error()) + return "" + } + url := resp.Request.URL.String() + url = strings.TrimSuffix(url, "/") + idx := strings.LastIndex(url, "/") + if idx >= len(url)-1 { + return "" + } + return url[idx+1:] +} diff --git a/main.go b/main.go index cd30819..45a8532 100644 --- a/main.go +++ b/main.go @@ -2,7 +2,6 @@ package main import ( "log" - "path" "supersonic/backend" "supersonic/ui" "supersonic/ui/theme" @@ -10,22 +9,19 @@ import ( "fyne.io/fyne/v2" "fyne.io/fyne/v2/app" - "github.com/20after4/configdir" ) const ( - appname = "supersonic" - displayName = "Supersonic" - appVersion = "0.0.1-alpha2" - configFile = "config.toml" + appname = "supersonic" + displayName = "Supersonic" + appVersion = "0.0.1-alpha2" + appVersionTag = "v" + appVersion + configFile = "config.toml" + latestReleaseURL = "https://github.com/dweymouth/supersonic/releases/latest" ) -func configPath() string { - return path.Join(configdir.LocalConfig(appname), configFile) -} - func main() { - myApp, err := backend.StartupApp() + myApp, err := backend.StartupApp(appname, appVersionTag, configFile, latestReleaseURL) if err != nil { log.Fatalf("fatal startup error: %v", err.Error()) } diff --git a/ui/controller/controller.go b/ui/controller/controller.go index ee2dd0e..b2d71a7 100644 --- a/ui/controller/controller.go +++ b/ui/controller/controller.go @@ -25,15 +25,16 @@ type ReloadFunc func() type CurPageFunc func() Route type Controller struct { - // if not nil, this popup should be hidden when escape is pressed - EscapablePopUp *widget.PopUp - 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) { @@ -41,7 +42,26 @@ func (m *Controller) NavigateTo(route Route) { } func (m *Controller) ClosePopUpOnEscape(pop *widget.PopUp) { - m.EscapablePopUp = pop + 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) { @@ -110,6 +130,7 @@ func (m *Controller) PromptForFirstServer() { if m.testConnectionAndUpdateDialogText(d) { // connection is good pop.Hide() + m.doModalClosed() server := m.App.Config.AddServer(d.Nickname, d.Host, d.Username, d.LegacyAuth) if err := m.App.ServerManager.SetServerPassword(server, d.Password); err != nil { log.Printf("error setting keyring credentials: %v", err) @@ -120,6 +141,7 @@ func (m *Controller) PromptForFirstServer() { d.EnableSubmit() }() } + m.haveModal = true pop.Show() } @@ -144,6 +166,7 @@ func (m *Controller) DoAddTracksToPlaylistWorkflow(trackIDs []string) { dlg.OnCanceled = pop.Hide dlg.OnSubmit = func(playlistChoice int, newPlaylistName string) { pop.Hide() + m.doModalClosed() if playlistChoice < 0 { m.App.ServerManager.Server.CreatePlaylistWithTracks( trackIDs, map[string]string{"name": newPlaylistName}) @@ -152,6 +175,7 @@ func (m *Controller) DoAddTracksToPlaylistWorkflow(trackIDs []string) { pls[playlistChoice].ID, trackIDs, nil /*tracksToRemove*/) } } + m.haveModal = true pop.Show() } @@ -159,7 +183,10 @@ func (m *Controller) DoEditPlaylistWorkflow(playlist *subsonic.Playlist) { dlg := dialogs.NewEditPlaylistDialog(playlist) pop := widget.NewModalPopUp(dlg, m.MainWindow.Canvas()) m.ClosePopUpOnEscape(pop) - dlg.OnCanceled = pop.Hide + dlg.OnCanceled = func() { + pop.Hide() + m.doModalClosed() + } dlg.OnDeletePlaylist = func() { pop.Hide() dialog.ShowCustomConfirm("Confirm Delete Playlist", "OK", "Cancel", layout.NewSpacer(), /*custom content*/ @@ -167,6 +194,7 @@ func (m *Controller) DoEditPlaylistWorkflow(playlist *subsonic.Playlist) { 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()) @@ -180,6 +208,7 @@ func (m *Controller) DoEditPlaylistWorkflow(playlist *subsonic.Playlist) { } dlg.OnUpdateMetadata = func() { pop.Hide() + m.doModalClosed() go func() { err := m.App.ServerManager.Server.UpdatePlaylist(playlist.ID, map[string]string{ "name": dlg.Name, @@ -194,6 +223,7 @@ func (m *Controller) DoEditPlaylistWorkflow(playlist *subsonic.Playlist) { } }() } + m.haveModal = true pop.Show() } @@ -208,6 +238,7 @@ func (c *Controller) DoConnectToServerWorkflow(server *backend.ServerConfig) { dlg.SetOnClosed(func() { c.PromptForLoginAndConnect() }) + c.haveModal = true dlg.Show() } } @@ -230,6 +261,7 @@ func (m *Controller) PromptForLoginAndConnect() { } else { pop.Hide() m.trySetPasswordAndConnectToServer(server, password) + m.doModalClosed() } d.EnableSubmit() }() @@ -249,20 +281,26 @@ func (m *Controller) PromptForLoginAndConnect() { server.Username = editD.Username server.LegacyAuth = editD.LegacyAuth m.trySetPasswordAndConnectToServer(server, editD.Password) + m.doModalClosed() } d.EnableSubmit() }() } editPop.Show() } + m.haveModal = true pop.Show() } func (c *Controller) ShowAboutDialog() { dlg := dialogs.NewAboutDialog(c.AppVersion) pop := widget.NewModalPopUp(dlg, c.MainWindow.Canvas()) - dlg.OnDismiss = pop.Hide + dlg.OnDismiss = func() { + pop.Hide() + c.doModalClosed() + } c.ClosePopUpOnEscape(pop) + c.haveModal = true pop.Show() } @@ -299,3 +337,11 @@ func (c *Controller) testConnectionAndUpdateDialogText(dlg *dialogs.AddEditServe } return true } + +func (c *Controller) doModalClosed() { + c.haveModal = false + if c.runOnModalClosed != nil { + c.runOnModalClosed() + c.runOnModalClosed = nil + } +} diff --git a/ui/mainwindow.go b/ui/mainwindow.go index 2dd9eb8..aa5bc32 100644 --- a/ui/mainwindow.go +++ b/ui/mainwindow.go @@ -10,7 +10,9 @@ import ( "fyne.io/fyne/v2" "fyne.io/fyne/v2/container" + "fyne.io/fyne/v2/dialog" "fyne.io/fyne/v2/driver/desktop" + "fyne.io/fyne/v2/widget" "github.com/dweymouth/go-subsonic/subsonic" ) @@ -79,6 +81,14 @@ func NewMainWindow(fyneApp fyne.App, appName, appVersion string, app *backend.Ap app.ServerManager.OnServerConnected(func() { m.BrowsingPane.EnableNavigationButtons() m.Router.NavigateTo(HomePage) + // check if found new version on startup + if t := app.UpdateChecker.VersionTagFound(); t != "" && t != app.Config.Application.LastCheckedVersion { + m.ShowNewVersionDialog(appName, t) + } + // register callback for the ongoing periodic update check + m.App.UpdateChecker.OnUpdatedVersionFound = func() { + m.ShowNewVersionDialog(appName, m.App.UpdateChecker.VersionTagFound()) + } }) app.ServerManager.OnLogout(func() { m.BrowsingPane.DisableNavigationButtons() @@ -87,6 +97,17 @@ func NewMainWindow(fyneApp fyne.App, appName, appVersion string, app *backend.Ap m.Controller.PromptForLoginAndConnect() }) m.BrowsingPane.AddSettingsMenuItem("Log Out", app.ServerManager.Logout) + m.BrowsingPane.AddSettingsMenuItem("Check for Updates", func() { + go func() { + if t := app.UpdateChecker.CheckLatestVersionTag(); t != "" && t != app.VersionTag() { + m.ShowNewVersionDialog(appName, t) + } else { + dialog.ShowInformation("No new version found", + "You are running the latest version of "+appName, + m.Window) + } + }() + }) m.BrowsingPane.AddSettingsMenuItem("About...", m.Controller.ShowAboutDialog) m.addNavigationButtons() m.BrowsingPane.DisableNavigationButtons() @@ -94,6 +115,21 @@ func NewMainWindow(fyneApp fyne.App, appName, appVersion string, app *backend.Ap return m } +func (m *MainWindow) ShowNewVersionDialog(appName, versionTag string) { + contentStr := fmt.Sprintf("A new version of %s (%s) is available", + appName, versionTag) + m.Controller.QueueShowModalFunc(func() { + dialog.ShowCustomConfirm("A new version is available", + "Go to release page", "Skip this version", + widget.NewLabel(contentStr), func(show bool) { + if show { + fyne.CurrentApp().OpenURL(m.App.UpdateChecker.LatestReleaseURL()) + } + m.App.Config.Application.LastCheckedVersion = versionTag + }, m.Window) + }) +} + func (m *MainWindow) addNavigationButtons() { m.BrowsingPane.AddNavigationButton(res.ResHeadphonesInvertPng, func() { m.Router.NavigateTo(controller.NowPlayingRoute()) @@ -153,10 +189,7 @@ func (m *MainWindow) addShortcuts() { m.Canvas().SetOnTypedKey(func(e *fyne.KeyEvent) { switch e.Name { case fyne.KeyEscape: - if m.Controller.EscapablePopUp != nil { - m.Controller.EscapablePopUp.Hide() - m.Controller.EscapablePopUp = nil - } + m.Controller.CloseEscapablePopUp() case fyne.KeySpace: m.App.Player.PlayPause() }