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/backend/playbackmanager.go b/backend/playbackmanager.go index 51f64c5..fb121bf 100644 --- a/backend/playbackmanager.go +++ b/backend/playbackmanager.go @@ -40,6 +40,7 @@ type PlaybackManager struct { onSongChange []func(nowPlaying, justScrobbledIfAny *mediaprovider.Track) onPlayTimeUpdate []func(float64, float64) + onLoopModeChange []func(string) } func NewPlaybackManager( @@ -128,6 +129,11 @@ func (p *PlaybackManager) OnPlayTimeUpdate(cb func(float64, float64)) { p.onPlayTimeUpdate = append(p.onPlayTimeUpdate, cb) } +// Registers a callback that is notified whenever the loop mode changes. +func (p *PlaybackManager) OnLoopModeChange(cb func(string)) { + p.onLoopModeChange = append(p.onLoopModeChange, cb) +} + // Loads the specified album into the play queue. func (p *PlaybackManager) LoadAlbum(albumID string, appendToQueue bool, shuffle bool) error { album, err := p.sm.Server.GetAlbum(albumID) @@ -291,6 +297,20 @@ func (p *PlaybackManager) SetReplayGainOptions(config ReplayGainConfig) { }) } +// 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 *PlaybackManager) SetNextLoopMode() error { + if err := p.player.SetNextLoopMode(); err != nil { + return err + } + + for _, cb := range p.onLoopModeChange { + cb(p.player.GetLoopMode().String()) + } + + return nil +} + // call BEFORE updating p.nowPlayingIdx func (p *PlaybackManager) checkScrobble() { if !p.scrobbleCfg.Enabled || len(p.playQueue) == 0 || p.nowPlayingIdx < 0 { diff --git a/player/player.go b/player/player.go index 7b631cd..e9ad599 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 @@ -390,6 +399,50 @@ func (p *Player) PlayPause() error { } } +// Get the loop mode of the player. +func (p *Player) GetLoopMode() LoopMode { + return p.loopMode +} + +// Set 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: + if err := p.mpv.SetOptionString("loop-playlist", "no"); err != nil { + return err + } + case LoopAll: + if err := p.mpv.SetOptionString("loop-playlist", "inf"); err != nil { + return err + } + } + p.loopMode = mode + + return nil +} + +// Change 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 { @@ -590,3 +643,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..36ad8cc 100644 --- a/ui/bottompanel.go +++ b/ui/bottompanel.go @@ -34,9 +34,17 @@ type BottomPanel struct { var _ fyne.Widget = (*BottomPanel)(nil) -func NewBottomPanel(p *player.Player, contr *controller.Controller) *BottomPanel { - bp := &BottomPanel{} +func NewBottomPanel(p *player.Player, pm *backend.PlaybackManager, contr *controller.Controller) *BottomPanel { + bp := &BottomPanel{playbackManager: pm} bp.ExtendBaseWidget(bp) + + bp.playbackManager.OnSongChange(bp.onSongChange) + bp.playbackManager.OnPlayTimeUpdate(func(cur, total float64) { + if !bp.playbackManager.IsSeeking() { + bp.Controls.UpdatePlayTime(cur, total) + } + }) + p.OnPaused(func() { bp.Controls.SetPlaying(false) }) @@ -46,6 +54,9 @@ func NewBottomPanel(p *player.Player, contr *controller.Controller) *BottomPanel p.OnStopped(func() { bp.Controls.SetPlaying(false) }) + pm.OnLoopModeChange(func(mode string) { + bp.AuxControls.SetLoopMode(mode) + }) bp.NowPlaying = widgets.NewNowPlayingCard() bp.NowPlaying.OnShowCoverImage = func() { @@ -92,22 +103,15 @@ func NewBottomPanel(p *player.Player, contr *controller.Controller) *BottomPanel bp.AuxControls.VolumeControl.OnVolumeChanged = func(v int) { _ = p.SetVolume(v) } + bp.AuxControls.OnChangeLoopMode(func() { + bp.playbackManager.SetNextLoopMode() + }) bp.container = container.New(layouts.NewLeftMiddleRightLayout(500), bp.NowPlaying, bp.Controls, bp.AuxControls) return bp } -func (bp *BottomPanel) SetPlaybackManager(pm *backend.PlaybackManager) { - bp.playbackManager = pm - pm.OnSongChange(bp.onSongChange) - pm.OnPlayTimeUpdate(func(cur, total float64) { - if !pm.IsSeeking() { - bp.Controls.UpdatePlayTime(cur, total) - } - }) -} - func (bp *BottomPanel) onSongChange(song, _ *mediaprovider.Track) { if song == nil { bp.NowPlaying.Update("", "", false, "", nil) diff --git a/ui/mainwindow.go b/ui/mainwindow.go index 10832db..1522215 100644 --- a/ui/mainwindow.go +++ b/ui/mainwindow.go @@ -75,8 +75,7 @@ func NewMainWindow(fyneApp fyne.App, appName, appVersion string, app *backend.Ap m.Controller.ReloadFunc = m.BrowsingPane.Reload m.Controller.CurPageFunc = m.BrowsingPane.CurrentPage - m.BottomPanel = NewBottomPanel(app.Player, m.Controller) - m.BottomPanel.SetPlaybackManager(app.PlaybackManager) + m.BottomPanel = NewBottomPanel(app.Player, app.PlaybackManager, m.Controller) m.BottomPanel.ImageManager = app.ImageManager m.container = container.NewBorder(nil, m.BottomPanel, nil, nil, m.BrowsingPane) m.Window.SetContent(m.container) diff --git a/ui/widgets/auxcontrols.go b/ui/widgets/auxcontrols.go index e3d61a2..b110a66 100644 --- a/ui/widgets/auxcontrols.go +++ b/ui/widgets/auxcontrols.go @@ -6,6 +6,7 @@ import ( "fyne.io/fyne/v2/layout" "fyne.io/fyne/v2/theme" "fyne.io/fyne/v2/widget" + "github.com/dweymouth/supersonic/ui/util" ) // The "aux" controls for playback, positioned to the right @@ -14,15 +15,44 @@ type AuxControls struct { widget.BaseWidget VolumeControl *VolumeControl + loop *miniButton container *fyne.Container } +type miniButton struct { + widget.Button +} + +func newMiniButton(icon fyne.Resource) *miniButton { + b := &miniButton{ + Button: widget.Button{ + Icon: icon, + }, + } + b.ExtendBaseWidget(b) + return b +} + +func (b *miniButton) MinSize() fyne.Size { + return fyne.NewSize(24, 24) +} + func NewAuxControls(initialVolume int) *AuxControls { a := &AuxControls{ VolumeControl: NewVolumeControl(initialVolume), + loop: newMiniButton(theme.MediaReplayIcon()), } - a.container = container.NewHBox(layout.NewSpacer(), a.VolumeControl) + a.container = container.NewHBox( + layout.NewSpacer(), + container.NewVBox( + util.NewHSpace(0), // hack to move everything down a tiny bit + layout.NewSpacer(), + a.VolumeControl, + container.NewHBox(layout.NewSpacer(), a.loop, util.NewHSpace(5)), + layout.NewSpacer(), + ), + ) return a } @@ -31,6 +61,19 @@ func (a *AuxControls) CreateRenderer() fyne.WidgetRenderer { return widget.NewSimpleRenderer(a.container) } +func (a *AuxControls) OnChangeLoopMode(f func()) { + a.loop.OnTapped = f +} + +func (a *AuxControls) SetLoopMode(mode string) { + if mode == "all" { + a.loop.Importance = widget.HighImportance + } else { + a.loop.Importance = widget.MediumImportance + } + a.loop.Refresh() +} + type volumeSlider struct { widget.Slider