From 2ab19f65e17011628a7602608f7d4c0397f90394 Mon Sep 17 00:00:00 2001 From: Michael Manganiello Date: Fri, 16 Jun 2023 15:26:25 -0300 Subject: [PATCH] Add Loop functionality to player controls Adding the Loop functionality, along with the Player changes to support it, and a button to the player controls. `Player` now has a `loopMode` attribute, which determines whether it should loop. At the moment, only the following modes are supported: * `LoopNone`: Disables loop. * `LoopAll`: Enables loop for the entire playlist queue. This has been designed as an enum, to support other modes in the future (e.g. loop for the current track only). It depends on the `loop` functionality provided by MPV (using the [`loop-playlist` option](https://mpv.io/manual/master/#options-loop-playlist)), so no custom logic is needed to reset the currently playing index. --- README.md | 8 ++-- player/player.go | 76 +++++++++++++++++++++++++++++++++--- ui/bottompanel.go | 6 +++ ui/widgets/playercontrols.go | 22 ++++++++++- 4 files changed, 102 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index b0b4d8e..6b461fb 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@ Supersonic logo Download on Flathub -Buy Me a Coffee at ko-fi.com - +Buy Me a Coffee at ko-fi.com + # Supersonic [![License](https://img.shields.io/github/license/dweymouth/supersonic)](https://github.com/dweymouth/supersonic/blob/main/LICENSE) @@ -40,7 +40,7 @@ Screenshots of Supersonic running against the Navidrome [demo server](https://ww * [x] Set/unset favorite and browse by favorite albums, artists, and songs * [x] Set and view track rating (0-5 stars) * [x] View and edit play queue (add and remove tracks; reorder support coming soon) -* [x] Shuffle and repeat playback modes (partial; shuffle album, playlist, artist radio, random songs) +* [x] Shuffle and repeat playback modes (partial; shuffle album, playlist, artist radio, random songs; repeat all) * [ ] Browse by folders (planned) * [ ] Download songs, albums or playlists (planned) * [ ] Cast to uPnP/DLNA devices (likely planned) @@ -86,7 +86,7 @@ Supersonic is available in the AUR and can be built either manually with `makepk ### Build with an AUR helper * Invoke your favorite AUR helper to automatically build the package - - ``yay -S supersonic-desktop`` + - ``yay -S supersonic-desktop`` ## Build instructions (Mac OS) diff --git a/player/player.go b/player/player.go index 7b631cd..6d034ec 100644 --- a/player/player.go +++ b/player/player.go @@ -58,6 +58,14 @@ type ReplayGainOptions struct { // Fallback gain intentionally omitted } +// The playback loop mode (LoopNone, LoopAll). +type LoopMode int + +const ( + LoopNone LoopMode = iota + LoopAll +) + // Information about a specific audio device. // Returned by ListAudioDevices. type AudioDevice struct { @@ -99,6 +107,7 @@ type Player struct { haveRGainOpts bool audioExclusive bool status Status + loopMode LoopMode seeking bool curPlaylistPos int64 prePausedState State @@ -107,11 +116,12 @@ type Player struct { bgCancel context.CancelFunc // callbacks - onPaused []func() - onStopped []func() - onPlaying []func() - onSeek []func() - onTrackChange []func(int64) + onPaused []func() + onStopped []func() + onPlaying []func() + onSeek []func() + onLoopModeChanged []func(string) + onTrackChange []func(int64) } // Returns a new player. @@ -390,6 +400,47 @@ func (p *Player) PlayPause() error { } } +// Sets the loop mode of the player. +func (p *Player) SetLoopMode(mode LoopMode) error { + if !p.initialized { + return ErrUnitialized + } + + // Return early if player is already in specified mode + if mode == p.loopMode { + return nil + } + + switch mode { + case LoopNone: + p.mpv.SetOptionString("loop-playlist", "no") + case LoopAll: + p.mpv.SetOptionString("loop-playlist", "inf") + } + p.loopMode = mode + + defer func() { + for _, cb := range p.onLoopModeChanged { + cb(p.loopMode.String()) + } + }() + + return nil +} + +// Changes the loop mode of the player to the next one. +// Useful for toggling UI elements, to change modes without knowing the current player mode. +func (p *Player) SetNextLoopMode() error { + switch p.loopMode { + case LoopNone: + return p.SetLoopMode(LoopAll) + case LoopAll: + return p.SetLoopMode(LoopNone) + default: + return nil + } +} + // Get the current status of the player. func (p *Player) GetStatus() Status { if !p.initialized { @@ -491,6 +542,11 @@ func (p *Player) OnSeek(cb func()) { p.onSeek = append(p.onSeek, cb) } +// Registers a callback which is invoked when the player enables queue repeat. +func (p *Player) OnLoopModeChanged(cb func(string)) { + p.onLoopModeChanged = append(p.onLoopModeChanged, cb) +} + // Registers a callback which is invoked when the currently playing track changes, // or when playback begins at any time from the Stopped state. // Callback is invoked with the index of the currently playing track (zero-based). @@ -590,3 +646,13 @@ func (s SeekMode) String() string { } return "UNKNOWN_SEEK_MODE" } + +func (l LoopMode) String() string { + switch l { + case LoopNone: + return "no" + case LoopAll: + return "all" + } + return "UNKNOWN_LOOP_MODE" +} diff --git a/ui/bottompanel.go b/ui/bottompanel.go index 429b969..8439c31 100644 --- a/ui/bottompanel.go +++ b/ui/bottompanel.go @@ -46,6 +46,9 @@ func NewBottomPanel(p *player.Player, contr *controller.Controller) *BottomPanel p.OnStopped(func() { bp.Controls.SetPlaying(false) }) + p.OnLoopModeChanged(func(mode string) { + bp.Controls.SetLoopMode(mode) + }) bp.NowPlaying = widgets.NewNowPlayingCard() bp.NowPlaying.OnShowCoverImage = func() { @@ -87,6 +90,9 @@ func NewBottomPanel(p *player.Player, contr *controller.Controller) *BottomPanel bp.Controls.OnSeek(func(f float64) { p.Seek(fmt.Sprintf("%d", int(f*100)), player.SeekAbsolutePercent) }) + bp.Controls.OnChangeLoopMode(func() { + p.SetNextLoopMode() + }) bp.AuxControls = widgets.NewAuxControls(p.GetVolume()) bp.AuxControls.VolumeControl.OnVolumeChanged = func(v int) { diff --git a/ui/widgets/playercontrols.go b/ui/widgets/playercontrols.go index c81ef49..6685dca 100644 --- a/ui/widgets/playercontrols.go +++ b/ui/widgets/playercontrols.go @@ -10,6 +10,10 @@ import ( "fyne.io/fyne/v2/widget" ) +var ( + themedResReplay = theme.NewThemedResource(theme.MediaReplayIcon()) +) + // TrackPosSlider is a custom slider that doesn't trigger // the seek action until drag end. type TrackPosSlider struct { @@ -62,6 +66,7 @@ type PlayerControls struct { prev *widget.Button playpause *widget.Button next *widget.Button + loop *widget.Button container *fyne.Container totalTime float64 @@ -105,8 +110,9 @@ func NewPlayerControls() *PlayerControls { pc.prev = widget.NewButtonWithIcon("", theme.MediaSkipPreviousIcon(), func() {}) pc.next = widget.NewButtonWithIcon("", theme.MediaSkipNextIcon(), func() {}) pc.playpause = widget.NewButtonWithIcon("", theme.MediaPlayIcon(), func() {}) + pc.loop = widget.NewButtonWithIcon("", themedResReplay, func() {}) - buttons := container.NewHBox(pc.prev, pc.playpause, pc.next) + buttons := container.NewHBox(pc.prev, pc.playpause, pc.next, pc.loop) b := container.New(layout.NewCenterLayout(), buttons) c := container.NewBorder(nil, nil, pc.curTimeLabel, pc.totalTimeLabel, pc.slider) @@ -139,6 +145,20 @@ func (pc *PlayerControls) SetPlaying(playing bool) { } } +func (pc *PlayerControls) OnChangeLoopMode(f func()) { + pc.loop.OnTapped = f +} + +func (pc *PlayerControls) SetLoopMode(mode string) { + if mode == "all" { + themedResReplay.ColorName = theme.ColorNameSuccess + pc.loop.SetIcon(themedResReplay) + } else { + themedResReplay.ColorName = "" + pc.loop.SetIcon(themedResReplay) + } +} + func (pc *PlayerControls) UpdatePlayTime(curTime, totalTime float64) { pc.totalTime = totalTime v := 0.0