diff --git a/backend/app.go b/backend/app.go
index 3946114..06f1fc9 100644
--- a/backend/app.go
+++ b/backend/app.go
@@ -361,6 +361,7 @@ func (a *App) Shutdown() {
repeatMode = "All"
}
a.Config.Playback.RepeatMode = repeatMode
+ a.Config.Playback.Autoplay = a.PlaybackManager.IsAutoplay()
a.Config.LocalPlayback.Volume = a.LocalPlayer.GetVolume()
a.SavePlayQueueIfEnabled()
a.SaveConfigFile()
diff --git a/backend/config.go b/backend/config.go
index 41626fc..6b88f42 100644
--- a/backend/config.go
+++ b/backend/config.go
@@ -99,6 +99,7 @@ type NowPlayingPageConfig struct {
}
type PlaybackConfig struct {
+ Autoplay bool
RepeatMode string
}
@@ -215,6 +216,7 @@ func DefaultConfig(appVersionTag string) *Config {
TracklistColumns: []string{"Album", "Time", "Plays"},
},
Playback: PlaybackConfig{
+ Autoplay: false,
RepeatMode: "None",
},
LocalPlayback: LocalPlaybackConfig{
diff --git a/backend/playbackmanager.go b/backend/playbackmanager.go
index 15d537b..6d98504 100644
--- a/backend/playbackmanager.go
+++ b/backend/playbackmanager.go
@@ -5,11 +5,14 @@ import (
"fmt"
"log"
"math/rand"
+ "runtime"
+ "slices"
"time"
"github.com/dweymouth/supersonic/backend/mediaprovider"
"github.com/dweymouth/supersonic/backend/player"
"github.com/dweymouth/supersonic/backend/player/mpv"
+ "github.com/dweymouth/supersonic/sharedutil"
)
// A high-level MediaProvider-aware playback engine, serves as an
@@ -19,6 +22,8 @@ type PlaybackManager struct {
cmdQueue *playbackCommandQueue
cfg *AppConfig
+ autoplay bool
+
lastPlayTime float64
}
@@ -37,13 +42,14 @@ func NewPlaybackManager(
engine: e,
cmdQueue: q,
cfg: appCfg,
+ autoplay: playbackCfg.Autoplay,
}
- pm.workaroundWindowsPlaybackIssue()
+ pm.addOnTrackChangeHook()
go pm.runCmdQueue(ctx)
return pm
}
-func (p *PlaybackManager) workaroundWindowsPlaybackIssue() {
+func (p *PlaybackManager) addOnTrackChangeHook() {
// See https://github.com/dweymouth/supersonic/issues/483
// On Windows, MPV sometimes fails to start playback when switching to a track
// with a different sample rate than the previous. If this is detected,
@@ -51,7 +57,17 @@ func (p *PlaybackManager) workaroundWindowsPlaybackIssue() {
p.OnPlayTimeUpdate(func(curTime, _ float64, _ bool) {
p.lastPlayTime = curTime
})
+
p.OnSongChange(func(mediaprovider.MediaItem, *mediaprovider.Track) {
+ // Autoplay if enabled and we are on the last track
+ if p.autoplay && p.NowPlayingIndex() == len(p.engine.playQueue)-1 {
+ p.enqueueAutoplayTracks()
+ }
+
+ if runtime.GOOS != "windows" {
+ return
+ }
+ // workaround for https://github.com/dweymouth/supersonic/issues/483 (see above comment)
if p.NowPlayingIndex() != len(p.engine.playQueue) && p.PlayerStatus().State == player.Playing {
p.lastPlayTime = 0
go func() {
@@ -348,6 +364,10 @@ func (p *PlaybackManager) GetLoopMode() LoopMode {
return p.engine.loopMode
}
+func (p *PlaybackManager) IsAutoplay() bool {
+ return p.autoplay
+}
+
func (p *PlaybackManager) PlayerStatus() player.Status {
return p.engine.PlayerStatus()
}
@@ -356,6 +376,13 @@ func (p *PlaybackManager) SetVolume(vol int) {
p.cmdQueue.SetVolume(vol)
}
+func (p *PlaybackManager) SetAutoplay(autoplay bool) {
+ p.autoplay = autoplay
+ if autoplay && p.NowPlayingIndex() == len(p.engine.playQueue)-1 {
+ p.enqueueAutoplayTracks()
+ }
+}
+
func (p *PlaybackManager) Volume() int {
return p.engine.CurrentPlayer().GetVolume()
}
@@ -419,6 +446,84 @@ func (p *PlaybackManager) PlayPause() {
}
}
+func (p *PlaybackManager) enqueueAutoplayTracks() {
+ nowPlaying := p.NowPlaying()
+ if nowPlaying == nil {
+ return
+ }
+
+ s := p.engine.sm.Server
+ if s == nil {
+ return
+ }
+
+ // last 500 played items
+ queue := p.GetPlayQueue()
+ if l := len(queue); l > 500 {
+ queue = queue[l-500:]
+ }
+
+ // tracks we will enqueue
+ var tracks []*mediaprovider.Track
+
+ filterRecentlyPlayed := func(tracks []*mediaprovider.Track) []*mediaprovider.Track {
+ return sharedutil.FilterSlice(tracks, func(t *mediaprovider.Track) bool {
+ return !slices.ContainsFunc(queue, func(i mediaprovider.MediaItem) bool {
+ return i.Metadata().Type == mediaprovider.MediaItemTypeTrack && i.Metadata().ID == t.ID
+ })
+ })
+ }
+
+ // since this func is invoked in a callback from the playback engine,
+ // need to do the rest async as it may take time and block other callbacks
+ go func() {
+ // first 2 strategies - similar by artist, and similar by genres - only work for tracks
+ if nowPlaying.Metadata().Type == mediaprovider.MediaItemTypeTrack {
+ tr := nowPlaying.(*mediaprovider.Track)
+
+ // similar tracks by artist
+ if len(tr.ArtistIDs) > 0 {
+ similar, err := s.GetSimilarTracks(tr.ArtistIDs[0], p.cfg.EnqueueBatchSize)
+ if err != nil {
+ log.Println("autoplay error: failed to get similar tracks: %v", err)
+ }
+ tracks = filterRecentlyPlayed(similar)
+ }
+
+ // fallback to random tracks from genre
+ if len(tracks) == 0 {
+ for _, g := range tr.Genres {
+ if g == "" {
+ continue
+ }
+ byGenre, err := s.GetRandomTracks(g, p.cfg.EnqueueBatchSize)
+ if err != nil {
+ log.Println("autoplay error: failed to get tracks by genre: %v", err)
+ }
+ tracks = filterRecentlyPlayed(byGenre)
+ if len(tracks) > 0 {
+ break
+ }
+ }
+ }
+ }
+
+ // random tracks works regardless of the type of the last playing media
+ if len(tracks) == 0 {
+ // fallback to random tracks
+ random, err := s.GetRandomTracks("", p.cfg.EnqueueBatchSize)
+ if err != nil {
+ log.Println("autoplay error: failed to get random tracks: %v", err)
+ }
+ tracks = filterRecentlyPlayed(random)
+ }
+
+ if len(tracks) > 0 {
+ p.LoadTracks(tracks, Append, false /*no need to shuffle, already random*/)
+ }
+ }()
+}
+
func (p *PlaybackManager) runCmdQueue(ctx context.Context) {
logIfErr := func(action string, err error) {
if err != nil {
diff --git a/res/bundled.go b/res/bundled.go
index b0806e4..dfb3f95 100644
--- a/res/bundled.go
+++ b/res/bundled.go
@@ -40,6 +40,11 @@ var ResHeartOutlineSvg = &fyne.StaticResource{
StaticContent: []byte(
""),
}
+var ResInfinitySvg = &fyne.StaticResource{
+ StaticName: "infinity.svg",
+ StaticContent: []byte(
+ "\n"),
+}
var ResMusicnotesSvg = &fyne.StaticResource{
StaticName: "musicnotes.svg",
StaticContent: []byte(
diff --git a/res/bundled_gen.sh b/res/bundled_gen.sh
index aa21d2e..a963c50 100755
--- a/res/bundled_gen.sh
+++ b/res/bundled_gen.sh
@@ -7,6 +7,7 @@ fyne bundle -append -prefix Res icons/publicdomain/disc.svg >> bundled.go
fyne bundle -append -prefix Res icons/publicdomain/headphones.svg >> bundled.go
fyne bundle -append -prefix Res icons/publicdomain/heart-filled.svg >> bundled.go
fyne bundle -append -prefix Res icons/publicdomain/heart-outline.svg >> bundled.go
+fyne bundle -append -prefix Res icons/publicdomain/infinity.svg >> bundled.go
fyne bundle -append -prefix Res icons/publicdomain/musicnotes.svg >> bundled.go
fyne bundle -append -prefix Res icons/publicdomain/people.svg >> bundled.go
fyne bundle -append -prefix Res icons/publicdomain/playlist.svg >> bundled.go
diff --git a/res/icons/publicdomain/infinity.svg b/res/icons/publicdomain/infinity.svg
new file mode 100644
index 0000000..cb71411
--- /dev/null
+++ b/res/icons/publicdomain/infinity.svg
@@ -0,0 +1,4 @@
+
+
\ No newline at end of file
diff --git a/res/translations/en.json b/res/translations/en.json
index bf7b438..f5f8a85 100644
--- a/res/translations/en.json
+++ b/res/translations/en.json
@@ -28,6 +28,7 @@
"Audiobook": "Audiobook",
"Authentication failed": "Authentication failed",
"Auto": "Auto",
+ "Autoplay": "Autoplay",
"Autoselect device": "Autoselect device",
"Back": "Back",
"Bit rate": "Bit rate",
diff --git a/ui/bottompanel.go b/ui/bottompanel.go
index 5851ffc..0925571 100644
--- a/ui/bottompanel.go
+++ b/ui/bottompanel.go
@@ -102,7 +102,7 @@ func NewBottomPanel(pm *backend.PlaybackManager, im *backend.ImageManager, contr
pm.SeekFraction(f)
})
- bp.AuxControls = widgets.NewAuxControls(pm.Volume(), pm.GetLoopMode())
+ bp.AuxControls = widgets.NewAuxControls(pm.Volume(), pm.GetLoopMode(), pm.IsAutoplay())
pm.OnLoopModeChange(bp.AuxControls.SetLoopMode)
pm.OnVolumeChange(bp.AuxControls.VolumeControl.SetVolume)
bp.AuxControls.VolumeControl.OnSetVolume = func(v int) {
@@ -111,6 +111,9 @@ func NewBottomPanel(pm *backend.PlaybackManager, im *backend.ImageManager, contr
bp.AuxControls.OnChangeLoopMode(func() {
pm.SetNextLoopMode()
})
+ bp.AuxControls.OnChangeAutoplay = func(autoplay bool) {
+ pm.SetAutoplay(autoplay)
+ }
bp.AuxControls.OnShowPlayQueue(contr.ShowPopUpPlayQueue)
bp.imageLoader = util.NewThumbnailLoader(im, bp.NowPlaying.SetImage)
diff --git a/ui/mainwindow.go b/ui/mainwindow.go
index c8f1f89..f6fbaa9 100644
--- a/ui/mainwindow.go
+++ b/ui/mainwindow.go
@@ -160,6 +160,7 @@ func NewMainWindow(fyneApp fyne.App, appName, displayAppName, appVersion string,
repeatMode = "All"
}
app.Config.Playback.RepeatMode = repeatMode
+ app.Config.Playback.Autoplay = app.PlaybackManager.IsAutoplay()
app.SavePlayQueueIfEnabled()
app.SaveConfigFile()
diff --git a/ui/theme/theme.go b/ui/theme/theme.go
index 5f92cbc..a9a1bcc 100644
--- a/ui/theme/theme.go
+++ b/ui/theme/theme.go
@@ -38,6 +38,7 @@ var (
AlbumIcon fyne.Resource = theme.NewThemedResource(res.ResDiscSvg)
ArtistIcon fyne.Resource = theme.NewThemedResource(res.ResPeopleSvg)
+ AutoplayIcon fyne.Resource = theme.NewThemedResource(res.ResInfinitySvg)
RadioIcon fyne.Resource = theme.NewThemedResource(res.ResBroadcastSvg)
FavoriteIcon fyne.Resource = theme.NewThemedResource(res.ResHeartFilledSvg)
NotFavoriteIcon fyne.Resource = theme.NewThemedResource(res.ResHeartOutlineSvg)
diff --git a/ui/widgets/auxcontrols.go b/ui/widgets/auxcontrols.go
index 582657d..1074a19 100644
--- a/ui/widgets/auxcontrols.go
+++ b/ui/widgets/auxcontrols.go
@@ -18,21 +18,34 @@ import (
type AuxControls struct {
widget.BaseWidget
+ OnChangeAutoplay func(autoplay bool)
+
VolumeControl *VolumeControl
+ autoplay *IconButton
loop *IconButton
showQueue *IconButton
container *fyne.Container
}
-func NewAuxControls(initialVolume int, initialLoopMode backend.LoopMode) *AuxControls {
+func NewAuxControls(initialVolume int, initialLoopMode backend.LoopMode, initialAutoplay bool) *AuxControls {
a := &AuxControls{
VolumeControl: NewVolumeControl(initialVolume),
+ autoplay: NewIconButton(myTheme.AutoplayIcon, nil),
loop: NewIconButton(myTheme.RepeatIcon, nil),
showQueue: NewIconButton(myTheme.PlayQueueIcon, nil),
}
a.loop.IconSize = IconButtonSizeSmaller
a.loop.SetToolTip(lang.L("Repeat"))
+ a.autoplay.Highlighted = initialAutoplay
+ //a.autoplay.IconSize = IconButtonSizeSmaller
+ a.autoplay.SetToolTip(lang.L("Autoplay"))
+ a.autoplay.OnTapped = func() {
+ a.SetAutoplay(!a.autoplay.Highlighted)
+ if a.OnChangeAutoplay != nil {
+ a.OnChangeAutoplay(a.autoplay.Highlighted)
+ }
+ }
a.SetLoopMode(initialLoopMode)
a.showQueue.IconSize = IconButtonSizeSmaller
a.showQueue.SetToolTip(lang.L("Show play queue"))
@@ -43,7 +56,7 @@ func NewAuxControls(initialVolume int, initialLoopMode backend.LoopMode) *AuxCon
a.VolumeControl,
container.New(
layout.NewCustomPaddedHBoxLayout(theme.Padding()*1.5),
- layout.NewSpacer(), a.loop, a.showQueue, util.NewHSpace(5)),
+ layout.NewSpacer(), a.autoplay, a.loop, a.showQueue, util.NewHSpace(5)),
layout.NewSpacer(),
),
)
@@ -73,6 +86,14 @@ func (a *AuxControls) SetLoopMode(mode backend.LoopMode) {
}
}
+func (a *AuxControls) SetAutoplay(autoplay bool) {
+ if autoplay == a.autoplay.Highlighted {
+ return
+ }
+ a.autoplay.Highlighted = autoplay
+ a.autoplay.Refresh()
+}
+
func (a *AuxControls) OnShowPlayQueue(f func()) {
a.showQueue.OnTapped = f
}