From c4c4a71d5e5b5952108ab98052bdcf2ff98b4bcf Mon Sep 17 00:00:00 2001 From: Drew Weymouth Date: Sun, 7 Jan 2024 16:25:15 -0800 Subject: [PATCH 1/4] save and load play queue on exit/startup (partial impl) --- backend/app.go | 12 ++++++ backend/config.go | 2 + backend/savedplayqueue.go | 83 +++++++++++++++++++++++++++++++++++++++ ui/mainwindow.go | 62 +++++++++++++++++------------ 4 files changed, 134 insertions(+), 25 deletions(-) create mode 100644 backend/savedplayqueue.go diff --git a/backend/app.go b/backend/app.go index e7951880..d98fbf78 100644 --- a/backend/app.go +++ b/backend/app.go @@ -25,6 +25,7 @@ const ( sessionDir = "session" sessionLockFile = ".lock" sessionActivateFile = ".activate" + savedQueueFile = "saved_queue.json" ) var ( @@ -300,6 +301,9 @@ func (a *App) DeleteServerCacheDir(serverID uuid.UUID) error { func (a *App) Shutdown() { a.MPRISHandler.Shutdown() a.PlaybackManager.DisableCallbacks() + if a.Config.Application.SavePlayQueue { + SavePlayQueue(a.ServerManager.ServerID.String(), a.PlaybackManager, configdir.LocalConfig(a.appName, savedQueueFile)) + } a.PlaybackManager.Stop() // will trigger scrobble check a.Config.LocalPlayback.Volume = a.LocalPlayer.GetVolume() a.cancel() @@ -308,6 +312,14 @@ func (a *App) Shutdown() { os.RemoveAll(configdir.LocalConfig(a.appName, sessionDir)) } +func (a *App) LoadSavedPlayQueue() error { + queue, err := LoadPlayQueue(configdir.LocalConfig(a.appName, savedQueueFile), a.ServerManager) + if err != nil { + return err + } + return a.PlaybackManager.LoadTracks(queue.Tracks, false, false) +} + func (a *App) SaveConfigFile() { a.Config.WriteConfigFile(a.configPath()) a.lastWrittenCfg = *a.Config diff --git a/backend/config.go b/backend/config.go index 1e14e78e..25a15a7f 100644 --- a/backend/config.go +++ b/backend/config.go @@ -41,6 +41,7 @@ type AppConfig struct { SettingsTab string AllowMultiInstance bool MaxImageCacheSizeMB int + SavePlayQueue bool // Experimental - may be removed in future FontNormalTTF string @@ -147,6 +148,7 @@ func DefaultConfig(appVersionTag string) *Config { AllowMultiInstance: false, MaxImageCacheSizeMB: 50, UIScaleSize: "Normal", + SavePlayQueue: false, }, AlbumPage: AlbumPageConfig{ TracklistColumns: []string{"Artist", "Time", "Plays", "Favorite", "Rating"}, diff --git a/backend/savedplayqueue.go b/backend/savedplayqueue.go new file mode 100644 index 00000000..6c3f7e70 --- /dev/null +++ b/backend/savedplayqueue.go @@ -0,0 +1,83 @@ +package backend + +import ( + "encoding/json" + "errors" + "os" + + "github.com/dweymouth/supersonic/backend/mediaprovider" +) + +type SavedPlayQueue struct { + Tracks []*mediaprovider.Track + TrackIndex int + TimePos float64 +} + +type serializedSavedPlayQueue struct { + ServerID string `json:"serverID"` + TrackIDs []string `json:"trackIDs"` + TrackIndex int `json:"trackIndex"` + TimePos float64 `json:"timePos"` +} + +// SavePlayQueue saves the current play queue and playback position to a JSON file. +func SavePlayQueue(serverID string, pm *PlaybackManager, filepath string) error { + queue := pm.GetPlayQueue() + stats := pm.PlayerStatus() + trackIdx := pm.NowPlayingIndex() + + trackIDs := make([]string, len(queue)) + for i, tr := range queue { + trackIDs[i] = tr.ID + } + + saved := serializedSavedPlayQueue{ + ServerID: serverID, + TrackIDs: trackIDs, + TrackIndex: trackIdx, + TimePos: stats.TimePos, + } + b, err := json.Marshal(saved) + if err != nil { + return err + } + + return os.WriteFile(filepath, b, 0644) +} + +// Loads the saved play queue from the given filepath using the current server. +// Returns an error if the queue could not be loaded for any reason, including the +// currently logged in server being different than the server from which the queue was saved. +func LoadPlayQueue(filepath string, sm *ServerManager) (*SavedPlayQueue, error) { + b, err := os.ReadFile(filepath) + if err != nil { + return nil, err + } + + var savedData serializedSavedPlayQueue + if err := json.Unmarshal(b, &savedData); err != nil { + return nil, err + } + + if sm.ServerID.String() != savedData.ServerID { + return nil, errors.New("saved play queue was from a different server") + } + + tracks := make([]*mediaprovider.Track, len(savedData.TrackIDs)) + mp := sm.Server + for i, id := range savedData.TrackIDs { + if tr, err := mp.GetTrack(id); err != nil { + return nil, err + } else { + tracks[i] = tr + } + } + + savedQueue := &SavedPlayQueue{ + Tracks: tracks, + TrackIndex: savedData.TrackIndex, + TimePos: savedData.TimePos, + } + return savedQueue, nil +} diff --git a/ui/mainwindow.go b/ui/mainwindow.go index 9af07d89..7781f671 100644 --- a/ui/mainwindow.go +++ b/ui/mainwindow.go @@ -2,6 +2,7 @@ package ui import ( "fmt" + "log" "strings" "github.com/20after4/configdir" @@ -92,31 +93,7 @@ func NewMainWindow(fyneApp fyne.App, appName, displayAppName, appVersion string, m.Window.SetTitle(fmt.Sprintf("%s – %s · %s", song.Name, strings.Join(song.ArtistNames, ", "), displayAppName)) }) app.ServerManager.OnServerConnected(func() { - m.BrowsingPane.EnableNavigationButtons() - m.Router.NavigateTo(m.StartupPage()) - _, canRate := m.App.ServerManager.Server.(mediaprovider.SupportsRating) - m.BottomPanel.NowPlaying.DisableRating = !canRate - // check if launching new version, else if found available update on startup - if l := app.Config.Application.LastLaunchedVersion; app.VersionTag() != l { - if !app.IsFirstLaunch() { - m.ShowWhatsNewDialog() - } - m.App.Config.Application.LastLaunchedVersion = app.VersionTag() - } else if t := app.UpdateChecker.VersionTagFound(); t != "" && t != app.Config.Application.LastCheckedVersion { - if t != app.VersionTag() { - m.ShowNewVersionDialog(displayAppName, t) - } - m.App.Config.Application.LastCheckedVersion = t - } - // register callback for the ongoing periodic update check - m.App.UpdateChecker.OnUpdatedVersionFound = func() { - t := m.App.UpdateChecker.VersionTagFound() - if t != app.VersionTag() { - m.ShowNewVersionDialog(displayAppName, t) - } - m.App.Config.Application.LastCheckedVersion = t - } - m.App.SaveConfigFile() + m.RunOnServerConnectedTasks(app, displayAppName) }) app.ServerManager.OnLogout(func() { m.BrowsingPane.DisableNavigationButtons() @@ -158,6 +135,41 @@ func (m *MainWindow) StartupPage() controller.Route { } } +func (m *MainWindow) RunOnServerConnectedTasks(app *backend.App, displayAppName string) { + m.BrowsingPane.EnableNavigationButtons() + m.Router.NavigateTo(m.StartupPage()) + _, canRate := m.App.ServerManager.Server.(mediaprovider.SupportsRating) + m.BottomPanel.NowPlaying.DisableRating = !canRate + + if app.Config.Application.SavePlayQueue { + if err := app.LoadSavedPlayQueue(); err != nil { + log.Printf("failed to load saved play queue: %s", err.Error()) + } + } + + // check if launching new version, else if found available update on startup + if l := app.Config.Application.LastLaunchedVersion; app.VersionTag() != l { + if !app.IsFirstLaunch() { + m.ShowWhatsNewDialog() + } + m.App.Config.Application.LastLaunchedVersion = app.VersionTag() + } else if t := app.UpdateChecker.VersionTagFound(); t != "" && t != app.Config.Application.LastCheckedVersion { + if t != app.VersionTag() { + m.ShowNewVersionDialog(displayAppName, t) + } + m.App.Config.Application.LastCheckedVersion = t + } + // register callback for the ongoing periodic update check + m.App.UpdateChecker.OnUpdatedVersionFound = func() { + t := m.App.UpdateChecker.VersionTagFound() + if t != app.VersionTag() { + m.ShowNewVersionDialog(displayAppName, t) + } + m.App.Config.Application.LastCheckedVersion = t + } + m.App.SaveConfigFile() +} + func (m *MainWindow) SetupSystemTrayMenu(appName string, fyneApp fyne.App) { if desk, ok := fyneApp.(desktop.App); ok { menu := fyne.NewMenu(appName, From fe41781df46f3073d8fa668186c53d3d446b7f2d Mon Sep 17 00:00:00 2001 From: Drew Weymouth Date: Sun, 7 Jan 2024 16:42:35 -0800 Subject: [PATCH 2/4] add settings dialog check for Save to Queue --- ui/dialogs/settingsdialog.go | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/ui/dialogs/settingsdialog.go b/ui/dialogs/settingsdialog.go index 641e2cef..30bf10b4 100644 --- a/ui/dialogs/settingsdialog.go +++ b/ui/dialogs/settingsdialog.go @@ -159,6 +159,9 @@ func (s *SettingsDialog) createGeneralTab() *container.TabItem { }) systemTrayEnable.Checked = s.config.Application.EnableSystemTray + saveQueue := widget.NewCheckWithData("Save play queue on exit", + binding.BindBool(&s.config.Application.SavePlayQueue)) + // Scrobble settings twoDigitValidator := func(text, selText string, r rune) bool { @@ -247,6 +250,7 @@ func (s *SettingsDialog) createGeneralTab() *container.TabItem { widget.NewLabel("Startup page"), container.NewGridWithColumns(2, startupPage), ), container.NewHBox(systemTrayEnable, closeToTray), + container.NewHBox(saveQueue), s.newSectionSeparator(), widget.NewRichText(&widget.TextSegment{Text: "Scrobbling", Style: util.BoldRichTextStyle}), From 793b570999bf4948a161bc5aa4cbe6c6d2b1abe4 Mon Sep 17 00:00:00 2001 From: Drew Weymouth Date: Sun, 7 Jan 2024 17:14:57 -0800 Subject: [PATCH 3/4] load saved play position on startup --- backend/app.go | 16 +++++++++++++++- ui/mainwindow.go | 8 +++++--- 2 files changed, 20 insertions(+), 4 deletions(-) diff --git a/backend/app.go b/backend/app.go index d98fbf78..f57d3fd8 100644 --- a/backend/app.go +++ b/backend/app.go @@ -317,7 +317,21 @@ func (a *App) LoadSavedPlayQueue() error { if err != nil { return err } - return a.PlaybackManager.LoadTracks(queue.Tracks, false, false) + if len(queue.Tracks) == 0 { + return nil + } + + if err := a.PlaybackManager.LoadTracks(queue.Tracks, false, false); err != nil { + return err + } + if queue.TrackIndex >= 0 { + // TODO: This isn't ideal but doesn't seem to cause an audible play-for-a-split-second artifact + a.PlaybackManager.PlayTrackAt(queue.TrackIndex) + a.PlaybackManager.Pause() + time.Sleep(50 * time.Millisecond) // MPV seek fails if run immediately after + a.PlaybackManager.SeekSeconds(queue.TimePos) + } + return nil } func (a *App) SaveConfigFile() { diff --git a/ui/mainwindow.go b/ui/mainwindow.go index 7781f671..54d6797f 100644 --- a/ui/mainwindow.go +++ b/ui/mainwindow.go @@ -142,9 +142,11 @@ func (m *MainWindow) RunOnServerConnectedTasks(app *backend.App, displayAppName m.BottomPanel.NowPlaying.DisableRating = !canRate if app.Config.Application.SavePlayQueue { - if err := app.LoadSavedPlayQueue(); err != nil { - log.Printf("failed to load saved play queue: %s", err.Error()) - } + go func() { + if err := app.LoadSavedPlayQueue(); err != nil { + log.Printf("failed to load saved play queue: %s", err.Error()) + } + }() } // check if launching new version, else if found available update on startup From fca5abca9421b6dc91e3edda21f621049449aa4f Mon Sep 17 00:00:00 2001 From: Drew Weymouth Date: Mon, 8 Jan 2024 08:14:45 -0800 Subject: [PATCH 4/4] ignore/skip failure to restore individual tracks from queue --- backend/savedplayqueue.go | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/backend/savedplayqueue.go b/backend/savedplayqueue.go index 6c3f7e70..1fe8e85e 100644 --- a/backend/savedplayqueue.go +++ b/backend/savedplayqueue.go @@ -64,13 +64,16 @@ func LoadPlayQueue(filepath string, sm *ServerManager) (*SavedPlayQueue, error) return nil, errors.New("saved play queue was from a different server") } - tracks := make([]*mediaprovider.Track, len(savedData.TrackIDs)) + tracks := make([]*mediaprovider.Track, 0, len(savedData.TrackIDs)) mp := sm.Server for i, id := range savedData.TrackIDs { if tr, err := mp.GetTrack(id); err != nil { - return nil, err + // ignore/skip individual track failures + if i < savedData.TrackIndex { + savedData.TrackIndex-- + } } else { - tracks[i] = tr + tracks = append(tracks, tr) } }