From 711d5645095f9e383b7d814a34393266fb8b0e79 Mon Sep 17 00:00:00 2001 From: Drew Weymouth Date: Sat, 1 Nov 2025 09:58:30 -0700 Subject: [PATCH 1/5] refactor: extract lyric fetching logic to LyricsManager --- backend/app.go | 6 ++++-- ui/browsing/nowplayingpage.go | 39 +++++++---------------------------- ui/browsing/router.go | 2 +- 3 files changed, 13 insertions(+), 34 deletions(-) diff --git a/backend/app.go b/backend/app.go index 8a5bb2e..e5cf402 100644 --- a/backend/app.go +++ b/backend/app.go @@ -44,6 +44,7 @@ var ( type App struct { Config *Config ServerManager *ServerManager + LyricsManager *LyricsManager ImageManager *ImageManager AudioCache *AudioCache PlaybackManager *PlaybackManager @@ -52,7 +53,6 @@ type App struct { MPRISHandler *MPRISHandler WinSMTC *windows.SMTC ipcServer ipc.IPCServer - LrcLibFetcher *LrcLibFetcher // UI callbacks to be set in main OnReactivate func() @@ -158,10 +158,12 @@ func StartupApp(appName, displayAppName, appVersion, appVersionTag, latestReleas a.ServerManager.SetPrefetchAlbumCoverCallback(func(coverID string) { _, _ = a.ImageManager.GetCoverThumbnail(coverID) }) + var fetch *LrcLibFetcher if a.Config.Application.EnableLrcLib { timeout := time.Duration(a.Config.Application.RequestTimeoutSeconds) * time.Second - a.LrcLibFetcher = NewLrcLibFetcher(a.cacheDir, a.Config.Application.CustomLrcLibUrl, timeout) + fetch = NewLrcLibFetcher(a.cacheDir, a.Config.Application.CustomLrcLibUrl, timeout) } + a.LyricsManager = NewLyricsManager(a.ServerManager, fetch) // Periodically scan for remote players go a.PlaybackManager.ScanRemotePlayers(a.bgrndCtx, true /*fastScan*/) diff --git a/ui/browsing/nowplayingpage.go b/ui/browsing/nowplayingpage.go index 7059d34..82514b6 100644 --- a/ui/browsing/nowplayingpage.go +++ b/ui/browsing/nowplayingpage.go @@ -61,7 +61,6 @@ type NowPlayingPage struct { container *fyne.Container // cancel funcs for background fetch tasks - lyricFetchCancel context.CancelFunc imageLoadCancel context.CancelFunc relatedFetchCancel context.CancelFunc } @@ -72,11 +71,11 @@ type nowPlayingPageState struct { pool *util.WidgetPool sm *backend.ServerManager pm *backend.PlaybackManager + lm *backend.LyricsManager im *backend.ImageManager mp mediaprovider.MediaProvider canRate bool canShare bool - lrcFetch *backend.LrcLibFetcher } func NewNowPlayingPage( @@ -84,15 +83,15 @@ func NewNowPlayingPage( contr *controller.Controller, pool *util.WidgetPool, sm *backend.ServerManager, + lm *backend.LyricsManager, im *backend.ImageManager, pm *backend.PlaybackManager, mp mediaprovider.MediaProvider, canRate bool, canShare bool, - lrcLibFetcher *backend.LrcLibFetcher, ) *NowPlayingPage { state := nowPlayingPageState{ - conf: conf, contr: contr, pool: pool, sm: sm, im: im, pm: pm, mp: mp, canRate: canRate, canShare: canShare, lrcFetch: lrcLibFetcher, + conf: conf, contr: contr, pool: pool, sm: sm, lm: lm, im: im, pm: pm, mp: mp, canRate: canRate, canShare: canShare, } if page, ok := pool.Obtain(util.WidgetTypeNowPlayingPage).(*NowPlayingPage); ok && page != nil { page.nowPlayingPageState = state @@ -336,10 +335,6 @@ func (a *NowPlayingPage) onImageLoaded(img image.Image, err error) { } func (a *NowPlayingPage) updateLyrics() { - if a.lyricFetchCancel != nil { - a.lyricFetchCancel() - } - if a.nowPlayingID == a.curLyricsID { if a.nowPlayingID != "" { // just need to sync the current time @@ -354,8 +349,6 @@ func (a *NowPlayingPage) updateLyrics() { return } a.curLyricsID = a.nowPlayingID - ctx, cancel := context.WithCancel(context.Background()) - a.lyricFetchCancel = cancel a.lyricsLoading.Start() // set the widget to an empty (not nil) lyric during fetch // to keep it from showing "Lyrics not available" @@ -365,27 +358,11 @@ func (a *NowPlayingPage) updateLyrics() { Lines: []mediaprovider.LyricLine{{Text: ""}}, }) tr, _ := a.nowPlaying.(*mediaprovider.Track) - go a.fetchLyrics(ctx, tr) -} -func (a *NowPlayingPage) fetchLyrics(ctx context.Context, song *mediaprovider.Track) { - var lyrics *mediaprovider.Lyrics - var err error - if lp, ok := a.sm.Server.(mediaprovider.LyricsProvider); ok { - if lyrics, err = lp.GetLyrics(song); err != nil { - log.Printf("Error fetching lyrics: %v", err) + a.lm.FetchLyricsAsync(tr, func(id string, lyrics *mediaprovider.Lyrics) { + if id != a.nowPlayingID { + return } - } - if lyrics == nil && a.lrcFetch != nil { - lyrics, err = a.lrcFetch.FetchLrcLibLyrics(song.Title, song.ArtistNames[0], song.Album, int(song.Duration.Seconds())) - if err != nil { - log.Println(err.Error()) - } - } - select { - case <-ctx.Done(): - return - default: fyne.Do(func() { a.lyricsLoading.Stop() a.lyricsViewer.EnableTapToSeek() @@ -395,7 +372,7 @@ func (a *NowPlayingPage) fetchLyrics(ctx context.Context, song *mediaprovider.Tr a.lyricsViewer.OnSeeked(a.lastPlayPos) } }) - } + }) } func (a *NowPlayingPage) updateRelatedList() { @@ -464,7 +441,7 @@ func (a *NowPlayingPage) Reload() { } func (s *nowPlayingPageState) Restore() Page { - return NewNowPlayingPage(s.conf, s.contr, s.pool, s.sm, s.im, s.pm, s.mp, s.canRate, s.canShare, s.lrcFetch) + return NewNowPlayingPage(s.conf, s.contr, s.pool, s.sm, s.lm, s.im, s.pm, s.mp, s.canRate, s.canShare) } var _ CanShowPlayTime = (*NowPlayingPage)(nil) diff --git a/ui/browsing/router.go b/ui/browsing/router.go index 80e631d..601a68f 100644 --- a/ui/browsing/router.go +++ b/ui/browsing/router.go @@ -48,7 +48,7 @@ func (r Router) CreatePage(rte controller.Route) Page { case controller.Genres: return NewGenresPage(r.Controller, r.App.ServerManager.Server) case controller.NowPlaying: - return NewNowPlayingPage(&r.App.Config.NowPlayingConfig, r.Controller, r.widgetPool, r.App.ServerManager, r.App.ImageManager, r.App.PlaybackManager, r.App.ServerManager.Server, canRate, canShare, r.App.LrcLibFetcher) + return NewNowPlayingPage(&r.App.Config.NowPlayingConfig, r.Controller, r.widgetPool, r.App.ServerManager, r.App.LyricsManager, r.App.ImageManager, r.App.PlaybackManager, r.App.ServerManager.Server, canRate, canShare) case controller.Playlist: return NewPlaylistPage(rte.Arg, &r.App.Config.PlaylistPage, r.widgetPool, r.Controller, r.App.ServerManager, r.App.PlaybackManager, r.App.ImageManager) case controller.Playlists: From 61a04e86b58f0171927785709dff42398650044b Mon Sep 17 00:00:00 2001 From: Drew Weymouth Date: Sat, 1 Nov 2025 11:26:49 -0700 Subject: [PATCH 2/5] add new lyricsmanager missed in last commit --- backend/lyricsmanager.go | 75 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 75 insertions(+) create mode 100644 backend/lyricsmanager.go diff --git a/backend/lyricsmanager.go b/backend/lyricsmanager.go new file mode 100644 index 0000000..1da38df --- /dev/null +++ b/backend/lyricsmanager.go @@ -0,0 +1,75 @@ +package backend + +import ( + "context" + "log" + "sync" + + "github.com/dweymouth/supersonic/backend/mediaprovider" +) + +type LyricsManager struct { + sm *ServerManager + lrclib *LrcLibFetcher + + // right now only one song can have lyrics being fetched + // at any given time (b/c we only show lyrics for the currently playing song) + lock sync.Mutex + fetchInProgressID string + fetchInProgressCancel context.CancelFunc + cbs []func(string, *mediaprovider.Lyrics) +} + +func NewLyricsManager(sm *ServerManager, lrclib *LrcLibFetcher) *LyricsManager { + return &LyricsManager{ + sm: sm, + lrclib: lrclib, + } +} + +func (lm *LyricsManager) FetchLyricsAsync(song *mediaprovider.Track, cb func(string, *mediaprovider.Lyrics)) { + lm.lock.Lock() + defer lm.lock.Unlock() + + if lm.fetchInProgressID == song.ID { + lm.cbs = append(lm.cbs, cb) + return + } + lm.fetchInProgressID = song.ID + + if lm.fetchInProgressCancel != nil { + lm.fetchInProgressCancel() + } + lm.cbs = []func(string, *mediaprovider.Lyrics){cb} + ctx, cancel := context.WithCancel(context.Background()) + lm.fetchInProgressCancel = cancel + go lm.fetchLyrics(ctx, song, func(id string, lyrics *mediaprovider.Lyrics) { + lm.lock.Lock() + defer lm.lock.Unlock() + for _, cb := range lm.cbs { + cb(id, lyrics) + } + }) +} + +func (lm *LyricsManager) fetchLyrics(ctx context.Context, song *mediaprovider.Track, cb func(string, *mediaprovider.Lyrics)) { + var lyrics *mediaprovider.Lyrics + var err error + if lp, ok := lm.sm.Server.(mediaprovider.LyricsProvider); ok { + if lyrics, err = lp.GetLyrics(song); err != nil { + log.Printf("Error fetching lyrics: %v", err) + } + } + if lyrics == nil && lm.lrclib != nil { + lyrics, err = lm.lrclib.FetchLrcLibLyrics(song.Title, song.ArtistNames[0], song.Album, int(song.Duration.Seconds())) + if err != nil { + log.Println(err.Error()) + } + } + select { + case <-ctx.Done(): + return + default: + cb(song.ID, lyrics) + } +} From 5aa8d36acea1ded894756c148d34ac1691c18afe Mon Sep 17 00:00:00 2001 From: Drew Weymouth Date: Sat, 1 Nov 2025 11:26:58 -0700 Subject: [PATCH 3/5] add lyrics to sidebar --- ui/mainwindow.go | 6 +-- ui/sidebar.go | 107 +++++++++++++++++++++++++++++++++++++++++++---- 2 files changed, 103 insertions(+), 10 deletions(-) diff --git a/ui/mainwindow.go b/ui/mainwindow.go index 206286a..4fa3120 100644 --- a/ui/mainwindow.go +++ b/ui/mainwindow.go @@ -69,7 +69,7 @@ func NewMainWindow(fyneApp fyne.App, appName, displayAppName, appVersion string, } m.Controller = controller.New(app, appVersion, m.Window) m.BrowsingPane = browsing.NewBrowsingPane(app.PlaybackManager, m.Controller, func() { m.Router.NavigateTo(m.StartupPage()) }) - m.Sidebar = NewSidebar(m.Controller, m.App.ImageManager) + m.Sidebar = NewSidebar(m.Controller, m.App.PlaybackManager, m.App.ImageManager, m.App.LyricsManager) m.ToastOverlay = NewToastOverlay() m.Router = browsing.NewRouter(app, m.Controller, m.BrowsingPane) goHomeFn := func() { m.Router.NavigateTo(m.StartupPage()) } @@ -167,12 +167,12 @@ func NewMainWindow(fyneApp fyne.App, appName, displayAppName, appVersion string, func (m *MainWindow) UpdateOnTrackChange(item mediaprovider.MediaItem) { if item == nil { m.Window.SetTitle(res.DisplayName) - m.Sidebar.SetNowPlaying("") + m.Sidebar.SetNowPlaying(nil) return } meta := item.Metadata() - m.Sidebar.SetNowPlaying(meta.ID) + m.Sidebar.SetNowPlaying(item) artistDisp := "" if tr, ok := item.(*mediaprovider.Track); ok { artistDisp = " – " + strings.Join(tr.ArtistNames, ", ") diff --git a/ui/sidebar.go b/ui/sidebar.go index f3fa7a9..aa5bf3d 100644 --- a/ui/sidebar.go +++ b/ui/sidebar.go @@ -15,15 +15,58 @@ import ( type Sidebar struct { widget.BaseWidget - queueList *widgets.PlayQueueList + lm *backend.LyricsManager + + queueList *widgets.PlayQueueList + lyricsViewer *widgets.LyricsViewer + lyricsLoading *widgets.LoadingDots + tabs *container.AppTabs + + nowPlaying mediaprovider.MediaItem + nowPlayingID string + curLyrics *mediaprovider.Lyrics + curLyricsID string + lastPlayPos float64 } -func NewSidebar(contr *controller.Controller, im *backend.ImageManager) *Sidebar { +func NewSidebar(contr *controller.Controller, pm *backend.PlaybackManager, im *backend.ImageManager, lm *backend.LyricsManager) *Sidebar { s := &Sidebar{ + lm: lm, queueList: widgets.NewPlayQueueList(im, false), } s.queueList.Reorderable = true contr.ConnectPlayQueuelistActions(s.queueList) + + s.lyricsViewer = widgets.NewLyricsViewer(func(i int) { + if s.curLyrics != nil && len(s.curLyrics.Lines) > i-1 { + time := s.curLyrics.Lines[i-1].Start + pm.SeekSeconds(time) + } + }) + s.lyricsLoading = widgets.NewLoadingDots() + + s.tabs = container.NewAppTabs( + container.NewTabItem(lang.L("Play Queue"), container.NewPadded(s.queueList)), + container.NewTabItem(lang.L("Lyrics"), container.NewStack( + s.lyricsViewer, + container.NewCenter(s.lyricsLoading))), + ) + s.tabs.OnSelected = func(*container.TabItem) { + idx := s.tabs.SelectedIndex() + if idx == 1 /*lyrics*/ { + s.updateLyrics() + } + } + + pm.OnPlayTimeUpdate(func(curTime, _ float64, seeked bool) { + s.lastPlayPos = curTime + if seeked { + fyne.Do(func() { s.lyricsViewer.OnSeeked(curTime) }) + } else { + fyne.Do(func() { s.lyricsViewer.UpdatePlayPos(curTime) }) + } + }) + s.ExtendBaseWidget(s) return s } @@ -32,15 +75,65 @@ func (s *Sidebar) SetQueueTracks(items []mediaprovider.MediaItem) { s.queueList.SetItems(items) } -func (s *Sidebar) SetNowPlaying(itemID string) { - s.queueList.SetNowPlaying(itemID) +func (s *Sidebar) SetNowPlaying(item mediaprovider.MediaItem) { + s.nowPlaying = item + id := "" + if item != nil { + id = item.Metadata().ID + } + s.queueList.SetNowPlaying(id) + s.nowPlayingID = id + if s.tabs.SelectedIndex() == 1 /*lyrics*/ { + s.updateLyrics() + } +} + +// TODO: this is more or less copy-paste from the Now Playing page +// refactor this shared logic somewhere else? +func (s *Sidebar) updateLyrics() { + if s.nowPlayingID == s.curLyricsID { + if s.nowPlayingID != "" { + // just need to sync the current time + s.lyricsViewer.OnSeeked(s.lastPlayPos) + return + } + } + if s.nowPlaying == nil || s.nowPlaying.Metadata().Type == mediaprovider.MediaItemTypeRadioStation { + s.lyricsViewer.SetLyrics(nil) + s.curLyrics = nil + s.curLyricsID = "" + return + } + s.curLyricsID = s.nowPlayingID + s.lyricsLoading.Start() + // set the widget to an empty (not nil) lyric during fetch + // to keep it from showing "Lyrics not available" + s.lyricsViewer.DisableTapToSeek() + s.lyricsViewer.SetLyrics(&mediaprovider.Lyrics{ + Synced: true, + Lines: []mediaprovider.LyricLine{{Text: ""}}, + }) + tr, _ := s.nowPlaying.(*mediaprovider.Track) + + s.lm.FetchLyricsAsync(tr, func(id string, lyrics *mediaprovider.Lyrics) { + if id != s.nowPlayingID { + return + } + fyne.Do(func() { + s.lyricsLoading.Stop() + s.lyricsViewer.EnableTapToSeek() + s.lyricsViewer.SetLyrics(lyrics) + s.curLyrics = lyrics + if lyrics != nil { + s.lyricsViewer.OnSeeked(s.lastPlayPos) + } + }) + }) } func (s *Sidebar) CreateRenderer() fyne.WidgetRenderer { return widget.NewSimpleRenderer(container.NewStack( theme.NewThemedRectangle(theme.ColorNamePageBackground), - container.NewAppTabs( - container.NewTabItem(lang.L("Play Queue"), container.NewPadded(s.queueList)), - ), + container.NewPadded(s.tabs), )) } From 694e59d5b62932162e30a39b30d036810d044251 Mon Sep 17 00:00:00 2001 From: Drew Weymouth Date: Sat, 1 Nov 2025 12:28:25 -0700 Subject: [PATCH 4/5] fix bug where only one lyrics viewer loads if both are visible --- backend/lyricsmanager.go | 2 ++ 1 file changed, 2 insertions(+) diff --git a/backend/lyricsmanager.go b/backend/lyricsmanager.go index 1da38df..c28729a 100644 --- a/backend/lyricsmanager.go +++ b/backend/lyricsmanager.go @@ -46,6 +46,8 @@ func (lm *LyricsManager) FetchLyricsAsync(song *mediaprovider.Track, cb func(str go lm.fetchLyrics(ctx, song, func(id string, lyrics *mediaprovider.Lyrics) { lm.lock.Lock() defer lm.lock.Unlock() + lm.fetchInProgressID = "" + lm.fetchInProgressCancel() for _, cb := range lm.cbs { cb(id, lyrics) } From 33eb081ff9a62053e45e5e201acb1b230f6649a5 Mon Sep 17 00:00:00 2001 From: Drew Weymouth Date: Sat, 1 Nov 2025 12:45:09 -0700 Subject: [PATCH 5/5] save selected sidebar tab --- backend/config.go | 1 + ui/mainwindow.go | 9 +++++++++ ui/sidebar.go | 8 ++++++++ 3 files changed, 18 insertions(+) diff --git a/backend/config.go b/backend/config.go index 15dbdf7..f6e5ae1 100644 --- a/backend/config.go +++ b/backend/config.go @@ -59,6 +59,7 @@ type AppConfig struct { EnableOSMediaPlayerAPIs bool ShowSidebar bool SidebarWidthFraction float64 + SidebarTab string FontNormalTTF string FontBoldTTF string diff --git a/ui/mainwindow.go b/ui/mainwindow.go index 4fa3120..9cf3193 100644 --- a/ui/mainwindow.go +++ b/ui/mainwindow.go @@ -70,6 +70,9 @@ func NewMainWindow(fyneApp fyne.App, appName, displayAppName, appVersion string, m.Controller = controller.New(app, appVersion, m.Window) m.BrowsingPane = browsing.NewBrowsingPane(app.PlaybackManager, m.Controller, func() { m.Router.NavigateTo(m.StartupPage()) }) m.Sidebar = NewSidebar(m.Controller, m.App.PlaybackManager, m.App.ImageManager, m.App.LyricsManager) + if m.App.Config.Application.SidebarTab == "Lyrics" { + m.Sidebar.SetSelectedIndex(1) + } m.ToastOverlay = NewToastOverlay() m.Router = browsing.NewRouter(app, m.Controller, m.BrowsingPane) goHomeFn := func() { m.Router.NavigateTo(m.StartupPage()) } @@ -533,6 +536,12 @@ func (m *MainWindow) SaveWindowSettings() { &m.App.Config.Application.WindowHeight) m.App.Config.Application.ShowSidebar = !m.Sidebar.Hidden m.App.Config.Application.SidebarWidthFraction = m.splitContainer.Offset + switch m.Sidebar.SelectedIndex() { + case 1: + m.App.Config.Application.SidebarTab = "Lyrics" + default: + m.App.Config.Application.SidebarTab = "Play Queue" + } } // widget just so we can catch a tap event that doesn't land anywhere else diff --git a/ui/sidebar.go b/ui/sidebar.go index aa5bf3d..d049749 100644 --- a/ui/sidebar.go +++ b/ui/sidebar.go @@ -71,6 +71,14 @@ func NewSidebar(contr *controller.Controller, pm *backend.PlaybackManager, im *b return s } +func (s *Sidebar) SelectedIndex() int { + return s.tabs.SelectedIndex() +} + +func (s *Sidebar) SetSelectedIndex(idx int) { + s.tabs.SelectIndex(idx) +} + func (s *Sidebar) SetQueueTracks(items []mediaprovider.MediaItem) { s.queueList.SetItems(items) }