diff --git a/backend/albumiterator.go b/backend/albumiterator.go index 6270d47..fb1798c 100644 --- a/backend/albumiterator.go +++ b/backend/albumiterator.go @@ -14,11 +14,11 @@ type AlbumIterator interface { } type LibraryManager struct { - s *subsonic.Client + s *ServerManager albumCache gcache.Cache } -func NewLibraryManager(s *subsonic.Client) *LibraryManager { +func NewLibraryManager(s *ServerManager) *LibraryManager { cache := gcache.New(250).LRU().Build() return &LibraryManager{ s: s, @@ -52,7 +52,7 @@ func (l *LibraryManager) GetAlbum(id string) (*subsonic.AlbumID3, error) { return a.(*subsonic.AlbumID3), nil } } - a, err := l.s.GetAlbum(id) + a, err := l.s.Server.GetAlbum(id) if err != nil { return nil, err } @@ -74,7 +74,7 @@ func (l *LibraryManager) newBaseIter(listType string) *baseIter { return &baseIter{ listType: listType, l: l, - s: l.s, + s: l.s.Server, } } diff --git a/backend/app.go b/backend/app.go new file mode 100644 index 0000000..2e7f350 --- /dev/null +++ b/backend/app.go @@ -0,0 +1,92 @@ +package backend + +import ( + "context" + "errors" + "fmt" + "log" + "path" + "supersonic/player" + + "github.com/20after4/configdir" + "github.com/zalando/go-keyring" +) + +const ( + AppName = "supersonic" + configFile = "config.toml" +) + +var ( + ErrNoServers = errors.New("no servers set up") +) + +type App struct { + Config *Config + ServerManager *ServerManager + ImageManager *ImageManager + LibraryManager *LibraryManager + PlaybackManager *PlaybackManager + Player *player.Player + + bgrndCtx context.Context + cancel context.CancelFunc +} + +func StartupApp() (*App, error) { + a := &App{} + a.bgrndCtx, a.cancel = context.WithCancel(context.Background()) + + a.readConfig() + + if err := a.initMPV(); err != nil { + return nil, err + } + + a.ServerManager = NewServerManager() + a.PlaybackManager = NewPlaybackManager(a.bgrndCtx, a.ServerManager, a.Player) + a.LibraryManager = NewLibraryManager(a.ServerManager) + a.ImageManager = NewImageManager(a.ServerManager, configdir.LocalCache(AppName)) + + return a, nil +} + +func (a *App) readConfig() { + configdir.MakePath(configdir.LocalConfig(AppName)) + cfg, err := ReadConfigFile(configPath()) + if err != nil { + log.Printf("Error reading app config file: %v", err) + cfg = DefaultConfig() + } + a.Config = cfg +} + +func (a *App) initMPV() error { + p := player.NewWithClientName(AppName) + if err := p.Init(); err != nil { + return fmt.Errorf("failed to initialize mpv player: %s", err.Error()) + } + a.Player = p + return nil +} + +func (a *App) LoginToDefaultServer() error { + serverCfg := a.Config.GetDefaultServer() + if serverCfg == nil { + return ErrNoServers + } + pass, err := keyring.Get(AppName, serverCfg.ID.String()) + if err != nil { + return fmt.Errorf("error reading keyring credentials: %v", err) + } + return a.ServerManager.ConnectToServer(serverCfg, pass) +} + +func (a *App) Shutdown() { + a.cancel() + a.Player.Destroy() +} + +func configPath() string { + return path.Join(configdir.LocalConfig(AppName), configFile) +} diff --git a/backend/imagemanager.go b/backend/imagemanager.go index c30b671..6cae6e3 100644 --- a/backend/imagemanager.go +++ b/backend/imagemanager.go @@ -10,24 +10,23 @@ import ( "github.com/20after4/configdir" "github.com/bluele/gcache" - subsonic "github.com/dweymouth/go-subsonic" ) type ImageManager struct { - s *subsonic.Client - cacheDir string + s *ServerManager + baseCacheDir string thumbnailCache gcache.Cache } -func NewImageManager(s *subsonic.Client, cacheDir string) *ImageManager { +func NewImageManager(s *ServerManager, baseCacheDir string) *ImageManager { cache := gcache.New(100).LRU().Build() - if err := configdir.MakePath(cacheDir); err != nil { + if err := configdir.MakePath(baseCacheDir); err != nil { log.Println("failed to create album cover cache dir") - cacheDir = "" + baseCacheDir = "" } return &ImageManager{ s: s, - cacheDir: cacheDir, + baseCacheDir: baseCacheDir, thumbnailCache: cache, } } @@ -41,8 +40,8 @@ func (i *ImageManager) GetAlbumThumbnail(albumID string) (image.Image, error) { } // on disc cache - path := filepath.Join(i.cacheDir, fmt.Sprintf("%s.jpg", albumID)) - if i.cacheDir != "" { + path := filepath.Join(i.coverCacheDir(), fmt.Sprintf("%s.jpg", albumID)) + if i.coverCacheDir() != "" { if _, err := os.Stat(path); err == nil { // serve image from on-disc cache // TODO: image may have changed on server. @@ -58,11 +57,11 @@ func (i *ImageManager) GetAlbumThumbnail(albumID string) (image.Image, error) { } // fetch from server - img, err := i.s.GetCoverArt(albumID, map[string]string{"size": "300"}) + img, err := i.s.Server.GetCoverArt(albumID, map[string]string{"size": "300"}) if err != nil { return nil, err } - if i.cacheDir != "" { + if i.coverCacheDir() != "" { if f, err := os.Create(path); err == nil { defer f.Close() if err := jpeg.Encode(f, img, nil /*options*/); err != nil { @@ -73,3 +72,7 @@ func (i *ImageManager) GetAlbumThumbnail(albumID string) (image.Image, error) { i.thumbnailCache.Set(albumID, img) return img, nil } + +func (i *ImageManager) coverCacheDir() string { + return configdir.LocalCache(i.baseCacheDir, i.s.ServerID.String(), "covers") +} diff --git a/backend/playbackmanager.go b/backend/playbackmanager.go index 01c186f..2b7eb7c 100644 --- a/backend/playbackmanager.go +++ b/backend/playbackmanager.go @@ -12,7 +12,7 @@ type PlaybackManager struct { ctx context.Context cancelPollPos context.CancelFunc pollingTick *time.Ticker - client *subsonic.Client + sm *ServerManager player *player.Player playQueue []*subsonic.Child @@ -21,10 +21,10 @@ type PlaybackManager struct { onPlayTimeUpdate []func(float64, float64) } -func NewPlaybackManager(ctx context.Context, cli *subsonic.Client, p *player.Player) *PlaybackManager { +func NewPlaybackManager(ctx context.Context, s *ServerManager, p *player.Player) *PlaybackManager { pm := &PlaybackManager{ ctx: ctx, - client: cli, + sm: s, player: p, } p.OnTrackChange(func(tracknum int64) { @@ -77,7 +77,7 @@ func (p *PlaybackManager) OnPlayTimeUpdate(cb func(float64, float64)) { // Loads the specified album into the play queue. func (p *PlaybackManager) LoadAlbum(albumID string, appendToQueue bool) error { - album, err := p.client.GetAlbum(albumID) + album, err := p.sm.Server.GetAlbum(albumID) if err != nil { return err } @@ -87,7 +87,7 @@ func (p *PlaybackManager) LoadAlbum(albumID string, appendToQueue bool) error { p.playQueue = nil } for _, song := range album.Song { - url, err := p.client.GetStreamURL(song.ID, map[string]string{}) + url, err := p.sm.Server.GetStreamURL(song.ID, map[string]string{}) if err != nil { return err } diff --git a/backend/servermanager.go b/backend/servermanager.go new file mode 100644 index 0000000..a973fa9 --- /dev/null +++ b/backend/servermanager.go @@ -0,0 +1,41 @@ +package backend + +import ( + "net/http" + + subsonic "github.com/dweymouth/go-subsonic" + "github.com/google/uuid" +) + +type ServerManager struct { + ServerID uuid.UUID + Server *subsonic.Client + + onServerConnected []func() +} + +func NewServerManager() *ServerManager { + return &ServerManager{} +} + +func (s *ServerManager) ConnectToServer(conf *ServerConfig, password string) error { + cli := &subsonic.Client{ + Client: &http.Client{}, + BaseUrl: conf.Hostname, + User: conf.Username, + ClientName: "supersonic", + } + if err := cli.Authenticate(password); err != nil { + return err + } + s.Server = cli + s.ServerID = conf.ID + for _, cb := range s.onServerConnected { + cb() + } + return nil +} + +func (s *ServerManager) OnServerConnected(cb func()) { + s.onServerConnected = append(s.onServerConnected, cb) +} diff --git a/main.go b/main.go index cd7715c..585a0ff 100644 --- a/main.go +++ b/main.go @@ -1,21 +1,13 @@ package main import ( - "context" - "fmt" "log" - "net/http" "path" "supersonic/backend" - "supersonic/player" "supersonic/ui" - "supersonic/ui/widgets" "fyne.io/fyne/v2/app" - "fyne.io/fyne/v2/container" - "fyne.io/fyne/v2/widget" "github.com/20after4/configdir" - "github.com/dweymouth/go-subsonic" "github.com/zalando/go-keyring" ) @@ -29,88 +21,43 @@ func configPath() string { } func main() { - // TODO: organize this whole file better. Move some stuff to mainwindow? - - ctx, cancel := context.WithCancel(context.Background()) - - p := player.NewWithClientName(appname) - if err := p.Init(); err != nil { - log.Fatalf("failed to initialize mpv player: %s", err.Error()) - } - - configdir.MakePath(configdir.LocalConfig(appname)) - - cfg, err := backend.ReadConfigFile(configPath()) + myApp, err := backend.StartupApp() if err != nil { - log.Printf("Error reading app config file: %v", err) - cfg = backend.DefaultConfig() + log.Fatalf("fatal startup error: %v", err.Error()) } - server := cfg.GetDefaultServer() - myApp := app.New() - mainWindow := ui.NewMainWindow(myApp, appname, p) - - if server == nil { - d := widgets.NewAddServerForm("Connect to Server") - pop := widget.NewModalPopUp(d, mainWindow.Canvas()) - d.OnSubmit = func() { - pop.Hide() - server = cfg.AddServer(d.Nickname, d.Host, d.Username) - err := keyring.Set(appname, server.ID.String(), d.Password) + fyneApp := app.New() + mainWindow := ui.NewMainWindow(fyneApp, appname, myApp) + defaultServer := myApp.Config.GetDefaultServer() + if defaultServer == nil { + mainWindow.PromptForFirstServer(func(nick, host, user, pass string) { + server := myApp.Config.AddServer(nick, host, user) + err := keyring.Set(appname, server.ID.String(), pass) if err != nil { log.Printf("error setting keyring credentials: %v", err) + // TODO: handle? } - setupServer(ctx, mainWindow, p, server) - } - pop.Show() + setupServer(myApp, server) + }) } else { - setupServer(ctx, mainWindow, p, server) + setupServer(myApp, defaultServer) } mainWindow.Show() - myApp.Run() - cfg.WriteConfigFile(configPath()) + fyneApp.Run() + + myApp.Config.WriteConfigFile(configPath()) + myApp.Shutdown() - cancel() - p.Destroy() } -func setupServer(ctx context.Context, myWindow ui.MainWindow, p *player.Player, server *backend.ServerConfig) { +func setupServer(app *backend.App, server *backend.ServerConfig) { pass, err := keyring.Get(appname, server.ID.String()) if err != nil { - log.Printf("error reading keyring credentials: %v", err) + log.Printf("error getting password from keyring: %v", err) } - - s := &subsonic.Client{ - Client: &http.Client{}, - BaseUrl: server.Hostname, - User: server.Username, - ClientName: appname, + if err := app.ServerManager.ConnectToServer(server, pass); err != nil { + log.Printf("error connecting to server: %v", err) + // TODO: surface error to user } - - if err := s.Authenticate(pass); err != nil { - // TODO: error dialog - fmt.Printf("error authenticating: %v\n", err) - } - - lm := backend.NewLibraryManager(s) - pm := backend.NewPlaybackManager(ctx, s, p) - im := backend.NewImageManager(s, configdir.LocalCache(appname, server.ID.String(), "covers")) - myWindow.BottomPanel.ImageManager = im - - myWindow.BottomPanel.SetPlaybackManager(pm) - pm.OnSongChange(func(song *subsonic.Child) { - if song == nil { - myWindow.SetTitle("gomuse") - return - } - myWindow.SetTitle(song.Title) - }) - - ag := ui.NewAlbumGrid(lm.RecentlyAddedIter(), im.GetAlbumThumbnail) - ag.OnPlayAlbum = func(albumID string) { - _ = pm.PlayAlbum(albumID) - } - myWindow.SetContent(container.NewBorder(nil, myWindow.BottomPanel, nil, nil, ag)) - } diff --git a/ui/albumgrid.go b/ui/albumgrid.go index 88ed29a..e867f36 100644 --- a/ui/albumgrid.go +++ b/ui/albumgrid.go @@ -22,7 +22,7 @@ type AlbumGrid struct { fetching bool done bool - imageFetcher func(string) (image.Image, error) + imageFetcher ImageFetcher OnPlayAlbum func(string) } diff --git a/ui/bottompanel.go b/ui/bottompanel.go index 74d85df..42ae4da 100644 --- a/ui/bottompanel.go +++ b/ui/bottompanel.go @@ -59,8 +59,8 @@ type BottomPanel struct { playbackManager *backend.PlaybackManager - nowPlaying *widgets.NowPlayingCard - controls *widgets.PlayerControls + NowPlaying *widgets.NowPlayingCard + Controls *widgets.PlayerControls container *fyne.Container } @@ -70,31 +70,31 @@ func NewBottomPanel(p *player.Player) *BottomPanel { bp := &BottomPanel{} bp.ExtendBaseWidget(bp) p.OnPaused(func() { - bp.controls.SetPlaying(false) + bp.Controls.SetPlaying(false) }) p.OnPlaying(func() { - bp.controls.SetPlaying(true) + bp.Controls.SetPlaying(true) }) p.OnStopped(func() { - bp.controls.SetPlaying(false) + bp.Controls.SetPlaying(false) }) - bp.nowPlaying = widgets.NewNowPlayingCard() - bp.controls = widgets.NewPlayerControls() - bp.controls.OnPlayPause(func() { + bp.NowPlaying = widgets.NewNowPlayingCard() + bp.Controls = widgets.NewPlayerControls() + bp.Controls.OnPlayPause(func() { p.PlayPause() }) - bp.controls.OnSeekNext(func() { + bp.Controls.OnSeekNext(func() { p.SeekNext() }) - bp.controls.OnSeekPrevious(func() { + bp.Controls.OnSeekPrevious(func() { p.SeekBackOrPrevious() }) - bp.controls.OnSeek(func(f float64) { + bp.Controls.OnSeek(func(f float64) { p.Seek(fmt.Sprintf("%d", int(f*100)), player.SeekAbsolutePercent) }) - bp.container = container.New(newBottomPanelLayout(500, bp.nowPlaying, bp.controls, nil), bp.nowPlaying, bp.controls) + bp.container = container.New(newBottomPanelLayout(500, bp.NowPlaying, bp.Controls, nil), bp.NowPlaying, bp.Controls) return bp } @@ -103,20 +103,20 @@ func (bp *BottomPanel) SetPlaybackManager(pm *backend.PlaybackManager) { pm.OnSongChange(bp.onSongChange) pm.OnPlayTimeUpdate(func(cur, total float64) { if !pm.IsSeeking() { - bp.controls.UpdatePlayTime(cur, total) + bp.Controls.UpdatePlayTime(cur, total) } }) } func (bp *BottomPanel) onSongChange(song *subsonic.Child) { if song == nil { - bp.nowPlaying.Update("", "", "", nil) + bp.NowPlaying.Update("", "", "", nil) } else { var im image.Image if bp.ImageManager != nil { im, _ = bp.ImageManager.GetAlbumThumbnail(song.AlbumID) } - bp.nowPlaying.Update(song.Title, song.Artist, song.Album, im) + bp.NowPlaying.Update(song.Title, song.Artist, song.Album, im) } } diff --git a/ui/mainwindow.go b/ui/mainwindow.go index 160180c..fa4713d 100644 --- a/ui/mainwindow.go +++ b/ui/mainwindow.go @@ -1,11 +1,14 @@ package ui import ( - "supersonic/player" + "supersonic/backend" + "supersonic/ui/widgets" "fyne.io/fyne/v2" "fyne.io/fyne/v2/container" "fyne.io/fyne/v2/layout" + "fyne.io/fyne/v2/widget" + "github.com/dweymouth/go-subsonic" ) type MainWindow struct { @@ -14,17 +17,43 @@ type MainWindow struct { BottomPanel *BottomPanel } -func NewMainWindow(app fyne.App, appName string, p *player.Player) MainWindow { +func NewMainWindow(fyneApp fyne.App, appName string, app *backend.App) MainWindow { m := MainWindow{ - Window: app.NewWindow(appName), - BottomPanel: NewBottomPanel(p), + Window: fyneApp.NewWindow(appName), + BottomPanel: NewBottomPanel(app.Player), } + m.BottomPanel.SetPlaybackManager(app.PlaybackManager) + m.BottomPanel.ImageManager = app.ImageManager c := container.NewBorder(nil, m.BottomPanel, nil, nil, &layout.Spacer{}) m.Window.SetContent(c) m.Window.Resize(fyne.NewSize(1000, 800)) + app.PlaybackManager.OnSongChange(func(song *subsonic.Child) { + if song == nil { + m.Window.SetTitle(appName) + return + } + m.Window.SetTitle(song.Title) + }) + app.ServerManager.OnServerConnected(func() { + ag := NewAlbumGrid(app.LibraryManager.RecentlyAddedIter(), app.ImageManager.GetAlbumThumbnail) + ag.OnPlayAlbum = func(albumID string) { + _ = app.PlaybackManager.PlayAlbum(albumID) + } + m.Window.SetContent(container.NewBorder(nil, m.BottomPanel, nil, nil, ag)) + }) return m } +func (m *MainWindow) PromptForFirstServer(cb func(string, string, string, string)) { + d := widgets.NewAddServerForm("Connect to Server") + pop := widget.NewModalPopUp(d, m.Canvas()) + d.OnSubmit = func() { + pop.Hide() + cb(d.Nickname, d.Host, d.Username, d.Password) + } + pop.Show() +} + func (m *MainWindow) Show() { m.Window.Show() }