Merge pull request #744 from dweymouth/feature/lyrics-sidebar

Add lyrics viewer to sidebar
This commit is contained in:
Drew Weymouth
2025-11-01 12:46:42 -07:00
committed by GitHub
7 changed files with 211 additions and 44 deletions
+4 -2
View File
@@ -44,6 +44,7 @@ var (
type App struct { type App struct {
Config *Config Config *Config
ServerManager *ServerManager ServerManager *ServerManager
LyricsManager *LyricsManager
ImageManager *ImageManager ImageManager *ImageManager
AudioCache *AudioCache AudioCache *AudioCache
PlaybackManager *PlaybackManager PlaybackManager *PlaybackManager
@@ -52,7 +53,6 @@ type App struct {
MPRISHandler *MPRISHandler MPRISHandler *MPRISHandler
WinSMTC *windows.SMTC WinSMTC *windows.SMTC
ipcServer ipc.IPCServer ipcServer ipc.IPCServer
LrcLibFetcher *LrcLibFetcher
// UI callbacks to be set in main // UI callbacks to be set in main
OnReactivate func() OnReactivate func()
@@ -158,10 +158,12 @@ func StartupApp(appName, displayAppName, appVersion, appVersionTag, latestReleas
a.ServerManager.SetPrefetchAlbumCoverCallback(func(coverID string) { a.ServerManager.SetPrefetchAlbumCoverCallback(func(coverID string) {
_, _ = a.ImageManager.GetCoverThumbnail(coverID) _, _ = a.ImageManager.GetCoverThumbnail(coverID)
}) })
var fetch *LrcLibFetcher
if a.Config.Application.EnableLrcLib { if a.Config.Application.EnableLrcLib {
timeout := time.Duration(a.Config.Application.RequestTimeoutSeconds) * time.Second 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 // Periodically scan for remote players
go a.PlaybackManager.ScanRemotePlayers(a.bgrndCtx, true /*fastScan*/) go a.PlaybackManager.ScanRemotePlayers(a.bgrndCtx, true /*fastScan*/)
+1
View File
@@ -59,6 +59,7 @@ type AppConfig struct {
EnableOSMediaPlayerAPIs bool EnableOSMediaPlayerAPIs bool
ShowSidebar bool ShowSidebar bool
SidebarWidthFraction float64 SidebarWidthFraction float64
SidebarTab string
FontNormalTTF string FontNormalTTF string
FontBoldTTF string FontBoldTTF string
+77
View File
@@ -0,0 +1,77 @@
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()
lm.fetchInProgressID = ""
lm.fetchInProgressCancel()
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)
}
}
+8 -31
View File
@@ -61,7 +61,6 @@ type NowPlayingPage struct {
container *fyne.Container container *fyne.Container
// cancel funcs for background fetch tasks // cancel funcs for background fetch tasks
lyricFetchCancel context.CancelFunc
imageLoadCancel context.CancelFunc imageLoadCancel context.CancelFunc
relatedFetchCancel context.CancelFunc relatedFetchCancel context.CancelFunc
} }
@@ -72,11 +71,11 @@ type nowPlayingPageState struct {
pool *util.WidgetPool pool *util.WidgetPool
sm *backend.ServerManager sm *backend.ServerManager
pm *backend.PlaybackManager pm *backend.PlaybackManager
lm *backend.LyricsManager
im *backend.ImageManager im *backend.ImageManager
mp mediaprovider.MediaProvider mp mediaprovider.MediaProvider
canRate bool canRate bool
canShare bool canShare bool
lrcFetch *backend.LrcLibFetcher
} }
func NewNowPlayingPage( func NewNowPlayingPage(
@@ -84,15 +83,15 @@ func NewNowPlayingPage(
contr *controller.Controller, contr *controller.Controller,
pool *util.WidgetPool, pool *util.WidgetPool,
sm *backend.ServerManager, sm *backend.ServerManager,
lm *backend.LyricsManager,
im *backend.ImageManager, im *backend.ImageManager,
pm *backend.PlaybackManager, pm *backend.PlaybackManager,
mp mediaprovider.MediaProvider, mp mediaprovider.MediaProvider,
canRate bool, canRate bool,
canShare bool, canShare bool,
lrcLibFetcher *backend.LrcLibFetcher,
) *NowPlayingPage { ) *NowPlayingPage {
state := nowPlayingPageState{ 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 { if page, ok := pool.Obtain(util.WidgetTypeNowPlayingPage).(*NowPlayingPage); ok && page != nil {
page.nowPlayingPageState = state page.nowPlayingPageState = state
@@ -336,10 +335,6 @@ func (a *NowPlayingPage) onImageLoaded(img image.Image, err error) {
} }
func (a *NowPlayingPage) updateLyrics() { func (a *NowPlayingPage) updateLyrics() {
if a.lyricFetchCancel != nil {
a.lyricFetchCancel()
}
if a.nowPlayingID == a.curLyricsID { if a.nowPlayingID == a.curLyricsID {
if a.nowPlayingID != "" { if a.nowPlayingID != "" {
// just need to sync the current time // just need to sync the current time
@@ -354,8 +349,6 @@ func (a *NowPlayingPage) updateLyrics() {
return return
} }
a.curLyricsID = a.nowPlayingID a.curLyricsID = a.nowPlayingID
ctx, cancel := context.WithCancel(context.Background())
a.lyricFetchCancel = cancel
a.lyricsLoading.Start() a.lyricsLoading.Start()
// set the widget to an empty (not nil) lyric during fetch // set the widget to an empty (not nil) lyric during fetch
// to keep it from showing "Lyrics not available" // to keep it from showing "Lyrics not available"
@@ -365,27 +358,11 @@ func (a *NowPlayingPage) updateLyrics() {
Lines: []mediaprovider.LyricLine{{Text: ""}}, Lines: []mediaprovider.LyricLine{{Text: ""}},
}) })
tr, _ := a.nowPlaying.(*mediaprovider.Track) tr, _ := a.nowPlaying.(*mediaprovider.Track)
go a.fetchLyrics(ctx, tr)
}
func (a *NowPlayingPage) fetchLyrics(ctx context.Context, song *mediaprovider.Track) { a.lm.FetchLyricsAsync(tr, func(id string, lyrics *mediaprovider.Lyrics) {
var lyrics *mediaprovider.Lyrics if id != a.nowPlayingID {
var err error return
if lp, ok := a.sm.Server.(mediaprovider.LyricsProvider); ok {
if lyrics, err = lp.GetLyrics(song); err != nil {
log.Printf("Error fetching lyrics: %v", err)
} }
}
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() { fyne.Do(func() {
a.lyricsLoading.Stop() a.lyricsLoading.Stop()
a.lyricsViewer.EnableTapToSeek() a.lyricsViewer.EnableTapToSeek()
@@ -395,7 +372,7 @@ func (a *NowPlayingPage) fetchLyrics(ctx context.Context, song *mediaprovider.Tr
a.lyricsViewer.OnSeeked(a.lastPlayPos) a.lyricsViewer.OnSeeked(a.lastPlayPos)
} }
}) })
} })
} }
func (a *NowPlayingPage) updateRelatedList() { func (a *NowPlayingPage) updateRelatedList() {
@@ -464,7 +441,7 @@ func (a *NowPlayingPage) Reload() {
} }
func (s *nowPlayingPageState) Restore() Page { 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) var _ CanShowPlayTime = (*NowPlayingPage)(nil)
+1 -1
View File
@@ -48,7 +48,7 @@ func (r Router) CreatePage(rte controller.Route) Page {
case controller.Genres: case controller.Genres:
return NewGenresPage(r.Controller, r.App.ServerManager.Server) return NewGenresPage(r.Controller, r.App.ServerManager.Server)
case controller.NowPlaying: 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: case controller.Playlist:
return NewPlaylistPage(rte.Arg, &r.App.Config.PlaylistPage, r.widgetPool, r.Controller, r.App.ServerManager, r.App.PlaybackManager, r.App.ImageManager) return NewPlaylistPage(rte.Arg, &r.App.Config.PlaylistPage, r.widgetPool, r.Controller, r.App.ServerManager, r.App.PlaybackManager, r.App.ImageManager)
case controller.Playlists: case controller.Playlists:
+12 -3
View File
@@ -69,7 +69,10 @@ func NewMainWindow(fyneApp fyne.App, appName, displayAppName, appVersion string,
} }
m.Controller = controller.New(app, appVersion, m.Window) m.Controller = controller.New(app, appVersion, m.Window)
m.BrowsingPane = browsing.NewBrowsingPane(app.PlaybackManager, m.Controller, func() { m.Router.NavigateTo(m.StartupPage()) }) 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)
if m.App.Config.Application.SidebarTab == "Lyrics" {
m.Sidebar.SetSelectedIndex(1)
}
m.ToastOverlay = NewToastOverlay() m.ToastOverlay = NewToastOverlay()
m.Router = browsing.NewRouter(app, m.Controller, m.BrowsingPane) m.Router = browsing.NewRouter(app, m.Controller, m.BrowsingPane)
goHomeFn := func() { m.Router.NavigateTo(m.StartupPage()) } goHomeFn := func() { m.Router.NavigateTo(m.StartupPage()) }
@@ -167,12 +170,12 @@ func NewMainWindow(fyneApp fyne.App, appName, displayAppName, appVersion string,
func (m *MainWindow) UpdateOnTrackChange(item mediaprovider.MediaItem) { func (m *MainWindow) UpdateOnTrackChange(item mediaprovider.MediaItem) {
if item == nil { if item == nil {
m.Window.SetTitle(res.DisplayName) m.Window.SetTitle(res.DisplayName)
m.Sidebar.SetNowPlaying("") m.Sidebar.SetNowPlaying(nil)
return return
} }
meta := item.Metadata() meta := item.Metadata()
m.Sidebar.SetNowPlaying(meta.ID) m.Sidebar.SetNowPlaying(item)
artistDisp := "" artistDisp := ""
if tr, ok := item.(*mediaprovider.Track); ok { if tr, ok := item.(*mediaprovider.Track); ok {
artistDisp = " " + strings.Join(tr.ArtistNames, ", ") artistDisp = " " + strings.Join(tr.ArtistNames, ", ")
@@ -533,6 +536,12 @@ func (m *MainWindow) SaveWindowSettings() {
&m.App.Config.Application.WindowHeight) &m.App.Config.Application.WindowHeight)
m.App.Config.Application.ShowSidebar = !m.Sidebar.Hidden m.App.Config.Application.ShowSidebar = !m.Sidebar.Hidden
m.App.Config.Application.SidebarWidthFraction = m.splitContainer.Offset 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 // widget just so we can catch a tap event that doesn't land anywhere else
+108 -7
View File
@@ -15,32 +15,133 @@ import (
type Sidebar struct { type Sidebar struct {
widget.BaseWidget 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{ s := &Sidebar{
lm: lm,
queueList: widgets.NewPlayQueueList(im, false), queueList: widgets.NewPlayQueueList(im, false),
} }
s.queueList.Reorderable = true s.queueList.Reorderable = true
contr.ConnectPlayQueuelistActions(s.queueList) 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) s.ExtendBaseWidget(s)
return s 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) { func (s *Sidebar) SetQueueTracks(items []mediaprovider.MediaItem) {
s.queueList.SetItems(items) s.queueList.SetItems(items)
} }
func (s *Sidebar) SetNowPlaying(itemID string) { func (s *Sidebar) SetNowPlaying(item mediaprovider.MediaItem) {
s.queueList.SetNowPlaying(itemID) 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 { func (s *Sidebar) CreateRenderer() fyne.WidgetRenderer {
return widget.NewSimpleRenderer(container.NewStack( return widget.NewSimpleRenderer(container.NewStack(
theme.NewThemedRectangle(theme.ColorNamePageBackground), theme.NewThemedRectangle(theme.ColorNamePageBackground),
container.NewAppTabs( container.NewPadded(s.tabs),
container.NewTabItem(lang.L("Play Queue"), container.NewPadded(s.queueList)),
),
)) ))
} }