diff --git a/backend/mediaprovider/mediaprovider.go b/backend/mediaprovider/mediaprovider.go index eca6fd6..52d455e 100644 --- a/backend/mediaprovider/mediaprovider.go +++ b/backend/mediaprovider/mediaprovider.go @@ -160,6 +160,10 @@ type SupportsSharing interface { CanShareArtists() bool } +type LyricsProvider interface { + GetLyrics(track *Track) (*Lyrics, error) +} + type JukeboxProvider interface { JukeboxStart() error JukeboxStop() error diff --git a/backend/mediaprovider/model.go b/backend/mediaprovider/model.go index ac4201a..d7a820f 100644 --- a/backend/mediaprovider/model.go +++ b/backend/mediaprovider/model.go @@ -120,6 +120,18 @@ type PlaylistWithTracks struct { Tracks []*Track } +type Lyrics struct { + Title string + Artist string + Synced bool + Lines []LyricLine +} + +type LyricLine struct { + Text string + Start float64 // seconds +} + type ContentType int const ( diff --git a/backend/mediaprovider/subsonic/subsonicmediaprovider.go b/backend/mediaprovider/subsonic/subsonicmediaprovider.go index ae64b94..f18f649 100644 --- a/backend/mediaprovider/subsonic/subsonicmediaprovider.go +++ b/backend/mediaprovider/subsonic/subsonicmediaprovider.go @@ -6,6 +6,7 @@ import ( "io" "math" "net/url" + "slices" "strconv" "strings" "sync" @@ -331,6 +332,53 @@ func (s *subsonicMediaProvider) RescanLibrary() error { return err } +// LyricsProvider interface +var _ mediaprovider.LyricsProvider = (*subsonicMediaProvider)(nil) + +func (s *subsonicMediaProvider) GetLyrics(track *mediaprovider.Track) (*mediaprovider.Lyrics, error) { + ext, err := s.client.GetOpenSubsonicExtensions() + supportsSynced := err == nil && + slices.ContainsFunc(ext, func(ext *subsonic.OpenSubsonicExtension) bool { + return ext.Name == subsonic.SongLyricsExtension + }) + if supportsSynced { + lyrics, err := s.client.GetLyricsBySongId(track.ID) + if err != nil || len(lyrics.StructuredLyrics) == 0 { + return nil, err + } + lyric := lyrics.StructuredLyrics[0] + mpLyrics := &mediaprovider.Lyrics{ + Title: lyric.DisplayTitle, + Artist: lyric.DisplayArtist, + Synced: lyric.Synced, + } + for _, line := range lyric.Lines { + mpLyrics.Lines = append(mpLyrics.Lines, mediaprovider.LyricLine{ + Text: line.Text, + Start: float64(line.Start) / 1000, + }) + } + return mpLyrics, nil + } + // fallback to legacy getLyrics endpoint + lyrics, err := s.client.GetLyrics(track.Name, track.ArtistNames[0]) + if err != nil || lyrics == nil || lyrics.Text == "" { + return nil, err + } + mpLyrics := &mediaprovider.Lyrics{ + Title: lyrics.Title, + Artist: lyrics.Artist, + Synced: false, + } + lines := strings.Split(lyrics.Text, "\n") + for _, line := range lines { + mpLyrics.Lines = append(mpLyrics.Lines, mediaprovider.LyricLine{ + Text: line, + }) + } + return mpLyrics, nil +} + func toTrack(ch *subsonic.Child) *mediaprovider.Track { if ch == nil { return nil diff --git a/go.mod b/go.mod index 3af1b7e..e5b31ee 100644 --- a/go.mod +++ b/go.mod @@ -8,7 +8,7 @@ require ( github.com/deluan/sanitize v0.0.0-20230310221930-6e18967d9fc1 github.com/dweymouth/go-jellyfin v0.0.0-20231116161116-e800860bdacc github.com/dweymouth/go-mpv v0.0.0-20230406003141-7f1858e503ee - github.com/dweymouth/go-subsonic v0.0.0-20240225165422-3617754d751b + github.com/dweymouth/go-subsonic v0.0.0-20240305034202-6193dca1c9de github.com/fsnotify/fsnotify v1.6.0 github.com/godbus/dbus/v5 v5.1.0 github.com/google/uuid v1.3.0 diff --git a/go.sum b/go.sum index feb47c5..5cec0b5 100644 --- a/go.sum +++ b/go.sum @@ -75,8 +75,8 @@ github.com/dweymouth/go-jellyfin v0.0.0-20231116161116-e800860bdacc h1:wJy4U12Ys github.com/dweymouth/go-jellyfin v0.0.0-20231116161116-e800860bdacc/go.mod h1:BMwS4vdjEYf1gmjPGSKCzWP/I6YlI6fkefJ9nsjBjaU= github.com/dweymouth/go-mpv v0.0.0-20230406003141-7f1858e503ee h1:ZGyJ6wp7CAfT31BugypcF/TPKEy2RrGR9JFq1JOjOpY= github.com/dweymouth/go-mpv v0.0.0-20230406003141-7f1858e503ee/go.mod h1:Ov0ieN90M7i+0k3OxhA/g1dozGs+UcPHDsMKqPgRDk0= -github.com/dweymouth/go-subsonic v0.0.0-20240225165422-3617754d751b h1:LGUWXRcndcWwBN0HTFsCUlIjr8aakRsi9FlUX1CPSs8= -github.com/dweymouth/go-subsonic v0.0.0-20240225165422-3617754d751b/go.mod h1:OWtcumdQsan8uM6wmx6PqKhldaCthH10CQ+vb+94kzo= +github.com/dweymouth/go-subsonic v0.0.0-20240305034202-6193dca1c9de h1:RVYHvjT01YSLphWlTA/uaGtfVp2x6/4UfOIYYp9vMis= +github.com/dweymouth/go-subsonic v0.0.0-20240305034202-6193dca1c9de/go.mod h1:OWtcumdQsan8uM6wmx6PqKhldaCthH10CQ+vb+94kzo= github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98= diff --git a/ui/browsing/nowplayingpage.go b/ui/browsing/nowplayingpage.go index 8f9d0ab..daf1f92 100644 --- a/ui/browsing/nowplayingpage.go +++ b/ui/browsing/nowplayingpage.go @@ -46,6 +46,7 @@ type NowPlayingPage struct { type nowPlayingPageState struct { contr *controller.Controller pool *util.WidgetPool + sm *backend.ServerManager pm *backend.PlaybackManager im *backend.ImageManager canRate bool @@ -55,13 +56,14 @@ type nowPlayingPageState struct { func NewNowPlayingPage( contr *controller.Controller, pool *util.WidgetPool, + sm *backend.ServerManager, im *backend.ImageManager, pm *backend.PlaybackManager, canRate bool, canShare bool, ) *NowPlayingPage { a := &NowPlayingPage{nowPlayingPageState: nowPlayingPageState{ - contr: contr, pool: pool, im: im, pm: pm, canRate: canRate, canShare: canShare, + contr: contr, pool: pool, sm: sm, im: im, pm: pm, canRate: canRate, canShare: canShare, }} a.ExtendBaseWidget(a) @@ -181,6 +183,7 @@ func (a *NowPlayingPage) OnSongChange(song, lastScrobbledIfAny *mediaprovider.Tr a.card.SetCoverImage(nil) return } + a.imageLoadCancel = a.im.GetFullSizeCoverArtAsync(song.CoverArtID, func(img image.Image, err error) { if err != nil { log.Printf("error loading cover art: %v\n", err) @@ -188,6 +191,18 @@ func (a *NowPlayingPage) OnSongChange(song, lastScrobbledIfAny *mediaprovider.Tr a.card.SetCoverImage(img) } }) + + go func() { + if lp, ok := a.sm.Server.(mediaprovider.LyricsProvider); ok { + lyrics, err := lp.GetLyrics(song) + if err == nil { + a.lyricsViewer.SetLyrics(lyrics) + } else { + log.Printf("Error fetching lyrics: %v", err) + a.lyricsViewer.SetLyrics(nil) + } + } + }() } func (a *NowPlayingPage) OnPlayQueueChange() { @@ -209,7 +224,7 @@ func (s *nowPlayingPageState) Restore() Page { page.Reload() return page } - return NewNowPlayingPage(s.contr, s.pool, s.im, s.pm, s.canRate, s.canShare) + return NewNowPlayingPage(s.contr, s.pool, s.sm, s.im, s.pm, s.canRate, s.canShare) } var _ CanShowPlayTime = (*NowPlayingPage)(nil) diff --git a/ui/browsing/router.go b/ui/browsing/router.go index c92d2e9..65aa5ed 100644 --- a/ui/browsing/router.go +++ b/ui/browsing/router.go @@ -43,14 +43,12 @@ func (r Router) CreatePage(rte controller.Route) Page { return NewArtistsPage(r.Controller, r.widgetPool, r.App.PlaybackManager, r.App.ServerManager.Server, r.App.ImageManager) case controller.Favorites: return NewFavoritesPage(&r.App.Config.FavoritesPage, r.widgetPool, r.Controller, r.App.ServerManager.Server, r.App.PlaybackManager, r.App.ImageManager) - case controller.Fullscreen: - return NewNowPlayingPage(r.Controller, r.widgetPool, r.App.ImageManager, r.App.PlaybackManager, canRate, canShare) case controller.Genre: return NewGenrePage(rte.Arg, r.widgetPool, r.Controller, r.App.PlaybackManager, r.App.ServerManager.Server, r.App.ImageManager) case controller.Genres: return NewGenresPage(r.Controller, r.App.ServerManager.Server) case controller.NowPlaying: - return NewNowPlayingPage(r.Controller, r.widgetPool, r.App.ImageManager, r.App.PlaybackManager, canRate, canShare) + return NewNowPlayingPage(r.Controller, r.widgetPool, r.App.ServerManager, r.App.ImageManager, r.App.PlaybackManager, 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: diff --git a/ui/controller/routes.go b/ui/controller/routes.go index a351b8f..dc2255a 100644 --- a/ui/controller/routes.go +++ b/ui/controller/routes.go @@ -8,7 +8,6 @@ const ( Albums Artist Artists - Fullscreen Genre Genres Favorites diff --git a/ui/widgets/lyricsviewer.go b/ui/widgets/lyricsviewer.go index 683771f..daffc61 100644 --- a/ui/widgets/lyricsviewer.go +++ b/ui/widgets/lyricsviewer.go @@ -1,15 +1,53 @@ package widgets -import "fyne.io/fyne/v2/widget" +import ( + "fyne.io/fyne/v2" + "fyne.io/fyne/v2/container" + "fyne.io/fyne/v2/widget" + "github.com/dweymouth/supersonic/backend/mediaprovider" +) type LyricsViewer struct { - widget.Label + widget.BaseWidget + + noLyricsLabel widget.Label + unsyncedViewer *widget.RichText + + container *container.Scroll } func NewLyricsViewer() *LyricsViewer { - l := &LyricsViewer{Label: widget.Label{ + l := &LyricsViewer{noLyricsLabel: widget.Label{ Text: "Lyrics not available", }} l.ExtendBaseWidget(l) + l.container = container.NewVScroll(&l.noLyricsLabel) return l } + +func (l *LyricsViewer) SetLyrics(lyrics *mediaprovider.Lyrics) { + if lyrics == nil || len(lyrics.Lines) == 0 { + l.container.Content = &l.noLyricsLabel + l.Refresh() + return + } + + if l.unsyncedViewer == nil { + l.unsyncedViewer = widget.NewRichText() + l.unsyncedViewer.Wrapping = fyne.TextWrapWord + } + l.unsyncedViewer.Segments = l.unsyncedViewer.Segments[:0] + for _, line := range lyrics.Lines { + ts := &widget.TextSegment{Text: line.Text} + ts.Style.Alignment = fyne.TextAlignCenter + ts.Style.SizeName = widget.RichTextStyleSubHeading.SizeName + ts.Style.Inline = false + l.unsyncedViewer.Segments = append(l.unsyncedViewer.Segments, ts) + } + l.container.Content = l.unsyncedViewer + l.Refresh() +} + +func (l *LyricsViewer) CreateRenderer() fyne.WidgetRenderer { + return widget.NewSimpleRenderer(l.container) +}