diff --git a/backend/playbackmanager.go b/backend/playbackmanager.go index 67a5d92..653fdbf 100644 --- a/backend/playbackmanager.go +++ b/backend/playbackmanager.go @@ -116,6 +116,10 @@ func (p *PlaybackManager) NowPlaying() *mediaprovider.Track { return p.playQueue[p.nowPlayingIdx] } +func (p *PlaybackManager) NowPlayingIndex() int { + return int(p.nowPlayingIdx) +} + // Sets a callback that is notified whenever a new song begins playing. func (p *PlaybackManager) OnSongChange(cb func(nowPlaying *mediaprovider.Track, justScrobbledIfAny *mediaprovider.Track)) { p.onSongChange = append(p.onSongChange, cb) diff --git a/player/player.go b/player/player.go index 56f7128..7b631cd 100644 --- a/player/player.go +++ b/player/player.go @@ -70,6 +70,25 @@ type AudioDevice struct { Description string } +// Media information about the currently playing media. +type MediaInfo struct { + // The sample format as string. This uses the same names as used in other places of mpv. + // NOTE: this is the format that the decoder outputs, NOT necessarily the format of the file. + Format string + + // Audio samplerate. + Samplerate int + + // The number of channels. + ChannelCount int + + // The audio codec. + Codec string + + // The average bit rate in bits per second. + Bitrate int +} + // Player encapsulates the mpv instance and provides functions // to control it and to check its status. type Player struct { @@ -413,6 +432,29 @@ func (p *Player) SetAudioDevice(deviceName string) error { return p.mpv.SetPropertyString("audio-device", deviceName) } +func (p *Player) GetMediaInfo() (MediaInfo, error) { + var info MediaInfo + n, err := p.mpv.GetProperty("audio-params", mpv.FORMAT_NODE) + if err != nil { + return info, err + } + nodeMap := n.(*mpv.Node).Data.(map[string]*mpv.Node) + info.Format = nodeMap["format"].Data.(string) + info.Samplerate = int(nodeMap["samplerate"].Data.(int64)) + info.ChannelCount = int(nodeMap["channel-count"].Data.(int64)) + + br, err := p.mpv.GetProperty("audio-bitrate", mpv.FORMAT_INT64) + if err == nil { + info.Bitrate = int(br.(int64)) + } + codec, err := p.mpv.GetProperty("track-list/0/codec", mpv.FORMAT_STRING) + if err == nil { + info.Codec = codec.(string) + } + + return info, nil +} + func (p *Player) getInt64Property(propName string) (int64, error) { playpos, err := p.mpv.GetProperty(propName, mpv.FORMAT_INT64) if err != nil { diff --git a/ui/browsing/browsingpane.go b/ui/browsing/browsingpane.go index 362e4e5..9db99a4 100644 --- a/ui/browsing/browsingpane.go +++ b/ui/browsing/browsingpane.go @@ -39,6 +39,10 @@ type CanShowNowPlaying interface { OnSongChange(song, lastScrobbledIfAny *mediaprovider.Track) } +type CanShowPlayTime interface { + OnPlayTimeUpdate(curTime, totalTime float64) +} + type BrowsingPane struct { widget.BaseWidget @@ -66,6 +70,7 @@ func NewBrowsingPane(app *backend.App) *BrowsingPane { b.forward = widget.NewButtonWithIcon("", theme.NavigateNextIcon(), b.GoForward) b.reload = widget.NewButtonWithIcon("", theme.ViewRefreshIcon(), b.Reload) b.app.PlaybackManager.OnSongChange(b.onSongChange) + b.app.PlaybackManager.OnPlayTimeUpdate(b.onPlayTimeUpdate) bkgrnd := myTheme.NewThemedRectangle(myTheme.ColorNamePageBackground) b.pageContainer = container.NewMax(bkgrnd, layout.NewSpacer()) b.settingsBtn = widget.NewButtonWithIcon("", theme.SettingsIcon(), func() { @@ -175,6 +180,15 @@ func (b *BrowsingPane) onSongChange(song, lastScrobbledIfAny *mediaprovider.Trac } } +func (b *BrowsingPane) onPlayTimeUpdate(cur, total float64) { + if b.curPage == nil { + return + } + if p, ok := b.curPage.(CanShowPlayTime); ok { + p.OnPlayTimeUpdate(cur, total) + } +} + func (b *BrowsingPane) addPageToHistory(p Page, truncate bool) { if truncate { // allow garbage collection of pages that will be removed from the history diff --git a/ui/browsing/nowplayingpage.go b/ui/browsing/nowplayingpage.go index 6ddb5e2..3700020 100644 --- a/ui/browsing/nowplayingpage.go +++ b/ui/browsing/nowplayingpage.go @@ -1,15 +1,23 @@ package browsing import ( + "fmt" + "log" + "strings" + "github.com/dweymouth/supersonic/backend" "github.com/dweymouth/supersonic/backend/mediaprovider" + "github.com/dweymouth/supersonic/player" "github.com/dweymouth/supersonic/sharedutil" "github.com/dweymouth/supersonic/ui/controller" "github.com/dweymouth/supersonic/ui/layouts" + myTheme "github.com/dweymouth/supersonic/ui/theme" + "github.com/dweymouth/supersonic/ui/util" "github.com/dweymouth/supersonic/ui/widgets" "fyne.io/fyne/v2" "fyne.io/fyne/v2/container" + "fyne.io/fyne/v2/theme" "fyne.io/fyne/v2/widget" ) @@ -18,8 +26,12 @@ type NowPlayingPage struct { nowPlayingPageState + queue []*mediaprovider.Track + totalTime float64 + title *widget.RichText tracklist *widgets.Tracklist + statusLabel *widget.RichText nowPlayingID string container *fyne.Container } @@ -28,6 +40,7 @@ type nowPlayingPageState struct { contr *controller.Controller conf *backend.NowPlayingPageConfig pm *backend.PlaybackManager + p *player.Player } func NewNowPlayingPage( @@ -35,9 +48,15 @@ func NewNowPlayingPage( contr *controller.Controller, conf *backend.NowPlayingPageConfig, pm *backend.PlaybackManager, + p *player.Player, // TODO: once other player backends are supported (eg uPnP), refactor ) *NowPlayingPage { - a := &NowPlayingPage{nowPlayingPageState: nowPlayingPageState{contr: contr, conf: conf, pm: pm}} + a := &NowPlayingPage{nowPlayingPageState: nowPlayingPageState{contr: contr, conf: conf, pm: pm, p: p}} a.ExtendBaseWidget(a) + + p.OnPaused(a.formatStatusLine) + p.OnPlaying(a.formatStatusLine) + p.OnStopped(a.formatStatusLine) + a.tracklist = widgets.NewTracklist(nil) a.tracklist.SetVisibleColumns(conf.TracklistColumns) a.tracklist.OnVisibleColumnsChanged = func(cols []string) { @@ -53,8 +72,13 @@ func NewNowPlayingPage( } a.title = widget.NewRichTextWithText("Now Playing") a.title.Segments[0].(*widget.TextSegment).Style.SizeName = widget.RichTextStyleHeading.SizeName + a.statusLabel = widget.NewRichTextWithText("Stopped") + statusLabelCtr := container.New(&layouts.VboxCustomPadding{ExtraPad: -5}, + myTheme.NewThemedRectangle(theme.ColorNameInputBorder), + a.statusLabel, + ) a.container = container.New(&layouts.MaxPadLayout{PadLeft: 15, PadRight: 15, PadTop: 5, PadBottom: 15}, - container.NewBorder(a.title, nil, nil, nil, a.tracklist)) + container.NewBorder(a.title, statusLabelCtr, nil, nil, a.tracklist)) a.load(highlightedTrackID) return a } @@ -80,6 +104,8 @@ func (a *NowPlayingPage) SelectAll() { a.tracklist.SelectAll() } +var _ CanShowNowPlaying = (*NowPlayingPage)(nil) + func (a *NowPlayingPage) OnSongChange(song, lastScrobbledIfAny *mediaprovider.Track) { if song == nil { a.nowPlayingID = "" @@ -90,6 +116,62 @@ func (a *NowPlayingPage) OnSongChange(song, lastScrobbledIfAny *mediaprovider.Tr a.tracklist.IncrementPlayCount(sharedutil.TrackIDOrEmptyStr(lastScrobbledIfAny)) } +var _ CanShowPlayTime = (*NowPlayingPage)(nil) + +func (a *NowPlayingPage) OnPlayTimeUpdate(_, _ float64) { + a.formatStatusLine() +} + +func (a *NowPlayingPage) formatStatusLine() { + playerStats := a.p.GetStatus() + ts := a.statusLabel.Segments[0].(*widget.TextSegment) + state := "Stopped" + switch playerStats.State { + case player.Paused: + state = "Paused" + case player.Playing: + state = "Playing" + } + + dur := 0.0 + if np := a.pm.NowPlaying(); np != nil { + dur = float64(np.Duration) + } + statusSuffix := "" + trackNum := 0 + if state != "Stopped" { + trackNum = a.pm.NowPlayingIndex() + 1 + statusSuffix = fmt.Sprintf(" %s/%s", + util.SecondsToTimeString(playerStats.TimePos), + util.SecondsToTimeString(dur)) + } + status := fmt.Sprintf("%s (%d/%d)%s", state, trackNum, + len(a.queue), statusSuffix) + + if state == "Stopped" { + ts.Text = fmt.Sprintf("%s | Total time: %s", status, util.SecondsToTimeString(a.totalTime)) + } else { + audioInfo, err := a.p.GetMediaInfo() + if err != nil { + log.Printf("error getting playback status: %s", err.Error()) + } + codec := audioInfo.Codec + if len(codec) <= 4 && !strings.EqualFold(codec, "opus") { + codec = strings.ToUpper(codec) // FLAC, MP3, AAC, etc + } + + // Note: bit depth intentionally omitted since MPV reports the decoded bit depth + // i.e. 24 bit files get reported as 32 bit. Also b/c bit depth isn't meaningful for lossy. + ts.Text = fmt.Sprintf("%s ยท %s %g kHz, %d kbps | Total time: %s", + status, + codec, + float64(audioInfo.Samplerate)/1000, + audioInfo.Bitrate/1000, + util.SecondsToTimeString(a.totalTime)) + } + a.statusLabel.Refresh() +} + func (a *NowPlayingPage) Reload() { a.load("") } @@ -106,14 +188,19 @@ func (a *NowPlayingPage) onRemoveSelectedFromQueue() { // does not make calls to server - can safely be run in UI callbacks func (a *NowPlayingPage) load(highlightedTrackID string) { - queue := a.pm.GetPlayQueue() - a.tracklist.SetTracks(queue) + a.queue = a.pm.GetPlayQueue() + a.tracklist.SetTracks(a.queue) a.tracklist.SetNowPlaying(a.nowPlayingID) if highlightedTrackID != "" { a.tracklist.SelectAndScrollToTrack(highlightedTrackID) } + a.totalTime = 0.0 + for _, tr := range a.queue { + a.totalTime += float64(tr.Duration) + } + a.formatStatusLine() } func (s *nowPlayingPageState) Restore() Page { - return NewNowPlayingPage("", s.contr, s.conf, s.pm) + return NewNowPlayingPage("", s.contr, s.conf, s.pm, s.p) } diff --git a/ui/browsing/router.go b/ui/browsing/router.go index e0f710b..c91cf6a 100644 --- a/ui/browsing/router.go +++ b/ui/browsing/router.go @@ -42,7 +42,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(rte.Arg, r.Controller, &r.App.Config.NowPlayingPage, r.App.PlaybackManager) + return NewNowPlayingPage(rte.Arg, r.Controller, &r.App.Config.NowPlayingPage, r.App.PlaybackManager, r.App.Player) case controller.Playlist: return NewPlaylistPage(rte.Arg, &r.App.Config.PlaylistPage, r.Controller, r.App.ServerManager, r.App.PlaybackManager, r.App.ImageManager) case controller.Playlists: