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 {
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*/)
+1
View File
@@ -59,6 +59,7 @@ type AppConfig struct {
EnableOSMediaPlayerAPIs bool
ShowSidebar bool
SidebarWidthFraction float64
SidebarTab string
FontNormalTTF 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
// 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)
+1 -1
View File
@@ -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:
+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.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.Router = browsing.NewRouter(app, m.Controller, m.BrowsingPane)
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) {
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, ", ")
@@ -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
+108 -7
View File
@@ -15,32 +15,133 @@ 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
}
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)
}
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),
))
}