diff --git a/backend/mediaprovider/helpers/other.go b/backend/mediaprovider/helpers/other.go index dd70978..41d9682 100644 --- a/backend/mediaprovider/helpers/other.go +++ b/backend/mediaprovider/helpers/other.go @@ -14,7 +14,11 @@ func GetSimilarSongsFallback(mp mediaprovider.MediaProvider, track *mediaprovide tracks, _ = mp.GetSimilarTracks(track.ArtistIDs[0], count) } if len(tracks) == 0 { - tracks, _ = mp.GetRandomTracks(track.Genre, count) + genre := "" + if len(track.Genres) > 0 { + genre = track.Genres[0] + } + tracks, _ = mp.GetRandomTracks(genre, count) } // make sure to exclude the song itself from the similar list diff --git a/backend/mediaprovider/model.go b/backend/mediaprovider/model.go index 40390ba..2516dc1 100644 --- a/backend/mediaprovider/model.go +++ b/backend/mediaprovider/model.go @@ -1,5 +1,7 @@ package mediaprovider +import "time" + // Bit field flag for the ReleaseTypes property type ReleaseType = int32 @@ -90,7 +92,7 @@ type Track struct { Duration int TrackNumber int DiscNumber int - Genre string + Genres []string ArtistIDs []string ArtistNames []string Album string @@ -100,9 +102,20 @@ type Track struct { Favorite bool Size int64 PlayCount int + LastPlayed time.Time FilePath string BitRate int + ContentType string Comment string + BPM int + ReplayGain ReplayGainInfo +} + +type ReplayGainInfo struct { + TrackGain float64 + AlbumGain float64 + TrackPeak float64 + AlbumPeak float64 } type Playlist struct { diff --git a/backend/mediaprovider/subsonic/subsonicmediaprovider.go b/backend/mediaprovider/subsonic/subsonicmediaprovider.go index 45fa8c2..cd3c681 100644 --- a/backend/mediaprovider/subsonic/subsonicmediaprovider.go +++ b/backend/mediaprovider/subsonic/subsonicmediaprovider.go @@ -493,6 +493,22 @@ func toTrack(ch *subsonic.Child) *mediaprovider.Track { artistIDs = append(artistIDs, ch.ArtistID) } + var rGain mediaprovider.ReplayGainInfo + if rg := ch.ReplayGain; rg != nil { + rGain.AlbumGain = rg.AlbumGain + rGain.TrackGain = rg.TrackGain + rGain.AlbumPeak = rg.AlbumPeak + rGain.TrackPeak = rg.TrackPeak + } + var genres []string + if len(ch.Genres) > 0 { + genres = sharedutil.MapSlice(ch.Genres, func(idName subsonic.IDName) string { + return idName.Name + }) + } else if ch.Genre != "" { + genres = []string{ch.Genre} + } + return &mediaprovider.Track{ ID: ch.ID, CoverArtID: ch.CoverArt, @@ -501,7 +517,7 @@ func toTrack(ch *subsonic.Child) *mediaprovider.Track { Duration: ch.Duration, TrackNumber: ch.Track, DiscNumber: ch.DiscNumber, - Genre: ch.Genre, + Genres: genres, ArtistIDs: artistIDs, ArtistNames: artistNames, Album: ch.Album, @@ -510,10 +526,14 @@ func toTrack(ch *subsonic.Child) *mediaprovider.Track { Rating: ch.UserRating, Favorite: !ch.Starred.IsZero(), PlayCount: int(ch.PlayCount), + LastPlayed: ch.Played, FilePath: ch.Path, Size: ch.Size, BitRate: ch.BitRate, + ContentType: ch.ContentType, Comment: ch.Comment, + BPM: ch.BPM, + ReplayGain: rGain, } } diff --git a/backend/mpris.go b/backend/mpris.go index 7be7121..d0716c3 100644 --- a/backend/mpris.go +++ b/backend/mpris.go @@ -254,7 +254,7 @@ func (m *MPRISHandler) Metadata() (types.Metadata, error) { var meta mediaprovider.MediaItemMetadata // metadata that can come only from tracks var discNumber, trackNumber, userRating, playCount, year int - var genre string + var genres []string if np := m.pm.NowPlaying(); np != nil && status.State != player.Stopped { meta = np.Metadata() @@ -264,7 +264,7 @@ func (m *MPRISHandler) Metadata() (types.Metadata, error) { userRating = track.Rating playCount = track.PlayCount year = track.Year - genre = track.Genre + genres = track.Genres } } var artURL string @@ -284,9 +284,7 @@ func (m *MPRISHandler) Metadata() (types.Metadata, error) { UserRating: float64(userRating) / 5, UseCount: playCount, ArtUrl: artURL, - } - if genre != "" { - mprisMeta.Genre = []string{genre} + Genre: genres, } if year != 0 { mprisMeta.ContentCreated = strconv.Itoa(year) diff --git a/go.mod b/go.mod index 2f438d5..7eb526a 100644 --- a/go.mod +++ b/go.mod @@ -12,7 +12,7 @@ require ( github.com/dweymouth/fyne-lyrics v0.0.0-20240528234907-15eee7ce5e64 github.com/dweymouth/go-jellyfin v0.0.0-20240517151952-5ceca61cb645 github.com/dweymouth/go-mpv v0.0.0-20230406003141-7f1858e503ee - github.com/dweymouth/go-subsonic v0.0.0-20240603150834-605046e7c78a + github.com/dweymouth/go-subsonic v0.0.0-20240716154859-c40a4519e1e0 github.com/godbus/dbus/v5 v5.1.0 github.com/google/uuid v1.3.0 github.com/pelletier/go-toml/v2 v2.0.8 diff --git a/go.sum b/go.sum index aa552b0..dd21869 100644 --- a/go.sum +++ b/go.sum @@ -87,6 +87,8 @@ github.com/dweymouth/go-mpv v0.0.0-20230406003141-7f1858e503ee h1:ZGyJ6wp7CAfT31 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-20240603150834-605046e7c78a h1:jcHDGzvpsyvEt8i0vqB92jHIfWJUt106QITrPoYjSWo= github.com/dweymouth/go-subsonic v0.0.0-20240603150834-605046e7c78a/go.mod h1:OWtcumdQsan8uM6wmx6PqKhldaCthH10CQ+vb+94kzo= +github.com/dweymouth/go-subsonic v0.0.0-20240716154859-c40a4519e1e0 h1:Zf27NWT1N889/toYK7/x7y7ERw8iJfAHBHGiMyYxaLY= +github.com/dweymouth/go-subsonic v0.0.0-20240716154859-c40a4519e1e0/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/controller/controller.go b/ui/controller/controller.go index 08f30e7..65dd509 100644 --- a/ui/controller/controller.go +++ b/ui/controller/controller.go @@ -146,6 +146,7 @@ func (m *Controller) connectTracklistActionsWithReplayGainMode(tracklist *widget tracklist.OnShare = func(trackID string) { go m.ShowShareDialog(trackID) } + tracklist.OnShowTrackInfo = m.ShowTrackInfoDialog tracklist.OnPlaySongRadio = func(track *mediaprovider.Track) { go func() { tracks, err := m.GetSongRadioTracks(track) @@ -858,6 +859,38 @@ func (c *Controller) ShowAlbumInfoDialog(albumID, albumName string, albumCover i }() } +func (c *Controller) ShowTrackInfoDialog(track *mediaprovider.Track) { + info := dialogs.NewTrackInfoDialog(track) + pop := widget.NewModalPopUp(info, c.MainWindow.Canvas()) + info.OnDismiss = func() { + pop.Hide() + c.doModalClosed() + } + info.OnNavigateToAlbum = func(albumID string) { + info.OnDismiss() + c.NavigateTo(AlbumRoute(albumID)) + } + info.OnNavigateToArtist = func(artistID string) { + info.OnDismiss() + c.NavigateTo(ArtistRoute(artistID)) + } + info.OnNavigateToGenre = func(genre string) { + info.OnDismiss() + c.NavigateTo(GenreRoute(genre)) + } + info.OnCopyFilePath = func() { + c.MainWindow.Clipboard().SetContent(track.FilePath) + } + c.ClosePopUpOnEscape(pop) + winSize := c.MainWindow.Canvas().Size() + popMin := pop.MinSize() + width := fyne.Max(700, fyne.Max(popMin.Width, winSize.Width*0.6)) + height := fyne.Max(500, fyne.Max(popMin.Height, winSize.Height*0.7)) + pop.Resize(fyne.NewSize(width, height)) + c.haveModal = true + pop.Show() +} + func (c *Controller) GetSongRadioTracks(sourceTrack *mediaprovider.Track) ([]*mediaprovider.Track, error) { radioTracks, err := c.App.ServerManager.Server.GetSongRadio(sourceTrack.ID, 100) if err != nil { diff --git a/ui/dialogs/trackinfodialog.go b/ui/dialogs/trackinfodialog.go new file mode 100644 index 0000000..7981bbb --- /dev/null +++ b/ui/dialogs/trackinfodialog.go @@ -0,0 +1,160 @@ +package dialogs + +import ( + "fmt" + "strconv" + "time" + + "fyne.io/fyne/v2" + "fyne.io/fyne/v2/container" + "fyne.io/fyne/v2/layout" + "fyne.io/fyne/v2/theme" + "fyne.io/fyne/v2/widget" + "github.com/dweymouth/supersonic/backend/mediaprovider" + "github.com/dweymouth/supersonic/ui/util" + "github.com/dweymouth/supersonic/ui/widgets" +) + +type TrackInfoDialog struct { + widget.BaseWidget + + OnDismiss func() + OnNavigateToArtist func(artistID string) + OnNavigateToAlbum func(albumID string) + OnNavigateToGenre func(genre string) + OnCopyFilePath func() + + track *mediaprovider.Track +} + +func NewTrackInfoDialog(track *mediaprovider.Track) *TrackInfoDialog { + t := &TrackInfoDialog{track: track} + t.ExtendBaseWidget(t) + return t +} + +func (t *TrackInfoDialog) CreateRenderer() fyne.WidgetRenderer { + c := container.New(layout.NewFormLayout()) + + addFormRow(c, "Title", t.track.Title) + + c.Add(newFormText("Artist", true)) + artists := widgets.NewMultiHyperlink() + artists.BuildSegments(t.track.ArtistNames, t.track.ArtistIDs) + artists.OnTapped = func(id string) { + if t.OnNavigateToArtist != nil { + t.OnNavigateToArtist(id) + } + } + c.Add(artists) + + c.Add(newFormText("Album", true)) + album := widget.NewHyperlink(t.track.Album, nil) + album.OnTapped = func() { + if t.OnNavigateToAlbum != nil { + t.OnNavigateToAlbum(t.track.AlbumID) + } + } + c.Add(album) + + if len(t.track.Genres) > 0 { + c.Add(newFormText("Genres", true)) + genres := widgets.NewMultiHyperlink() + genres.BuildSegments(t.track.Genres, t.track.Genres) + genres.OnTapped = func(g string) { + if t.OnNavigateToGenre != nil { + t.OnNavigateToGenre(g) + } + } + c.Add(genres) + } + + addFormRow(c, "Duration", util.SecondsToTimeString(float64(t.track.Duration))) + + copyBtn := widgets.NewIconButton(theme.ContentCopyIcon(), func() { + if t.OnCopyFilePath != nil { + t.OnCopyFilePath() + } + }) + copyBtn.IconSize = widgets.IconButtonSizeSmaller + btnCtr := container.New(layout.NewCustomPaddedLayout(8, 0, 10, 0), + container.NewVBox(copyBtn, layout.NewSpacer())) + c.Add(container.NewHBox(btnCtr, newFormText("File path", true))) + c.Add(newFormText(t.track.FilePath, false)) + + addFormRow(c, "Comment", t.track.Comment) + addFormRow(c, "Year", strconv.Itoa(t.track.Year)) + addFormRow(c, "Track number", strconv.Itoa(t.track.TrackNumber)) + addFormRow(c, "Disc number", strconv.Itoa(t.track.DiscNumber)) + + if t.track.BPM > 0 { + addFormRow(c, "BPM", strconv.Itoa(t.track.BPM)) + } + + addFormRow(c, "Content type", t.track.ContentType) + addFormRow(c, "Bit rate", fmt.Sprintf("%d kbps", t.track.BitRate)) + addFormRow(c, "File size", util.BytesToSizeString(t.track.Size)) + addFormRow(c, "Play count", strconv.Itoa(t.track.PlayCount)) + + if !t.track.LastPlayed.IsZero() { + addFormRow(c, "Last played", t.track.LastPlayed.Format(time.RFC1123)) + } + + if t.track.ReplayGain.TrackPeak > 0 { + addFormRow(c, "Track gain", fmt.Sprintf("%0.2f dB", t.track.ReplayGain.TrackGain)) + addFormRow(c, "Track peak", fmt.Sprintf("%0.6f", t.track.ReplayGain.TrackPeak)) + } + if t.track.ReplayGain.AlbumPeak > 0 { + addFormRow(c, "Album gain", fmt.Sprintf("%0.2f dB", t.track.ReplayGain.AlbumGain)) + addFormRow(c, "Album peak", fmt.Sprintf("%0.6f", t.track.ReplayGain.AlbumPeak)) + } + + title := widget.NewRichTextWithText("Track Info") + title.Segments[0].(*widget.TextSegment).Style.TextStyle.Bold = true + dismissBtn := widget.NewButton("Close", func() { + if t.OnDismiss != nil { + t.OnDismiss() + } + }) + + return widget.NewSimpleRenderer( + container.NewBorder( + /*top*/ container.NewHBox(layout.NewSpacer(), title, layout.NewSpacer()), + /*bottom*/ container.NewVBox( + widget.NewSeparator(), + container.NewHBox(layout.NewSpacer(), dismissBtn), + ), + /*left/right*/ nil, nil, + /*center*/ container.New(layout.NewCustomPaddedLayout(10, 10, 15, 15), + container.NewScroll(c)), + ), + ) +} + +func addFormRow(c *fyne.Container, left, right string) { + if right == "" { + return + } + c.Add(newFormText(left, true)) + c.Add(newFormText(right, false)) +} + +func newFormText(text string, leftCol bool) *widget.RichText { + alignment := fyne.TextAlignLeading + if leftCol { + alignment = fyne.TextAlignTrailing + } + rt := widget.NewRichText( + &widget.TextSegment{ + Text: text, + Style: widget.RichTextStyle{ + TextStyle: fyne.TextStyle{Bold: leftCol}, + Alignment: alignment, + }, + }, + ) + if !leftCol { + rt.Wrapping = fyne.TextWrapWord + } + return rt +} diff --git a/ui/widgets/tracklist.go b/ui/widgets/tracklist.go index 6cd9c55..3c703b6 100644 --- a/ui/widgets/tracklist.go +++ b/ui/widgets/tracklist.go @@ -78,6 +78,7 @@ type Tracklist struct { OnShare func(trackID string) OnPlaySongRadio func(track *mediaprovider.Track) OnReorderTracks func(trackIDs []string, insertPos int) + OnShowTrackInfo func(track *mediaprovider.Track) OnShowArtistPage func(artistID string) OnShowAlbumPage func(albumID string) @@ -101,6 +102,7 @@ type Tracklist struct { ratingSubmenu *fyne.MenuItem shareMenuItem *fyne.MenuItem songRadioMenuItem *fyne.MenuItem + infoMenuItem *fyne.MenuItem container *fyne.Container } @@ -559,6 +561,12 @@ func (t *Tracklist) onShowContextMenu(e *fyne.PointEvent, trackIdx int) { t.onDownload(t.selectedTracks(), "Selected tracks") }) download.Icon = theme.DownloadIcon() + t.infoMenuItem = fyne.NewMenuItem("Show info...", func() { + if t.OnShowTrackInfo != nil { + t.OnShowTrackInfo(t.selectedTracks()[0]) + } + }) + t.infoMenuItem.Icon = theme.InfoIcon() favorite := fyne.NewMenuItem("Set favorite", func() { t.onSetFavorites(t.selectedTracks(), true, true) }) @@ -568,7 +576,7 @@ func (t *Tracklist) onShowContextMenu(e *fyne.PointEvent, trackIdx int) { }) unfavorite.Icon = myTheme.NotFavoriteIcon t.ctxMenu.Items = append(t.ctxMenu.Items, fyne.NewMenuItemSeparator(), - playlist, download) + playlist, download, t.infoMenuItem) t.shareMenuItem = fyne.NewMenuItem("Share...", func() { t.onShare(t.selectedTracks()) }) @@ -587,6 +595,7 @@ func (t *Tracklist) onShowContextMenu(e *fyne.PointEvent, trackIdx int) { } t.ratingSubmenu.Disabled = t.Options.DisableRating t.shareMenuItem.Disabled = t.Options.DisableSharing || len(t.selectedTracks()) != 1 + t.infoMenuItem.Disabled = len(t.selectedTracks()) != 1 widget.ShowPopUpMenuAtPosition(t.ctxMenu, fyne.CurrentApp().Driver().CanvasForObject(t), e.AbsolutePosition) }