Merge pull request #744 from dweymouth/feature/lyrics-sidebar
Add lyrics viewer to sidebar
This commit is contained in:
+4
-2
@@ -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*/)
|
||||
|
||||
@@ -59,6 +59,7 @@ type AppConfig struct {
|
||||
EnableOSMediaPlayerAPIs bool
|
||||
ShowSidebar bool
|
||||
SidebarWidthFraction float64
|
||||
SidebarTab string
|
||||
|
||||
FontNormalTTF string
|
||||
FontBoldTTF string
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -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
@@ -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),
|
||||
))
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user