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