Merge pull request #342 from dweymouth/feature/lyrics
Add lyrics viewer
This commit is contained in:
@@ -160,6 +160,10 @@ type SupportsSharing interface {
|
|||||||
CanShareArtists() bool
|
CanShareArtists() bool
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type LyricsProvider interface {
|
||||||
|
GetLyrics(track *Track) (*Lyrics, error)
|
||||||
|
}
|
||||||
|
|
||||||
type JukeboxProvider interface {
|
type JukeboxProvider interface {
|
||||||
JukeboxStart() error
|
JukeboxStart() error
|
||||||
JukeboxStop() error
|
JukeboxStop() error
|
||||||
|
|||||||
@@ -120,6 +120,18 @@ type PlaylistWithTracks struct {
|
|||||||
Tracks []*Track
|
Tracks []*Track
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type Lyrics struct {
|
||||||
|
Title string
|
||||||
|
Artist string
|
||||||
|
Synced bool
|
||||||
|
Lines []LyricLine
|
||||||
|
}
|
||||||
|
|
||||||
|
type LyricLine struct {
|
||||||
|
Text string
|
||||||
|
Start float64 // seconds
|
||||||
|
}
|
||||||
|
|
||||||
type ContentType int
|
type ContentType int
|
||||||
|
|
||||||
const (
|
const (
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import (
|
|||||||
"io"
|
"io"
|
||||||
"math"
|
"math"
|
||||||
"net/url"
|
"net/url"
|
||||||
|
"slices"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
@@ -331,6 +332,53 @@ func (s *subsonicMediaProvider) RescanLibrary() error {
|
|||||||
return err
|
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 {
|
func toTrack(ch *subsonic.Child) *mediaprovider.Track {
|
||||||
if ch == nil {
|
if ch == nil {
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ require (
|
|||||||
github.com/deluan/sanitize v0.0.0-20230310221930-6e18967d9fc1
|
github.com/deluan/sanitize v0.0.0-20230310221930-6e18967d9fc1
|
||||||
github.com/dweymouth/go-jellyfin v0.0.0-20231116161116-e800860bdacc
|
github.com/dweymouth/go-jellyfin v0.0.0-20231116161116-e800860bdacc
|
||||||
github.com/dweymouth/go-mpv v0.0.0-20230406003141-7f1858e503ee
|
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/fsnotify/fsnotify v1.6.0
|
||||||
github.com/godbus/dbus/v5 v5.1.0
|
github.com/godbus/dbus/v5 v5.1.0
|
||||||
github.com/google/uuid v1.3.0
|
github.com/google/uuid v1.3.0
|
||||||
|
|||||||
@@ -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-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 h1:ZGyJ6wp7CAfT31BugypcF/TPKEy2RrGR9JFq1JOjOpY=
|
||||||
github.com/dweymouth/go-mpv v0.0.0-20230406003141-7f1858e503ee/go.mod h1:Ov0ieN90M7i+0k3OxhA/g1dozGs+UcPHDsMKqPgRDk0=
|
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-20240305034202-6193dca1c9de h1:RVYHvjT01YSLphWlTA/uaGtfVp2x6/4UfOIYYp9vMis=
|
||||||
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/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.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.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
|
||||||
github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98=
|
github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98=
|
||||||
|
|||||||
@@ -46,6 +46,7 @@ type NowPlayingPage struct {
|
|||||||
type nowPlayingPageState struct {
|
type nowPlayingPageState struct {
|
||||||
contr *controller.Controller
|
contr *controller.Controller
|
||||||
pool *util.WidgetPool
|
pool *util.WidgetPool
|
||||||
|
sm *backend.ServerManager
|
||||||
pm *backend.PlaybackManager
|
pm *backend.PlaybackManager
|
||||||
im *backend.ImageManager
|
im *backend.ImageManager
|
||||||
canRate bool
|
canRate bool
|
||||||
@@ -55,13 +56,14 @@ type nowPlayingPageState struct {
|
|||||||
func NewNowPlayingPage(
|
func NewNowPlayingPage(
|
||||||
contr *controller.Controller,
|
contr *controller.Controller,
|
||||||
pool *util.WidgetPool,
|
pool *util.WidgetPool,
|
||||||
|
sm *backend.ServerManager,
|
||||||
im *backend.ImageManager,
|
im *backend.ImageManager,
|
||||||
pm *backend.PlaybackManager,
|
pm *backend.PlaybackManager,
|
||||||
canRate bool,
|
canRate bool,
|
||||||
canShare bool,
|
canShare bool,
|
||||||
) *NowPlayingPage {
|
) *NowPlayingPage {
|
||||||
a := &NowPlayingPage{nowPlayingPageState: nowPlayingPageState{
|
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)
|
a.ExtendBaseWidget(a)
|
||||||
|
|
||||||
@@ -181,6 +183,7 @@ func (a *NowPlayingPage) OnSongChange(song, lastScrobbledIfAny *mediaprovider.Tr
|
|||||||
a.card.SetCoverImage(nil)
|
a.card.SetCoverImage(nil)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
a.imageLoadCancel = a.im.GetFullSizeCoverArtAsync(song.CoverArtID, func(img image.Image, err error) {
|
a.imageLoadCancel = a.im.GetFullSizeCoverArtAsync(song.CoverArtID, func(img image.Image, err error) {
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("error loading cover art: %v\n", err)
|
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)
|
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() {
|
func (a *NowPlayingPage) OnPlayQueueChange() {
|
||||||
@@ -209,7 +224,7 @@ func (s *nowPlayingPageState) Restore() Page {
|
|||||||
page.Reload()
|
page.Reload()
|
||||||
return page
|
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)
|
var _ CanShowPlayTime = (*NowPlayingPage)(nil)
|
||||||
|
|||||||
@@ -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)
|
return NewArtistsPage(r.Controller, r.widgetPool, r.App.PlaybackManager, r.App.ServerManager.Server, r.App.ImageManager)
|
||||||
case controller.Favorites:
|
case controller.Favorites:
|
||||||
return NewFavoritesPage(&r.App.Config.FavoritesPage, r.widgetPool, r.Controller, r.App.ServerManager.Server, r.App.PlaybackManager, r.App.ImageManager)
|
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:
|
case controller.Genre:
|
||||||
return NewGenrePage(rte.Arg, r.widgetPool, r.Controller, r.App.PlaybackManager, r.App.ServerManager.Server, r.App.ImageManager)
|
return NewGenrePage(rte.Arg, r.widgetPool, r.Controller, r.App.PlaybackManager, r.App.ServerManager.Server, r.App.ImageManager)
|
||||||
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.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:
|
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:
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ const (
|
|||||||
Albums
|
Albums
|
||||||
Artist
|
Artist
|
||||||
Artists
|
Artists
|
||||||
Fullscreen
|
|
||||||
Genre
|
Genre
|
||||||
Genres
|
Genres
|
||||||
Favorites
|
Favorites
|
||||||
|
|||||||
@@ -1,15 +1,53 @@
|
|||||||
package widgets
|
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 {
|
type LyricsViewer struct {
|
||||||
widget.Label
|
widget.BaseWidget
|
||||||
|
|
||||||
|
noLyricsLabel widget.Label
|
||||||
|
unsyncedViewer *widget.RichText
|
||||||
|
|
||||||
|
container *container.Scroll
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewLyricsViewer() *LyricsViewer {
|
func NewLyricsViewer() *LyricsViewer {
|
||||||
l := &LyricsViewer{Label: widget.Label{
|
l := &LyricsViewer{noLyricsLabel: widget.Label{
|
||||||
Text: "Lyrics not available",
|
Text: "Lyrics not available",
|
||||||
}}
|
}}
|
||||||
l.ExtendBaseWidget(l)
|
l.ExtendBaseWidget(l)
|
||||||
|
l.container = container.NewVScroll(&l.noLyricsLabel)
|
||||||
return l
|
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)
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user