Merge pull request #367 from dweymouth/feature/savequeuetoserver
Add ability to save and load play queue from Subsonic servers
This commit is contained in:
+10
-2
@@ -11,6 +11,7 @@ import (
|
|||||||
"slices"
|
"slices"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/dweymouth/supersonic/backend/mediaprovider"
|
||||||
"github.com/dweymouth/supersonic/backend/player"
|
"github.com/dweymouth/supersonic/backend/player"
|
||||||
"github.com/dweymouth/supersonic/backend/player/mpv"
|
"github.com/dweymouth/supersonic/backend/player/mpv"
|
||||||
"github.com/dweymouth/supersonic/backend/util"
|
"github.com/dweymouth/supersonic/backend/util"
|
||||||
@@ -305,7 +306,13 @@ func (a *App) Shutdown() {
|
|||||||
a.MPRISHandler.Shutdown()
|
a.MPRISHandler.Shutdown()
|
||||||
a.PlaybackManager.DisableCallbacks()
|
a.PlaybackManager.DisableCallbacks()
|
||||||
if a.Config.Application.SavePlayQueue {
|
if a.Config.Application.SavePlayQueue {
|
||||||
SavePlayQueue(a.ServerManager.ServerID.String(), a.PlaybackManager, configdir.LocalConfig(a.appName, savedQueueFile))
|
var queueServer mediaprovider.CanSavePlayQueue = nil
|
||||||
|
if a.Config.Application.SaveQueueToServer {
|
||||||
|
if qs, ok := a.ServerManager.Server.(mediaprovider.CanSavePlayQueue); ok {
|
||||||
|
queueServer = qs
|
||||||
|
}
|
||||||
|
}
|
||||||
|
SavePlayQueue(a.ServerManager.ServerID.String(), a.PlaybackManager, configdir.LocalConfig(a.appName, savedQueueFile), queueServer)
|
||||||
}
|
}
|
||||||
a.PlaybackManager.Stop() // will trigger scrobble check
|
a.PlaybackManager.Stop() // will trigger scrobble check
|
||||||
a.Config.LocalPlayback.Volume = a.LocalPlayer.GetVolume()
|
a.Config.LocalPlayback.Volume = a.LocalPlayer.GetVolume()
|
||||||
@@ -316,7 +323,8 @@ func (a *App) Shutdown() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (a *App) LoadSavedPlayQueue() error {
|
func (a *App) LoadSavedPlayQueue() error {
|
||||||
queue, err := LoadPlayQueue(configdir.LocalConfig(a.appName, savedQueueFile), a.ServerManager)
|
queueFilePath := configdir.LocalConfig(a.appName, savedQueueFile)
|
||||||
|
queue, err := LoadPlayQueue(queueFilePath, a.ServerManager, a.Config.Application.SaveQueueToServer)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|||||||
+3
-1
@@ -42,6 +42,7 @@ type AppConfig struct {
|
|||||||
AllowMultiInstance bool
|
AllowMultiInstance bool
|
||||||
MaxImageCacheSizeMB int
|
MaxImageCacheSizeMB int
|
||||||
SavePlayQueue bool
|
SavePlayQueue bool
|
||||||
|
SaveQueueToServer bool
|
||||||
DefaultPlaylistID string
|
DefaultPlaylistID string
|
||||||
ShowTrackChangeNotification bool
|
ShowTrackChangeNotification bool
|
||||||
|
|
||||||
@@ -155,7 +156,8 @@ func DefaultConfig(appVersionTag string) *Config {
|
|||||||
AllowMultiInstance: false,
|
AllowMultiInstance: false,
|
||||||
MaxImageCacheSizeMB: 50,
|
MaxImageCacheSizeMB: 50,
|
||||||
UIScaleSize: "Normal",
|
UIScaleSize: "Normal",
|
||||||
SavePlayQueue: false,
|
SavePlayQueue: true,
|
||||||
|
SaveQueueToServer: false,
|
||||||
ShowTrackChangeNotification: false,
|
ShowTrackChangeNotification: false,
|
||||||
},
|
},
|
||||||
AlbumPage: AlbumPageConfig{
|
AlbumPage: AlbumPageConfig{
|
||||||
|
|||||||
@@ -258,6 +258,11 @@ type SupportsSharing interface {
|
|||||||
CanShareArtists() bool
|
CanShareArtists() bool
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type CanSavePlayQueue interface {
|
||||||
|
SavePlayQueue(trackIDs []string, currentTrackPos int, timeSeconds int) error
|
||||||
|
GetPlayQueue() (*SavedPlayQueue, error)
|
||||||
|
}
|
||||||
|
|
||||||
type LyricsProvider interface {
|
type LyricsProvider interface {
|
||||||
GetLyrics(track *Track) (*Lyrics, error)
|
GetLyrics(track *Track) (*Lyrics, error)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -133,6 +133,12 @@ type LyricLine struct {
|
|||||||
Start float64 // seconds
|
Start float64 // seconds
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type SavedPlayQueue struct {
|
||||||
|
Tracks []*Track
|
||||||
|
TrackPos int
|
||||||
|
TimePos int // seconds
|
||||||
|
}
|
||||||
|
|
||||||
type ContentType int
|
type ContentType int
|
||||||
|
|
||||||
const (
|
const (
|
||||||
|
|||||||
@@ -379,6 +379,32 @@ func (s *subsonicMediaProvider) GetLyrics(track *mediaprovider.Track) (*mediapro
|
|||||||
return mpLyrics, nil
|
return mpLyrics, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// CanSavePlayQueue interface
|
||||||
|
|
||||||
|
func (s *subsonicMediaProvider) SavePlayQueue(trackIDs []string, currentTrackPos int, timeSeconds int) error {
|
||||||
|
if len(trackIDs) == 0 {
|
||||||
|
return nil // don't save an empty queue
|
||||||
|
}
|
||||||
|
return s.client.SavePlayQueue(trackIDs, map[string]string{
|
||||||
|
"current": trackIDs[currentTrackPos],
|
||||||
|
"position": strconv.Itoa(timeSeconds * 1000),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *subsonicMediaProvider) GetPlayQueue() (*mediaprovider.SavedPlayQueue, error) {
|
||||||
|
pq, err := s.client.GetPlayQueue()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
savedQueue := &mediaprovider.SavedPlayQueue{}
|
||||||
|
savedQueue.Tracks = sharedutil.MapSlice(pq.Entries, toTrack)
|
||||||
|
savedQueue.TrackPos = slices.IndexFunc(pq.Entries, func(e *subsonic.Child) bool {
|
||||||
|
return e.ID == pq.Current
|
||||||
|
})
|
||||||
|
savedQueue.TimePos = int(pq.Position / 1000)
|
||||||
|
return savedQueue, nil
|
||||||
|
}
|
||||||
|
|
||||||
func toTrack(ch *subsonic.Child) *mediaprovider.Track {
|
func toTrack(ch *subsonic.Child) *mediaprovider.Track {
|
||||||
if ch == nil {
|
if ch == nil {
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package backend
|
|||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
|
"log"
|
||||||
"os"
|
"os"
|
||||||
|
|
||||||
"github.com/dweymouth/supersonic/backend/mediaprovider"
|
"github.com/dweymouth/supersonic/backend/mediaprovider"
|
||||||
@@ -22,7 +23,8 @@ type serializedSavedPlayQueue struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// SavePlayQueue saves the current play queue and playback position to a JSON file.
|
// SavePlayQueue saves the current play queue and playback position to a JSON file.
|
||||||
func SavePlayQueue(serverID string, pm *PlaybackManager, filepath string) error {
|
// If the provided CanSavePlayQueue server is non-nil, it will also save to the server.
|
||||||
|
func SavePlayQueue(serverID string, pm *PlaybackManager, filepath string, server mediaprovider.CanSavePlayQueue) error {
|
||||||
queue := pm.GetPlayQueue()
|
queue := pm.GetPlayQueue()
|
||||||
stats := pm.PlayerStatus()
|
stats := pm.PlayerStatus()
|
||||||
trackIdx := pm.NowPlayingIndex()
|
trackIdx := pm.NowPlayingIndex()
|
||||||
@@ -38,18 +40,37 @@ func SavePlayQueue(serverID string, pm *PlaybackManager, filepath string) error
|
|||||||
TrackIndex: trackIdx,
|
TrackIndex: trackIdx,
|
||||||
TimePos: stats.TimePos,
|
TimePos: stats.TimePos,
|
||||||
}
|
}
|
||||||
b, err := json.Marshal(saved)
|
b, _ := json.Marshal(saved)
|
||||||
if err != nil {
|
err := os.WriteFile(filepath, b, 0644)
|
||||||
|
|
||||||
|
if server != nil {
|
||||||
|
// save to server
|
||||||
|
err = server.SavePlayQueue(trackIDs, trackIdx, int(stats.TimePos))
|
||||||
|
}
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
return os.WriteFile(filepath, b, 0644)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Loads the saved play queue from the given filepath using the current server.
|
// Loads the saved play queue from the given filepath using the current server.
|
||||||
|
// If loadFromServer is true and the current server supports saving the play queue,
|
||||||
|
// the queue will attempt to load from the server and only use the local file as a fallback.
|
||||||
// Returns an error if the queue could not be loaded for any reason, including the
|
// 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.
|
// currently logged in server being different than the server from which the queue was saved.
|
||||||
func LoadPlayQueue(filepath string, sm *ServerManager) (*SavedPlayQueue, error) {
|
func LoadPlayQueue(filepath string, sm *ServerManager, loadFromServer bool) (*SavedPlayQueue, error) {
|
||||||
|
if pq, ok := sm.Server.(mediaprovider.CanSavePlayQueue); loadFromServer && ok && pq != nil {
|
||||||
|
// load queue from server
|
||||||
|
queue, err := pq.GetPlayQueue()
|
||||||
|
if err == nil {
|
||||||
|
return &SavedPlayQueue{
|
||||||
|
Tracks: queue.Tracks,
|
||||||
|
TrackIndex: queue.TrackPos,
|
||||||
|
TimePos: float64(queue.TimePos),
|
||||||
|
}, nil
|
||||||
|
} else {
|
||||||
|
log.Printf("error loading queue from server: %v", err.Error())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// load queue from local file
|
||||||
b, err := os.ReadFile(filepath)
|
b, err := os.ReadFile(filepath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ require (
|
|||||||
github.com/deluan/sanitize v0.0.0-20230310221930-6e18967d9fc1
|
github.com/deluan/sanitize v0.0.0-20230310221930-6e18967d9fc1
|
||||||
github.com/dweymouth/go-jellyfin v0.0.0-20240330010648-fb02c0b3878e
|
github.com/dweymouth/go-jellyfin v0.0.0-20240330010648-fb02c0b3878e
|
||||||
github.com/dweymouth/go-mpv v0.0.0-20230406003141-7f1858e503ee
|
github.com/dweymouth/go-mpv v0.0.0-20230406003141-7f1858e503ee
|
||||||
github.com/dweymouth/go-subsonic v0.0.0-20240331151503-47a6f310eb73
|
github.com/dweymouth/go-subsonic v0.0.0-20240417012336-798603e9f3a3
|
||||||
github.com/fsnotify/fsnotify v1.6.0
|
github.com/fsnotify/fsnotify v1.6.0
|
||||||
github.com/godbus/dbus/v5 v5.1.0
|
github.com/godbus/dbus/v5 v5.1.0
|
||||||
github.com/google/uuid v1.3.0
|
github.com/google/uuid v1.3.0
|
||||||
|
|||||||
@@ -75,8 +75,8 @@ github.com/dweymouth/go-jellyfin v0.0.0-20240330010648-fb02c0b3878e h1:89N7tfmGP
|
|||||||
github.com/dweymouth/go-jellyfin v0.0.0-20240330010648-fb02c0b3878e/go.mod h1:fcUagHBaQnt06GmBAllNE0J4O/7064zXRWdqnTTtVjI=
|
github.com/dweymouth/go-jellyfin v0.0.0-20240330010648-fb02c0b3878e/go.mod h1:fcUagHBaQnt06GmBAllNE0J4O/7064zXRWdqnTTtVjI=
|
||||||
github.com/dweymouth/go-mpv v0.0.0-20230406003141-7f1858e503ee h1:ZGyJ6wp7CAfT31BugypcF/TPKEy2RrGR9JFq1JOjOpY=
|
github.com/dweymouth/go-mpv v0.0.0-20230406003141-7f1858e503ee h1:ZGyJ6wp7CAfT31BugypcF/TPKEy2RrGR9JFq1JOjOpY=
|
||||||
github.com/dweymouth/go-mpv v0.0.0-20230406003141-7f1858e503ee/go.mod h1:Ov0ieN90M7i+0k3OxhA/g1dozGs+UcPHDsMKqPgRDk0=
|
github.com/dweymouth/go-mpv v0.0.0-20230406003141-7f1858e503ee/go.mod h1:Ov0ieN90M7i+0k3OxhA/g1dozGs+UcPHDsMKqPgRDk0=
|
||||||
github.com/dweymouth/go-subsonic v0.0.0-20240331151503-47a6f310eb73 h1:uSy9D1HzfY7Y2Ifat14WIDSkWRhbyo57fPsqdM1XMUA=
|
github.com/dweymouth/go-subsonic v0.0.0-20240417012336-798603e9f3a3 h1:DJwu4MrQ6cPJF+eqcuP0eGKelsaZbHa08xsRHrbJYc8=
|
||||||
github.com/dweymouth/go-subsonic v0.0.0-20240331151503-47a6f310eb73/go.mod h1:OWtcumdQsan8uM6wmx6PqKhldaCthH10CQ+vb+94kzo=
|
github.com/dweymouth/go-subsonic v0.0.0-20240417012336-798603e9f3a3/go.mod h1:OWtcumdQsan8uM6wmx6PqKhldaCthH10CQ+vb+94kzo=
|
||||||
github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
|
github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
|
||||||
github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
|
github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
|
||||||
github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98=
|
github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98=
|
||||||
|
|||||||
@@ -578,12 +578,13 @@ func (c *Controller) ShowSettingsDialog(themeUpdateCallbk func(), themeFiles map
|
|||||||
curPlayer := c.App.PlaybackManager.CurrentPlayer()
|
curPlayer := c.App.PlaybackManager.CurrentPlayer()
|
||||||
_, isReplayGainPlayer := curPlayer.(player.ReplayGainPlayer)
|
_, isReplayGainPlayer := curPlayer.(player.ReplayGainPlayer)
|
||||||
_, isEqualizerPlayer := curPlayer.(*mpv.Player)
|
_, isEqualizerPlayer := curPlayer.(*mpv.Player)
|
||||||
|
_, canSavePlayQueue := c.App.ServerManager.Server.(mediaprovider.CanSavePlayQueue)
|
||||||
isLocalPlayer := isEqualizerPlayer
|
isLocalPlayer := isEqualizerPlayer
|
||||||
bands := c.App.LocalPlayer.Equalizer().BandFrequencies()
|
bands := c.App.LocalPlayer.Equalizer().BandFrequencies()
|
||||||
dlg := dialogs.NewSettingsDialog(c.App.Config,
|
dlg := dialogs.NewSettingsDialog(c.App.Config,
|
||||||
devs, themeFiles, bands,
|
devs, themeFiles, bands,
|
||||||
c.App.ServerManager.Server.ClientDecidesScrobble(),
|
c.App.ServerManager.Server.ClientDecidesScrobble(),
|
||||||
isLocalPlayer, isReplayGainPlayer, isEqualizerPlayer,
|
isLocalPlayer, isReplayGainPlayer, isEqualizerPlayer, canSavePlayQueue,
|
||||||
c.MainWindow)
|
c.MainWindow)
|
||||||
dlg.OnReplayGainSettingsChanged = func() {
|
dlg.OnReplayGainSettingsChanged = func() {
|
||||||
c.App.PlaybackManager.SetReplayGainOptions(c.App.Config.ReplayGain)
|
c.App.PlaybackManager.SetReplayGainOptions(c.App.Config.ReplayGain)
|
||||||
|
|||||||
@@ -56,6 +56,7 @@ func NewSettingsDialog(
|
|||||||
isLocalPlayer bool,
|
isLocalPlayer bool,
|
||||||
isReplayGainPlayer bool,
|
isReplayGainPlayer bool,
|
||||||
isEqualizerPlayer bool,
|
isEqualizerPlayer bool,
|
||||||
|
canSavePlayQueue bool,
|
||||||
window fyne.Window,
|
window fyne.Window,
|
||||||
) *SettingsDialog {
|
) *SettingsDialog {
|
||||||
s := &SettingsDialog{config: config, audioDevices: audioDeviceList, themeFiles: themeFileList, clientDecidesScrobble: clientDecidesScrobble}
|
s := &SettingsDialog{config: config, audioDevices: audioDeviceList, themeFiles: themeFileList, clientDecidesScrobble: clientDecidesScrobble}
|
||||||
@@ -66,14 +67,14 @@ func NewSettingsDialog(
|
|||||||
var tabs *container.AppTabs
|
var tabs *container.AppTabs
|
||||||
if isEqualizerPlayer {
|
if isEqualizerPlayer {
|
||||||
tabs = container.NewAppTabs(
|
tabs = container.NewAppTabs(
|
||||||
s.createGeneralTab(),
|
s.createGeneralTab(canSavePlayQueue),
|
||||||
s.createPlaybackTab(isLocalPlayer, isReplayGainPlayer),
|
s.createPlaybackTab(isLocalPlayer, isReplayGainPlayer),
|
||||||
s.createEqualizerTab(equalizerBands),
|
s.createEqualizerTab(equalizerBands),
|
||||||
s.createExperimentalTab(window),
|
s.createExperimentalTab(window),
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
tabs = container.NewAppTabs(
|
tabs = container.NewAppTabs(
|
||||||
s.createGeneralTab(),
|
s.createGeneralTab(canSavePlayQueue),
|
||||||
s.createPlaybackTab(isLocalPlayer, isReplayGainPlayer),
|
s.createPlaybackTab(isLocalPlayer, isReplayGainPlayer),
|
||||||
s.createExperimentalTab(window),
|
s.createExperimentalTab(window),
|
||||||
)
|
)
|
||||||
@@ -94,7 +95,7 @@ func NewSettingsDialog(
|
|||||||
return s
|
return s
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *SettingsDialog) createGeneralTab() *container.TabItem {
|
func (s *SettingsDialog) createGeneralTab(canSaveQueueToServer bool) *container.TabItem {
|
||||||
themeNames := []string{"Default"}
|
themeNames := []string{"Default"}
|
||||||
themeFileNames := []string{""}
|
themeFileNames := []string{""}
|
||||||
i, selIndex := 1, 0
|
i, selIndex := 1, 0
|
||||||
@@ -157,8 +158,32 @@ func (s *SettingsDialog) createGeneralTab() *container.TabItem {
|
|||||||
})
|
})
|
||||||
systemTrayEnable.Checked = s.config.Application.EnableSystemTray
|
systemTrayEnable.Checked = s.config.Application.EnableSystemTray
|
||||||
|
|
||||||
saveQueue := widget.NewCheckWithData("Save play queue on exit",
|
// save play queue settings
|
||||||
binding.BindBool(&s.config.Application.SavePlayQueue))
|
saveToServer := widget.NewRadioGroup([]string{"Locally", "To server"}, func(choice string) {
|
||||||
|
s.config.Application.SaveQueueToServer = choice == "To server"
|
||||||
|
})
|
||||||
|
saveToServer.Horizontal = true
|
||||||
|
if !s.config.Application.SavePlayQueue {
|
||||||
|
saveToServer.Disable()
|
||||||
|
}
|
||||||
|
saveToServer.Selected = "Locally"
|
||||||
|
if s.config.Application.SaveQueueToServer {
|
||||||
|
saveToServer.Selected = "To server"
|
||||||
|
}
|
||||||
|
saveQueue := widget.NewCheck("Save play queue on exit", func(save bool) {
|
||||||
|
s.config.Application.SavePlayQueue = save
|
||||||
|
if save && canSaveQueueToServer {
|
||||||
|
saveToServer.Enable()
|
||||||
|
} else if canSaveQueueToServer {
|
||||||
|
saveToServer.Disable()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
saveQueue.Checked = s.config.Application.SavePlayQueue
|
||||||
|
saveQueueHBox := container.NewHBox(saveQueue)
|
||||||
|
if canSaveQueueToServer {
|
||||||
|
saveQueueHBox.Add(saveToServer)
|
||||||
|
}
|
||||||
|
|
||||||
trackNotif := widget.NewCheckWithData("Show notification on track change",
|
trackNotif := widget.NewCheckWithData("Show notification on track change",
|
||||||
binding.BindBool(&s.config.Application.ShowTrackChangeNotification))
|
binding.BindBool(&s.config.Application.ShowTrackChangeNotification))
|
||||||
|
|
||||||
@@ -250,7 +275,7 @@ func (s *SettingsDialog) createGeneralTab() *container.TabItem {
|
|||||||
widget.NewLabel("Startup page"), container.NewGridWithColumns(2, startupPage),
|
widget.NewLabel("Startup page"), container.NewGridWithColumns(2, startupPage),
|
||||||
),
|
),
|
||||||
container.NewHBox(systemTrayEnable, closeToTray),
|
container.NewHBox(systemTrayEnable, closeToTray),
|
||||||
saveQueue,
|
saveQueueHBox,
|
||||||
trackNotif,
|
trackNotif,
|
||||||
s.newSectionSeparator(),
|
s.newSectionSeparator(),
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user