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.
This commit is contained in:
@@ -1,7 +1,7 @@
|
|||||||
<img src="res/appicon-128.png" alt="Supersonic logo" title="Supersonic" align="left" height="60px"/>
|
<img src="res/appicon-128.png" alt="Supersonic logo" title="Supersonic" align="left" height="60px"/>
|
||||||
<a href='https://flathub.org/apps/details/io.github.dweymouth.supersonic'><img height="50px" align="right" alt='Download on Flathub' src='https://flathub.org/assets/badges/flathub-badge-en.png'/></a></div>
|
<a href='https://flathub.org/apps/details/io.github.dweymouth.supersonic'><img height="50px" align="right" alt='Download on Flathub' src='https://flathub.org/assets/badges/flathub-badge-en.png'/></a></div>
|
||||||
<a href='https://ko-fi.com/dweymouth' target='_blank'><img height='40' align='right' src='https://az743702.vo.msecnd.net/cdn/kofi3.png?v=0' border='0' alt='Buy Me a Coffee at ko-fi.com'/>
|
<a href='https://ko-fi.com/dweymouth' target='_blank'><img height='40' align='right' src='https://az743702.vo.msecnd.net/cdn/kofi3.png?v=0' border='0' alt='Buy Me a Coffee at ko-fi.com'/>
|
||||||
|
|
||||||
# Supersonic
|
# Supersonic
|
||||||
|
|
||||||
[](https://github.com/dweymouth/supersonic/blob/main/LICENSE)
|
[](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/unset favorite and browse by favorite albums, artists, and songs
|
||||||
* [x] Set and view track rating (0-5 stars)
|
* [x] Set and view track rating (0-5 stars)
|
||||||
* [x] View and edit play queue (add and remove tracks; reorder support coming soon)
|
* [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)
|
* [ ] Browse by folders (planned)
|
||||||
* [ ] Download songs, albums or playlists (planned)
|
* [ ] Download songs, albums or playlists (planned)
|
||||||
* [ ] Cast to uPnP/DLNA devices (likely 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
|
### Build with an AUR helper
|
||||||
* Invoke your favorite AUR helper to automatically build the package
|
* Invoke your favorite AUR helper to automatically build the package
|
||||||
- ``yay -S supersonic-desktop``
|
- ``yay -S supersonic-desktop``
|
||||||
|
|
||||||
## Build instructions (Mac OS)
|
## Build instructions (Mac OS)
|
||||||
|
|
||||||
|
|||||||
+71
-5
@@ -58,6 +58,14 @@ type ReplayGainOptions struct {
|
|||||||
// Fallback gain intentionally omitted
|
// Fallback gain intentionally omitted
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The playback loop mode (LoopNone, LoopAll).
|
||||||
|
type LoopMode int
|
||||||
|
|
||||||
|
const (
|
||||||
|
LoopNone LoopMode = iota
|
||||||
|
LoopAll
|
||||||
|
)
|
||||||
|
|
||||||
// Information about a specific audio device.
|
// Information about a specific audio device.
|
||||||
// Returned by ListAudioDevices.
|
// Returned by ListAudioDevices.
|
||||||
type AudioDevice struct {
|
type AudioDevice struct {
|
||||||
@@ -99,6 +107,7 @@ type Player struct {
|
|||||||
haveRGainOpts bool
|
haveRGainOpts bool
|
||||||
audioExclusive bool
|
audioExclusive bool
|
||||||
status Status
|
status Status
|
||||||
|
loopMode LoopMode
|
||||||
seeking bool
|
seeking bool
|
||||||
curPlaylistPos int64
|
curPlaylistPos int64
|
||||||
prePausedState State
|
prePausedState State
|
||||||
@@ -107,11 +116,12 @@ type Player struct {
|
|||||||
bgCancel context.CancelFunc
|
bgCancel context.CancelFunc
|
||||||
|
|
||||||
// callbacks
|
// callbacks
|
||||||
onPaused []func()
|
onPaused []func()
|
||||||
onStopped []func()
|
onStopped []func()
|
||||||
onPlaying []func()
|
onPlaying []func()
|
||||||
onSeek []func()
|
onSeek []func()
|
||||||
onTrackChange []func(int64)
|
onLoopModeChanged []func(string)
|
||||||
|
onTrackChange []func(int64)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Returns a new player.
|
// 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.
|
// Get the current status of the player.
|
||||||
func (p *Player) GetStatus() Status {
|
func (p *Player) GetStatus() Status {
|
||||||
if !p.initialized {
|
if !p.initialized {
|
||||||
@@ -491,6 +542,11 @@ func (p *Player) OnSeek(cb func()) {
|
|||||||
p.onSeek = append(p.onSeek, cb)
|
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,
|
// Registers a callback which is invoked when the currently playing track changes,
|
||||||
// or when playback begins at any time from the Stopped state.
|
// or when playback begins at any time from the Stopped state.
|
||||||
// Callback is invoked with the index of the currently playing track (zero-based).
|
// 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"
|
return "UNKNOWN_SEEK_MODE"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (l LoopMode) String() string {
|
||||||
|
switch l {
|
||||||
|
case LoopNone:
|
||||||
|
return "no"
|
||||||
|
case LoopAll:
|
||||||
|
return "all"
|
||||||
|
}
|
||||||
|
return "UNKNOWN_LOOP_MODE"
|
||||||
|
}
|
||||||
|
|||||||
@@ -46,6 +46,9 @@ func NewBottomPanel(p *player.Player, contr *controller.Controller) *BottomPanel
|
|||||||
p.OnStopped(func() {
|
p.OnStopped(func() {
|
||||||
bp.Controls.SetPlaying(false)
|
bp.Controls.SetPlaying(false)
|
||||||
})
|
})
|
||||||
|
p.OnLoopModeChanged(func(mode string) {
|
||||||
|
bp.Controls.SetLoopMode(mode)
|
||||||
|
})
|
||||||
|
|
||||||
bp.NowPlaying = widgets.NewNowPlayingCard()
|
bp.NowPlaying = widgets.NewNowPlayingCard()
|
||||||
bp.NowPlaying.OnShowCoverImage = func() {
|
bp.NowPlaying.OnShowCoverImage = func() {
|
||||||
@@ -87,6 +90,9 @@ func NewBottomPanel(p *player.Player, contr *controller.Controller) *BottomPanel
|
|||||||
bp.Controls.OnSeek(func(f float64) {
|
bp.Controls.OnSeek(func(f float64) {
|
||||||
p.Seek(fmt.Sprintf("%d", int(f*100)), player.SeekAbsolutePercent)
|
p.Seek(fmt.Sprintf("%d", int(f*100)), player.SeekAbsolutePercent)
|
||||||
})
|
})
|
||||||
|
bp.Controls.OnChangeLoopMode(func() {
|
||||||
|
p.SetNextLoopMode()
|
||||||
|
})
|
||||||
|
|
||||||
bp.AuxControls = widgets.NewAuxControls(p.GetVolume())
|
bp.AuxControls = widgets.NewAuxControls(p.GetVolume())
|
||||||
bp.AuxControls.VolumeControl.OnVolumeChanged = func(v int) {
|
bp.AuxControls.VolumeControl.OnVolumeChanged = func(v int) {
|
||||||
|
|||||||
@@ -10,6 +10,10 @@ import (
|
|||||||
"fyne.io/fyne/v2/widget"
|
"fyne.io/fyne/v2/widget"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
themedResReplay = theme.NewThemedResource(theme.MediaReplayIcon())
|
||||||
|
)
|
||||||
|
|
||||||
// TrackPosSlider is a custom slider that doesn't trigger
|
// TrackPosSlider is a custom slider that doesn't trigger
|
||||||
// the seek action until drag end.
|
// the seek action until drag end.
|
||||||
type TrackPosSlider struct {
|
type TrackPosSlider struct {
|
||||||
@@ -62,6 +66,7 @@ type PlayerControls struct {
|
|||||||
prev *widget.Button
|
prev *widget.Button
|
||||||
playpause *widget.Button
|
playpause *widget.Button
|
||||||
next *widget.Button
|
next *widget.Button
|
||||||
|
loop *widget.Button
|
||||||
container *fyne.Container
|
container *fyne.Container
|
||||||
|
|
||||||
totalTime float64
|
totalTime float64
|
||||||
@@ -105,8 +110,9 @@ func NewPlayerControls() *PlayerControls {
|
|||||||
pc.prev = widget.NewButtonWithIcon("", theme.MediaSkipPreviousIcon(), func() {})
|
pc.prev = widget.NewButtonWithIcon("", theme.MediaSkipPreviousIcon(), func() {})
|
||||||
pc.next = widget.NewButtonWithIcon("", theme.MediaSkipNextIcon(), func() {})
|
pc.next = widget.NewButtonWithIcon("", theme.MediaSkipNextIcon(), func() {})
|
||||||
pc.playpause = widget.NewButtonWithIcon("", theme.MediaPlayIcon(), 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)
|
b := container.New(layout.NewCenterLayout(), buttons)
|
||||||
|
|
||||||
c := container.NewBorder(nil, nil, pc.curTimeLabel, pc.totalTimeLabel, pc.slider)
|
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) {
|
func (pc *PlayerControls) UpdatePlayTime(curTime, totalTime float64) {
|
||||||
pc.totalTime = totalTime
|
pc.totalTime = totalTime
|
||||||
v := 0.0
|
v := 0.0
|
||||||
|
|||||||
Reference in New Issue
Block a user