From 14e03bed0cbc40fa6b7017fa27a16a96e28b641c Mon Sep 17 00:00:00 2001 From: Drew Weymouth Date: Wed, 5 Mar 2025 16:55:52 -0800 Subject: [PATCH 01/45] initial work for playlist search --- ui/browsing/playlistpage.go | 45 ++++++++++++++++++++++++++++++++++++- ui/widgets/searchentry.go | 21 +++++++++++++++-- 2 files changed, 63 insertions(+), 3 deletions(-) diff --git a/ui/browsing/playlistpage.go b/ui/browsing/playlistpage.go index 01caba2..2098840 100644 --- a/ui/browsing/playlistpage.go +++ b/ui/browsing/playlistpage.go @@ -5,6 +5,7 @@ import ( "log" "strings" + ttwidget "github.com/dweymouth/fyne-tooltip/widget" "github.com/dweymouth/supersonic/backend" "github.com/dweymouth/supersonic/backend/mediaprovider" "github.com/dweymouth/supersonic/sharedutil" @@ -14,6 +15,7 @@ import ( "github.com/dweymouth/supersonic/ui/widgets" "fyne.io/fyne/v2" + "fyne.io/fyne/v2/canvas" "fyne.io/fyne/v2/container" "fyne.io/fyne/v2/lang" "fyne.io/fyne/v2/layout" @@ -275,6 +277,9 @@ func NewPlaylistPageHeader(page *PlaylistPage) *PlaylistPageHeader { a.ownerLabel = util.NewTruncatingLabel() a.createdAtLabel = widget.NewLabel("") a.trackTimeLabel = widget.NewLabel("") + + var buttonRow *fyne.Container + a.editButton = widget.NewButtonWithIcon(lang.L("Edit"), theme.DocumentCreateIcon(), func() { if a.playlistInfo != nil { a.page.contr.DoEditPlaylistWorkflow(&a.playlistInfo.Playlist) @@ -289,6 +294,42 @@ func NewPlaylistPageHeader(page *PlaylistPage) *PlaylistPageHeader { a.page.pm.LoadTracks(a.page.tracks, backend.Replace, true) a.page.pm.PlayFromBeginning() }) + var searchBtn *ttwidget.Button + var searchEntry *widgets.SearchEntry + searchBtn = ttwidget.NewButtonWithIcon("", theme.SearchIcon(), func() { + minW := searchBtn.MinSize().Width + if searchEntry == nil { + searchEntry = widgets.NewSearchEntry() + searchEntry.PlaceHolder = "" + searchEntry.Scroll = container.ScrollNone + searchEntry.OnFocusLost = func() { + if searchEntry.Text == "" { + searchEntry.SetPlaceHolder("") + fyne.NewAnimation(canvas.DurationShort, func(f float32) { + f = 1 - f + w := (200-minW)*f + minW + searchEntry.SetMinWidth(w) + if f == 0 { + buttonRow.Objects[3] = searchBtn + } + buttonRow.Layout.Layout(buttonRow.Objects, buttonRow.Layout.MinSize(buttonRow.Objects)) + }).Start() + } + } + } + buttonRow.Objects[3] = searchEntry + searchEntry.SetMinWidth(minW) + fyne.CurrentApp().Driver().CanvasForObject(a).Focus(searchEntry) + fyne.NewAnimation(canvas.DurationShort, func(f float32) { + w := (200-minW)*f + minW + searchEntry.SetMinWidth(w) + buttonRow.Layout.Layout(buttonRow.Objects, buttonRow.Layout.MinSize(buttonRow.Objects)) + if f == 1 { + searchEntry.SetPlaceHolder(lang.L("Search")) + } + }).Start() + }) + searchBtn.SetToolTip(lang.L("Search")) var pop *widget.PopUpMenu menuBtn := widget.NewButtonWithIcon("", theme.MoreHorizontalIcon(), nil) menuBtn.OnTapped = func() { @@ -317,13 +358,15 @@ func NewPlaylistPageHeader(page *PlaylistPage) *PlaylistPageHeader { pop.ShowAtPosition(fyne.NewPos(pos.X, pos.Y+menuBtn.Size().Height)) } + buttonRow = container.NewHBox(a.editButton, playButton, shuffleBtn, searchBtn, menuBtn) + a.container = util.AddHeaderBackground( container.NewBorder(nil, nil, a.image, nil, container.NewVBox(a.titleLabel, container.New(layout.NewCustomPaddedVBoxLayout(theme.Padding()-10), a.descriptionLabel, a.ownerLabel, a.trackTimeLabel), - container.NewHBox(a.editButton, playButton, shuffleBtn, menuBtn), + buttonRow, ))) return a } diff --git a/ui/widgets/searchentry.go b/ui/widgets/searchentry.go index f998bc1..c71e4da 100644 --- a/ui/widgets/searchentry.go +++ b/ui/widgets/searchentry.go @@ -17,7 +17,9 @@ import ( type SearchEntry struct { widget.Entry - OnSearched func(string) + minWidth float32 + OnSearched func(string) + OnFocusLost func() } func NewSearchEntry() *SearchEntry { @@ -53,13 +55,28 @@ func (s *SearchEntry) TypedKey(e *fyne.KeyEvent) { s.Entry.TypedKey(e) } +func (s *SearchEntry) FocusLost() { + s.Entry.FocusLost() + if s.OnFocusLost != nil { + s.OnFocusLost() + } +} + func (s *SearchEntry) Refresh() { s.updateActionButton() s.Entry.Refresh() } +func (s *SearchEntry) SetMinWidth(w float32) { + s.minWidth = w +} + func (s *SearchEntry) MinSize() fyne.Size { - return fyne.NewSize(200, s.Entry.MinSize().Height) + w := s.minWidth + if w == 0 { + w = 200 + } + return fyne.NewSize(w, s.Entry.MinSize().Height) } func (s *SearchEntry) updateActionButton() bool { From 15b73893c3cb6a016fa316ae09b79cebb5c61369 Mon Sep 17 00:00:00 2001 From: Drew Weymouth Date: Thu, 6 Mar 2025 19:24:21 -0800 Subject: [PATCH 02/45] implement search within playlist --- ui/browsing/playlistpage.go | 46 +++++++++++++++++++++++++++++++++++-- 1 file changed, 44 insertions(+), 2 deletions(-) diff --git a/ui/browsing/playlistpage.go b/ui/browsing/playlistpage.go index 2098840..2f12ac5 100644 --- a/ui/browsing/playlistpage.go +++ b/ui/browsing/playlistpage.go @@ -5,6 +5,7 @@ import ( "log" "strings" + "github.com/deluan/sanitize" ttwidget "github.com/dweymouth/fyne-tooltip/widget" "github.com/dweymouth/supersonic/backend" "github.com/dweymouth/supersonic/backend/mediaprovider" @@ -242,6 +243,37 @@ func (a *PlaylistPage) onRemoveSelectedFromPlaylist() { a.Reload() } +func (a *PlaylistPage) onSearched(query string) { + if query == "" { + a.tracklist.Options.Reorderable = true + ids := a.tracklist.SelectedTrackIDs() + a.tracklist.SetTracks(a.tracks) + // if a track was selected in the searched view, + // scroll to it when switching back to full playlist + if len(ids) > 0 { + a.tracklist.SelectAndScrollToTrack(ids[0]) + } + return + } + + searched := sharedutil.FilterSlice(a.tracks, func(t *mediaprovider.Track) bool { + sani := func(s string) string { + return strings.ToLower(sanitize.Accents(s)) + } + qLower := strings.ToLower(query) + return strings.Contains(sani(t.Title), qLower) || + strings.Contains(sani(strings.Join(t.ArtistNames, "")), qLower) || + strings.Contains(sani(t.Album), qLower) || + strings.Contains(sani(strings.Join(t.Genres, "")), qLower) || + strings.Contains(sani(strings.Join(t.ComposerNames, "")), qLower) || + strings.Contains(fmt.Sprintf("%d", t.Year), query) || + strings.Contains(sani(t.Comment), qLower) + }) + + a.tracklist.Options.Reorderable = false + a.tracklist.SetTracks(searched) +} + type PlaylistPageHeader struct { widget.BaseWidget @@ -278,8 +310,6 @@ func NewPlaylistPageHeader(page *PlaylistPage) *PlaylistPageHeader { a.createdAtLabel = widget.NewLabel("") a.trackTimeLabel = widget.NewLabel("") - var buttonRow *fyne.Container - a.editButton = widget.NewButtonWithIcon(lang.L("Edit"), theme.DocumentCreateIcon(), func() { if a.playlistInfo != nil { a.page.contr.DoEditPlaylistWorkflow(&a.playlistInfo.Playlist) @@ -294,6 +324,10 @@ func NewPlaylistPageHeader(page *PlaylistPage) *PlaylistPageHeader { a.page.pm.LoadTracks(a.page.tracks, backend.Replace, true) a.page.pm.PlayFromBeginning() }) + + // set up search button / entry animation pair + // needs reference to containing HBox container (initialized later) + var buttonRow *fyne.Container var searchBtn *ttwidget.Button var searchEntry *widgets.SearchEntry searchBtn = ttwidget.NewButtonWithIcon("", theme.SearchIcon(), func() { @@ -302,8 +336,11 @@ func NewPlaylistPageHeader(page *PlaylistPage) *PlaylistPageHeader { searchEntry = widgets.NewSearchEntry() searchEntry.PlaceHolder = "" searchEntry.Scroll = container.ScrollNone + searchEntry.OnSearched = page.onSearched searchEntry.OnFocusLost = func() { if searchEntry.Text == "" { + // dismissal animation + searchEntry.Scroll = container.ScrollNone searchEntry.SetPlaceHolder("") fyne.NewAnimation(canvas.DurationShort, func(f float32) { f = 1 - f @@ -312,6 +349,7 @@ func NewPlaylistPageHeader(page *PlaylistPage) *PlaylistPageHeader { if f == 0 { buttonRow.Objects[3] = searchBtn } + // re-layout container (without unneeded full refresh) buttonRow.Layout.Layout(buttonRow.Objects, buttonRow.Layout.MinSize(buttonRow.Objects)) }).Start() } @@ -323,13 +361,17 @@ func NewPlaylistPageHeader(page *PlaylistPage) *PlaylistPageHeader { fyne.NewAnimation(canvas.DurationShort, func(f float32) { w := (200-minW)*f + minW searchEntry.SetMinWidth(w) + // re-layout container (without unneeded full refresh) buttonRow.Layout.Layout(buttonRow.Objects, buttonRow.Layout.MinSize(buttonRow.Objects)) if f == 1 { + searchEntry.Scroll = container.ScrollHorizontalOnly + searchEntry.Refresh() // needed to initialize widget's scroller searchEntry.SetPlaceHolder(lang.L("Search")) } }).Start() }) searchBtn.SetToolTip(lang.L("Search")) + var pop *widget.PopUpMenu menuBtn := widget.NewButtonWithIcon("", theme.MoreHorizontalIcon(), nil) menuBtn.OnTapped = func() { From 56c32e529ee1d498e7532d480020c6f64429f75c Mon Sep 17 00:00:00 2001 From: Drew Weymouth Date: Fri, 7 Mar 2025 08:04:15 -0800 Subject: [PATCH 03/45] fix a few bugs --- res/translations/en.json | 2 +- ui/browsing/playlistpage.go | 14 ++++++++++++-- 2 files changed, 13 insertions(+), 3 deletions(-) diff --git a/res/translations/en.json b/res/translations/en.json index fabadfc..5c10897 100644 --- a/res/translations/en.json +++ b/res/translations/en.json @@ -168,6 +168,7 @@ "Save play queue on exit": "Save play queue on exit", "Saved at": "Saved at", "Scrobble when": "Scrobble when", + "Search": "Search", "Search Everywhere": "Search Everywhere", "Search page": "Search page", "Search playlists or new playlist name": "Search playlists or new playlist name", @@ -237,7 +238,6 @@ "You are running the latest version of": "You are running the latest version of", "Quit": "Quit", "Rescan Library": "Rescan Library", - "Search": "Search", "Owner": "Owner", "To server": "To server", "Fav.": "Fav.", diff --git a/ui/browsing/playlistpage.go b/ui/browsing/playlistpage.go index 2f12ac5..6319025 100644 --- a/ui/browsing/playlistpage.go +++ b/ui/browsing/playlistpage.go @@ -245,8 +245,11 @@ func (a *PlaylistPage) onRemoveSelectedFromPlaylist() { func (a *PlaylistPage) onSearched(query string) { if query == "" { - a.tracklist.Options.Reorderable = true + // switch back to full playlist view + ids := a.tracklist.SelectedTrackIDs() + a.tracklist.Options.Reorderable = true + a.tracklist.SetSorting(a.trackSort) // restore old sort order a.tracklist.SetTracks(a.tracks) // if a track was selected in the searched view, // scroll to it when switching back to full playlist @@ -256,6 +259,7 @@ func (a *PlaylistPage) onSearched(query string) { return } + // search tracks within the playlist searched := sharedutil.FilterSlice(a.tracks, func(t *mediaprovider.Track) bool { sani := func(s string) string { return strings.ToLower(sanitize.Accents(s)) @@ -270,7 +274,9 @@ func (a *PlaylistPage) onSearched(query string) { strings.Contains(sani(t.Comment), qLower) }) + a.trackSort = a.tracklist.Sorting() // save old sort order a.tracklist.Options.Reorderable = false + a.tracklist.SetSorting(widgets.TracklistSort{}) a.tracklist.SetTracks(searched) } @@ -336,7 +342,11 @@ func NewPlaylistPageHeader(page *PlaylistPage) *PlaylistPageHeader { searchEntry = widgets.NewSearchEntry() searchEntry.PlaceHolder = "" searchEntry.Scroll = container.ScrollNone - searchEntry.OnSearched = page.onSearched + searchEntry.OnSearched = func(s string) { + // we can't assign a.page.onSearched directly, + // since widget reuse means a.page could change + a.page.onSearched(s) + } searchEntry.OnFocusLost = func() { if searchEntry.Text == "" { // dismissal animation From 7d39ea4daf594a13e69860abdba2ddbc68aac2b8 Mon Sep 17 00:00:00 2001 From: Drew Weymouth Date: Sat, 8 Mar 2025 07:46:40 -0800 Subject: [PATCH 04/45] bump Fyne to 2.6-beta1 --- go.mod | 8 ++++---- go.sum | 12 ++++++------ 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/go.mod b/go.mod index b4f1784..672b93e 100644 --- a/go.mod +++ b/go.mod @@ -3,7 +3,7 @@ module github.com/dweymouth/supersonic go 1.21 require ( - fyne.io/fyne/v2 v2.6.0-alpha1 + fyne.io/fyne/v2 v2.6.0-beta1 github.com/20after4/configdir v0.1.1 github.com/Microsoft/go-winio v0.6.2 github.com/cenkalti/dominantcolor v1.0.3 @@ -33,7 +33,7 @@ require ( github.com/fredbi/uri v1.1.0 // indirect github.com/fsnotify/fsnotify v1.7.0 // indirect github.com/fyne-io/gl-js v0.1.0 // indirect - github.com/fyne-io/glfw-js v0.1.0 // indirect + github.com/fyne-io/glfw-js v0.2.0 // indirect github.com/fyne-io/image v0.1.0 // indirect github.com/go-gl/gl v0.0.0-20231021071112-07e5d0ea2e71 // indirect github.com/go-gl/glfw/v3.3/glfw v0.0.0-20240506104042-037f3cc74f2a // indirect @@ -44,7 +44,7 @@ require ( github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 // indirect github.com/nicksnyder/go-i18n/v2 v2.5.1 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect - github.com/rymdport/portal v0.3.0 // indirect + github.com/rymdport/portal v0.4.1 // indirect github.com/srwiley/oksvg v0.0.0-20221011165216-be6e8873101c // indirect github.com/srwiley/rasterx v0.0.0-20220730225603-2ab79fcdd4ef // indirect github.com/stretchr/testify v1.10.0 // indirect @@ -53,4 +53,4 @@ require ( gopkg.in/yaml.v3 v3.0.1 // indirect ) -replace fyne.io/fyne/v2 v2.6.0-alpha1 => github.com/dweymouth/fyne/v2 v2.3.0-rc1.0.20250222005659-e73d8f178006 +replace fyne.io/fyne/v2 v2.6.0-beta1 => github.com/dweymouth/fyne/v2 v2.3.0-rc1.0.20250308154116-c32a0de49bde diff --git a/go.sum b/go.sum index 8dde906..ac2322e 100644 --- a/go.sum +++ b/go.sum @@ -23,8 +23,8 @@ github.com/dweymouth/fyne-lyrics v0.0.0-20240528234907-15eee7ce5e64 h1:RUIrnGY03 github.com/dweymouth/fyne-lyrics v0.0.0-20240528234907-15eee7ce5e64/go.mod h1:3YrjFDHMlhCsSZ/OvmJCxWm9QHSgOVWZBxnraZz9Z7c= github.com/dweymouth/fyne-tooltip v0.3.0 h1:NKCyTkh9NtvnTsiHtTOtaJzRDOFYP8AckQ2tyhOh6JY= github.com/dweymouth/fyne-tooltip v0.3.0/go.mod h1:m04ShLW/Tp6LXrNieTumApvNgo7YSB+wi+jZTN+kDBU= -github.com/dweymouth/fyne/v2 v2.3.0-rc1.0.20250222005659-e73d8f178006 h1:vsLOmm3C/RLFmWhfv+8MNcjApC9S3SdXZLcIGydiVnI= -github.com/dweymouth/fyne/v2 v2.3.0-rc1.0.20250222005659-e73d8f178006/go.mod h1:Bzv2yK+ncZ8LJbHKjyJJpEAFlbs6oulHgKm04ObOqA8= +github.com/dweymouth/fyne/v2 v2.3.0-rc1.0.20250308154116-c32a0de49bde h1:vSJQkJWRwwTnex2cSsGQNA9EZKzCUKzFc1Gs8A/JUXs= +github.com/dweymouth/fyne/v2 v2.3.0-rc1.0.20250308154116-c32a0de49bde/go.mod h1:ON11afuS9jVyN+nfEtITv3jX1H+JD+Z50gxCiEC1zLw= github.com/dweymouth/go-jellyfin v0.0.0-20240517151952-5ceca61cb645 h1:KzqSaQwG3HsTZQlEtkp0BeUy9vmYZ0rq0B15qIPSiBs= github.com/dweymouth/go-jellyfin v0.0.0-20240517151952-5ceca61cb645/go.mod h1:fcUagHBaQnt06GmBAllNE0J4O/7064zXRWdqnTTtVjI= github.com/felixge/fgprof v0.9.3 h1:VvyZxILNuCiUCSXtPtYmmtGvb65nqXh2QFWc0Wpf2/g= @@ -35,8 +35,8 @@ github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nos github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM= github.com/fyne-io/gl-js v0.1.0 h1:8luJzNs0ntEAJo+8x8kfUOXujUlP8gB3QMOxO2mUdpM= github.com/fyne-io/gl-js v0.1.0/go.mod h1:ZcepK8vmOYLu96JoxbCKJy2ybr+g1pTnaBDdl7c3ajI= -github.com/fyne-io/glfw-js v0.1.0 h1:RGGMmVjcsG17Oifl3X2UJ5vH3PgS4B1UY3ASeN5BXbI= -github.com/fyne-io/glfw-js v0.1.0/go.mod h1:Ri6te7rdZtBgBpxLW19uBpp3Dl6K9K/bRaYdJ22G8Jk= +github.com/fyne-io/glfw-js v0.2.0 h1:8GUZtN2aCoTPNqgRDxK5+kn9OURINhBEBc7M4O1KrmM= +github.com/fyne-io/glfw-js v0.2.0/go.mod h1:Ri6te7rdZtBgBpxLW19uBpp3Dl6K9K/bRaYdJ22G8Jk= github.com/fyne-io/image v0.1.0 h1:Vm2TQJ2PWGHCf3jYi1/XroaNNMu+GfI/O2QpSbZd4XQ= github.com/fyne-io/image v0.1.0/go.mod h1:xrfYBh6yspc+KjkgdZU/ifUC9sPA5Iv7WYUBzQKK7JM= github.com/go-gl/gl v0.0.0-20231021071112-07e5d0ea2e71 h1:5BVwOaUSBTlVZowGO6VZGw2H/zl9nrd3eCZfYV+NfQA= @@ -77,8 +77,8 @@ github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZb github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/quarckster/go-mpris-server v1.0.3 h1:ef6d3DpxlORtdEBHnhQ/j3gS0Z3+YUfXeJhC9L9DZvA= github.com/quarckster/go-mpris-server v1.0.3/go.mod h1:2b4IdrpnEoEfU+6fQKjYhAgdvsiz4JxmTpDAUrMJVO4= -github.com/rymdport/portal v0.3.0 h1:QRHcwKwx3kY5JTQcsVhmhC3TGqGQb9LFghVNUy8AdB8= -github.com/rymdport/portal v0.3.0/go.mod h1:kFF4jslnJ8pD5uCi17brj/ODlfIidOxlgUDTO5ncnC4= +github.com/rymdport/portal v0.4.1 h1:2dnZhjf5uEaeDjeF/yBIeeRo6pNI2QAKm7kq1w/kbnA= +github.com/rymdport/portal v0.4.1/go.mod h1:kFF4jslnJ8pD5uCi17brj/ODlfIidOxlgUDTO5ncnC4= github.com/srwiley/oksvg v0.0.0-20221011165216-be6e8873101c h1:km8GpoQut05eY3GiYWEedbTT0qnSxrCjsVbb7yKY1KE= github.com/srwiley/oksvg v0.0.0-20221011165216-be6e8873101c/go.mod h1:cNQ3dwVJtS5Hmnjxy6AgTPd0Inb3pW05ftPSX7NZO7Q= github.com/srwiley/rasterx v0.0.0-20220730225603-2ab79fcdd4ef h1:Ch6Q+AZUxDBCVqdkI8FSpFyZDtCVBc2VmejdNrm5rRQ= From c79be131a730e55332a4af1bbc1834abeea3c76c Mon Sep 17 00:00:00 2001 From: Drew Weymouth Date: Wed, 12 Mar 2025 17:11:01 -0700 Subject: [PATCH 05/45] Fix #578: scrobble correct track when skipping next --- backend/playbackengine.go | 34 ++++++++++++++++++++++++++-------- 1 file changed, 26 insertions(+), 8 deletions(-) diff --git a/backend/playbackengine.go b/backend/playbackengine.go index 17295c3..d7a644c 100644 --- a/backend/playbackengine.go +++ b/backend/playbackengine.go @@ -48,12 +48,15 @@ type playbackEngine struct { latestTrackPosition float64 // cleared by checkScrobble callbacksDisabled bool - playQueue []mediaprovider.MediaItem - nowPlayingIdx int - isRadio bool + playQueue []mediaprovider.MediaItem + nowPlayingIdx int + isRadio bool + loopMode LoopMode + + // flags for handleOnTrackChange / handleOnStopped callbacks - reset to false in the callbacks wasStopped bool // true iff player was stopped before handleOnTrackChange invocation - noIncrementNextTrackChange bool // true iff the nowPlayingIndex should not be incremente don the next onTrackChange - loopMode LoopMode + noIncrementNextTrackChange bool // true iff the nowPlayingIndex should not be incremented on the next onTrackChange + alreadyScrobbled bool // true iff the previously-playing track was already scrobbled // to pass to onSongChange listeners; clear once listeners have been called lastScrobbled *mediaprovider.Track @@ -127,6 +130,9 @@ func (p *playbackEngine) PlayTrackAt(idx int) error { if idx < 0 || idx >= len(p.playQueue) { return errors.New("track index out of range") } + // scrobble current track if needed + p.checkScrobble() + p.alreadyScrobbled = true p.noIncrementNextTrackChange = true err := p.setTrack(idx, false) if err == nil { @@ -385,6 +391,7 @@ func (p *playbackEngine) RemoveTracksFromQueue(idxs []int) { isPlayingTrackRemoved = true // If we are removing the currently playing track, we need to scrobble it p.checkScrobble() + p.alreadyScrobbled = true } else if nowPlaying >= 0 && i == nowPlaying+1 { isNextPlayingTrackremoved = true } @@ -456,7 +463,11 @@ func (p *playbackEngine) SetReplayGainMode(mode player.ReplayGainMode) { } func (p *playbackEngine) handleOnTrackChange() { - p.checkScrobble() // scrobble the previous song if needed + // scrobble the previous song if needed + if !p.alreadyScrobbled { + p.checkScrobble() + } + if p.player.GetStatus().State == player.Playing { p.playTimeStopwatch.Start() } @@ -466,11 +477,15 @@ func (p *playbackEngine) handleOnTrackChange() { p.nowPlayingIdx = 0 // wrapped around } } - p.noIncrementNextTrackChange = false nowPlaying := p.playQueue[p.nowPlayingIdx] _, isRadio := nowPlaying.(*mediaprovider.RadioStation) p.isRadio = isRadio + + // reset flags + p.noIncrementNextTrackChange = false p.wasStopped = false + p.alreadyScrobbled = false + p.curTrackDuration = float64(nowPlaying.Metadata().Duration) p.sendNowPlayingScrobble() // Must come before invokeOnChangeCallbacks b/c track may immediately be scrobbled p.invokeOnSongChangeCallbacks() @@ -480,11 +495,14 @@ func (p *playbackEngine) handleOnTrackChange() { func (p *playbackEngine) handleOnStopped() { p.playTimeStopwatch.Stop() - p.checkScrobble() + if !p.alreadyScrobbled { + p.checkScrobble() + } p.stopPollTimePos() p.doUpdateTimePos(false) p.invokeOnSongChangeCallbacks() p.invokeNoArgCallbacks(p.onStopped) + p.alreadyScrobbled = false p.wasStopped = true p.nowPlayingIdx = -1 } From 31df8ab054e010978558739d962fcb69f26fbf89 Mon Sep 17 00:00:00 2001 From: Drew Weymouth Date: Fri, 14 Mar 2025 09:24:39 -0700 Subject: [PATCH 06/45] add migrated_fynedo to `make build` --- Makefile | 2 +- README.md | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/Makefile b/Makefile index f91cfdf..8ac1bc2 100644 --- a/Makefile +++ b/Makefile @@ -1,5 +1,5 @@ build: - go build + go build -tags migrated_fynedo # dylibbundler doesn't seem to pick up on the Python framework dependency, # so the last 3 cmds move it over manually. This is a bit fragile though diff --git a/README.md b/README.md index becd07a..2a36033 100644 --- a/README.md +++ b/README.md @@ -82,7 +82,7 @@ If you are running **Windows**, **Mac OS**, or a **Debian**-based Linux distro, * ``sudo dnf install golang mpv-devel libX11-devel libXcursor-devel libXrandr-devel libXinerama-devel libXi-devel libglvnd-devel libXxf86vm-devel`` ### Build -* clone the repo, CD into the repo root, and run ``go build`` +* clone the repo, CD into the repo root, and run ``make build`` * (note that the first build will take some time as it will download and build the UI library) ### Generate installable .tar.xz bundle @@ -135,7 +135,7 @@ Supersonic is available in the AUR and can be built either manually with `makepk - ``export LIBRARY_PATH=/opt/local/lib:$LIBRARY_PATH`` #### Building the application -* clone the repo, CD into the repo root, and run ``go build`` +* clone the repo, CD into the repo root, and run ``make build`` * (note that the first build will take some time as it will download and build the UI library) * run ``make package_macos`` to generate the .app bundle * **If** you are on Mac OS **High Sierra** through **Catalina**, run ``make bundledeps_macos_highsierra`` and you are done! Otherwise, continue reaading. From 1a4237e493a3c9546d82eb6f0539eae8f9ea0622 Mon Sep 17 00:00:00 2001 From: Drew Weymouth Date: Tue, 18 Mar 2025 08:39:38 -0700 Subject: [PATCH 07/45] Fix #580: ensure track search results are added properly --- ui/widgets/tracklist.go | 2 +- ui/widgets/tracklistloader.go | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/ui/widgets/tracklist.go b/ui/widgets/tracklist.go index d06f279..b5277ff 100644 --- a/ui/widgets/tracklist.go +++ b/ui/widgets/tracklist.go @@ -342,7 +342,7 @@ func (t *Tracklist) GetTracks() []*mediaprovider.Track { // Append more tracks to the tracklist. func (t *Tracklist) AppendTracks(trs []*mediaprovider.Track) { - t.tracksOrigOrder = append(t.tracks, util.ToTrackListModels(trs)...) + t.tracksOrigOrder = append(t.tracksOrigOrder, util.ToTrackListModels(trs)...) t.doSortTracks() t.list.Refresh() } diff --git a/ui/widgets/tracklistloader.go b/ui/widgets/tracklistloader.go index 461c829..cc1a235 100644 --- a/ui/widgets/tracklistloader.go +++ b/ui/widgets/tracklistloader.go @@ -72,7 +72,7 @@ func (t *TracklistLoader) loadMoreTracks(num int) { if t.disposed.Load() { return } - fyne.Do(func() { + fyne.DoAndWait(func() { t.tracklist.SetLoading(false) t.tracklist.AppendTracks(t.trackBuffer) t.len += len(t.trackBuffer) From 5c88180d6990144a65ca6ad154652274fa6e422f Mon Sep 17 00:00:00 2001 From: Drew Weymouth Date: Wed, 26 Mar 2025 08:30:04 -0700 Subject: [PATCH 08/45] refactor: Extract tracklist context menu as own component --- ui/util/trackcontextmenu.go | 137 ++++++++++++++++++++++++++++++++++++ ui/util/tracklistutil.go | 5 +- ui/widgets/tracklist.go | 134 +++++++++++------------------------ 3 files changed, 183 insertions(+), 93 deletions(-) create mode 100644 ui/util/trackcontextmenu.go diff --git a/ui/util/trackcontextmenu.go b/ui/util/trackcontextmenu.go new file mode 100644 index 0000000..b6e881f --- /dev/null +++ b/ui/util/trackcontextmenu.go @@ -0,0 +1,137 @@ +package util + +import ( + "fyne.io/fyne/v2" + "fyne.io/fyne/v2/lang" + "fyne.io/fyne/v2/theme" + "fyne.io/fyne/v2/widget" + myTheme "github.com/dweymouth/supersonic/ui/theme" +) + +type TrackContextMenu struct { + ratingSubmenu *fyne.MenuItem + shareMenuItem *fyne.MenuItem + songRadioMenuItem *fyne.MenuItem + infoMenuItem *fyne.MenuItem + + OnPlay func(shuffle bool) + OnAddToQueue func(next bool) + OnPlaySongRadio func() + OnDownload func() + OnAddToPlaylist func() + OnShowInfo func() + OnShare func() + OnFavorite func(fav bool) + OnSetRating func(rating int) + + menu *fyne.Menu +} + +func NewTrackContextMenu(disablePlaybackMenu bool, auxItems []*fyne.MenuItem) *TrackContextMenu { + tcm := &TrackContextMenu{} + + tcm.menu = fyne.NewMenu("") + if !disablePlaybackMenu { + play := fyne.NewMenuItem(lang.L("Play"), func() { + if tcm.OnPlay != nil { + tcm.OnPlay(false) + } + }) + play.Icon = theme.MediaPlayIcon() + shuffle := fyne.NewMenuItem(lang.L("Shuffle"), func() { + if tcm.OnPlay != nil { + tcm.OnPlay(true) + } + }) + shuffle.Icon = myTheme.ShuffleIcon + playNext := fyne.NewMenuItem(lang.L("Play next"), func() { + if tcm.OnAddToQueue != nil { + tcm.OnAddToQueue(true) + } + }) + playNext.Icon = myTheme.PlayNextIcon + add := fyne.NewMenuItem(lang.L("Add to queue"), func() { + if tcm.OnAddToQueue != nil { + tcm.OnAddToQueue(false) + } + }) + add.Icon = theme.ContentAddIcon() + tcm.songRadioMenuItem = fyne.NewMenuItem(lang.L("Play song radio"), func() { + if tcm.OnPlaySongRadio != nil { + tcm.OnPlaySongRadio() + } + }) + tcm.songRadioMenuItem.Icon = myTheme.RadioIcon + tcm.menu.Items = append(tcm.menu.Items, + play, shuffle, playNext, add, tcm.songRadioMenuItem) + } + playlist := fyne.NewMenuItem(lang.L("Add to playlist")+"...", func() { + if tcm.OnAddToPlaylist != nil { + tcm.OnAddToPlaylist() + } + }) + playlist.Icon = myTheme.PlaylistIcon + download := fyne.NewMenuItem(lang.L("Download")+"...", func() { + if tcm.OnDownload != nil { + tcm.OnDownload() + } + }) + download.Icon = theme.DownloadIcon() + tcm.infoMenuItem = fyne.NewMenuItem(lang.L("Show info")+"...", func() { + if tcm.OnShowInfo != nil { + tcm.OnShowInfo() + } + }) + tcm.infoMenuItem.Icon = theme.InfoIcon() + favorite := fyne.NewMenuItem(lang.L("Set favorite"), func() { + if tcm.OnFavorite != nil { + tcm.OnFavorite(true) + } + }) + favorite.Icon = myTheme.FavoriteIcon + unfavorite := fyne.NewMenuItem(lang.L("Unset favorite"), func() { + if tcm.OnFavorite != nil { + tcm.OnFavorite(false) + } + }) + unfavorite.Icon = myTheme.NotFavoriteIcon + tcm.menu.Items = append(tcm.menu.Items, fyne.NewMenuItemSeparator(), + playlist, download, tcm.infoMenuItem) + tcm.shareMenuItem = fyne.NewMenuItem(lang.L("Share")+"...", func() { + if tcm.OnShare != nil { + tcm.OnShare() + } + }) + tcm.shareMenuItem.Icon = myTheme.ShareIcon + tcm.menu.Items = append(tcm.menu.Items, tcm.shareMenuItem) + tcm.menu.Items = append(tcm.menu.Items, fyne.NewMenuItemSeparator()) + tcm.menu.Items = append(tcm.menu.Items, favorite, unfavorite) + tcm.ratingSubmenu = NewRatingSubmenu(func(rating int) { + if tcm.OnSetRating != nil { + tcm.OnSetRating(rating) + } + }) + tcm.menu.Items = append(tcm.menu.Items, tcm.ratingSubmenu) + if len(auxItems) > 0 { + tcm.menu.Items = append(tcm.menu.Items, fyne.NewMenuItemSeparator()) + tcm.menu.Items = append(tcm.menu.Items, auxItems...) + } + + return tcm +} + +func (tcm *TrackContextMenu) SetRatingDisabled(disabled bool) { + tcm.ratingSubmenu.Disabled = disabled +} + +func (tcm *TrackContextMenu) SetShareDisabled(disabled bool) { + tcm.shareMenuItem.Disabled = disabled +} + +func (tcm *TrackContextMenu) SetInfoDisabled(disabled bool) { + tcm.infoMenuItem.Disabled = disabled +} + +func (tcm *TrackContextMenu) ShowAtPosition(pos fyne.Position, canvas fyne.Canvas) { + widget.ShowPopUpMenuAtPosition(tcm.menu, canvas, pos) +} diff --git a/ui/util/tracklistutil.go b/ui/util/tracklistutil.go index 68a35a0..1669517 100644 --- a/ui/util/tracklistutil.go +++ b/ui/util/tracklistutil.go @@ -52,12 +52,13 @@ func SelectedIndexes(items []*TrackListModel) []int { return selected } -func SelectItem(items []*TrackListModel, idx int) { +func SelectItem(items []*TrackListModel, idx int) bool { if items[idx].Selected { - return + return false } UnselectAllItems(items) items[idx].Selected = true + return true } func SelectAllItems(items []*TrackListModel) { diff --git a/ui/widgets/tracklist.go b/ui/widgets/tracklist.go index b5277ff..7f1b27f 100644 --- a/ui/widgets/tracklist.go +++ b/ui/widgets/tracklist.go @@ -6,13 +6,10 @@ import ( "sort" "strings" - "fyne.io/fyne/v2/lang" - "github.com/dweymouth/supersonic/backend" "github.com/dweymouth/supersonic/backend/mediaprovider" "github.com/dweymouth/supersonic/sharedutil" "github.com/dweymouth/supersonic/ui/layouts" - myTheme "github.com/dweymouth/supersonic/ui/theme" "github.com/dweymouth/supersonic/ui/util" "fyne.io/fyne/v2" @@ -93,17 +90,13 @@ type Tracklist struct { tracks []*util.TrackListModel tracksOrigOrder []*util.TrackListModel - nowPlayingID string - colLayout *layouts.ColumnsLayout - hdr *ListHeader - list *FocusList - ctxMenu *fyne.Menu - ratingSubmenu *fyne.MenuItem - shareMenuItem *fyne.MenuItem - songRadioMenuItem *fyne.MenuItem - infoMenuItem *fyne.MenuItem - loadingDots *LoadingDots - container *fyne.Container + nowPlayingID string + colLayout *layouts.ColumnsLayout + hdr *ListHeader + list *FocusList + ctxMenu *util.TrackContextMenu + loadingDots *LoadingDots + container *fyne.Container } func NewTracklist(tracks []*mediaprovider.Track, im *backend.ImageManager, useCompactRows bool) *Tracklist { @@ -491,8 +484,8 @@ func (t *Tracklist) selectAddOrRemove(idx int) { t.tracks[idx].Selected = !t.tracks[idx].Selected } -func (t *Tracklist) selectTrack(idx int) { - util.SelectItem(t.tracks, idx) +func (t *Tracklist) selectTrack(idx int) bool { + return util.SelectItem(t.tracks, idx) } func (t *Tracklist) selectRange(idx int) { @@ -500,88 +493,47 @@ func (t *Tracklist) selectRange(idx int) { } func (t *Tracklist) onShowContextMenu(e *fyne.PointEvent, trackIdx int) { - t.selectTrack(trackIdx) - t.list.Refresh() + if t.selectTrack(trackIdx) { + t.list.Refresh() + } if t.ctxMenu == nil { - t.ctxMenu = fyne.NewMenu("") - if !t.Options.DisablePlaybackMenu { - play := fyne.NewMenuItem(lang.L("Play"), func() { - if t.OnPlaySelection != nil { - t.OnPlaySelection(t.selectedTracks(), false) - } - }) - play.Icon = theme.MediaPlayIcon() - shuffle := fyne.NewMenuItem(lang.L("Shuffle"), func() { - if t.OnPlaySelection != nil { - t.OnPlaySelection(t.selectedTracks(), true) - } - }) - shuffle.Icon = myTheme.ShuffleIcon - playNext := fyne.NewMenuItem(lang.L("Play next"), func() { - if t.OnPlaySelection != nil { - t.OnPlaySelectionNext(t.selectedTracks()) - } - }) - playNext.Icon = myTheme.PlayNextIcon - add := fyne.NewMenuItem(lang.L("Add to queue"), func() { - if t.OnPlaySelection != nil { - t.OnAddToQueue(t.selectedTracks()) - } - }) - add.Icon = theme.ContentAddIcon() - t.songRadioMenuItem = fyne.NewMenuItem(lang.L("Play song radio"), func() { - t.onPlaySongRadio(t.selectedTracks()) - }) - t.songRadioMenuItem.Icon = myTheme.RadioIcon - t.ctxMenu.Items = append(t.ctxMenu.Items, - play, shuffle, playNext, add, t.songRadioMenuItem) + t.ctxMenu = util.NewTrackContextMenu(t.Options.DisablePlaybackMenu, t.Options.AuxiliaryMenuItems) + t.ctxMenu.OnPlay = func(shuffle bool) { + t.OnPlaySelection(t.selectedTracks(), shuffle) } - playlist := fyne.NewMenuItem(lang.L("Add to playlist")+"...", func() { - if t.OnAddToPlaylist != nil { - t.OnAddToPlaylist(t.SelectedTrackIDs()) + t.ctxMenu.OnAddToQueue = func(next bool) { + if next { + t.OnPlaySelectionNext(t.selectedTracks()) + } else { + t.OnAddToQueue(t.selectedTracks()) } - }) - playlist.Icon = myTheme.PlaylistIcon - download := fyne.NewMenuItem(lang.L("Download")+"...", func() { + } + t.ctxMenu.OnPlaySongRadio = func() { + t.onPlaySongRadio(t.selectedTracks()) + } + t.ctxMenu.OnAddToPlaylist = func() { + t.OnAddToPlaylist(t.SelectedTrackIDs()) + } + t.ctxMenu.OnDownload = func() { t.onDownload(t.selectedTracks(), "Selected tracks") - }) - download.Icon = theme.DownloadIcon() - t.infoMenuItem = fyne.NewMenuItem(lang.L("Show info")+"...", func() { - if t.OnShowTrackInfo != nil { - t.OnShowTrackInfo(t.selectedTracks()[0]) - } - }) - t.infoMenuItem.Icon = theme.InfoIcon() - favorite := fyne.NewMenuItem(lang.L("Set favorite"), func() { - t.onSetFavorites(t.selectedTracks(), true, true) - }) - favorite.Icon = myTheme.FavoriteIcon - unfavorite := fyne.NewMenuItem(lang.L("Unset favorite"), func() { - t.onSetFavorites(t.selectedTracks(), false, true) - }) - unfavorite.Icon = myTheme.NotFavoriteIcon - t.ctxMenu.Items = append(t.ctxMenu.Items, fyne.NewMenuItemSeparator(), - playlist, download, t.infoMenuItem) - t.shareMenuItem = fyne.NewMenuItem(lang.L("Share")+"...", func() { + } + t.ctxMenu.OnShowInfo = func() { + t.OnShowTrackInfo(t.selectedTracks()[0]) + } + t.ctxMenu.OnFavorite = func(fav bool) { + t.onSetFavorites(t.selectedTracks(), fav, true /*needRefresh*/) + } + t.ctxMenu.OnShare = func() { t.onShare(t.selectedTracks()) - }) - t.shareMenuItem.Icon = myTheme.ShareIcon - t.ctxMenu.Items = append(t.ctxMenu.Items, t.shareMenuItem) - t.ctxMenu.Items = append(t.ctxMenu.Items, fyne.NewMenuItemSeparator()) - t.ctxMenu.Items = append(t.ctxMenu.Items, favorite, unfavorite) - t.ratingSubmenu = util.NewRatingSubmenu(func(rating int) { - t.onSetRatings(t.selectedTracks(), rating, true) - }) - t.ctxMenu.Items = append(t.ctxMenu.Items, t.ratingSubmenu) - if len(t.Options.AuxiliaryMenuItems) > 0 { - t.ctxMenu.Items = append(t.ctxMenu.Items, fyne.NewMenuItemSeparator()) - t.ctxMenu.Items = append(t.ctxMenu.Items, t.Options.AuxiliaryMenuItems...) + } + t.ctxMenu.OnSetRating = func(rating int) { + t.onSetRatings(t.selectedTracks(), rating, true /*needRefresh*/) } } - t.ratingSubmenu.Disabled = t.Options.DisableRating - t.shareMenuItem.Disabled = t.Options.DisableSharing || len(t.selectedTracks()) != 1 - t.infoMenuItem.Disabled = len(t.selectedTracks()) != 1 - widget.ShowPopUpMenuAtPosition(t.ctxMenu, fyne.CurrentApp().Driver().CanvasForObject(t), e.AbsolutePosition) + t.ctxMenu.SetRatingDisabled(t.Options.DisableRating) + t.ctxMenu.SetShareDisabled(t.Options.DisableSharing || len(t.selectedTracks()) != 1) + t.ctxMenu.SetInfoDisabled(len(t.selectedTracks()) != 1) + t.ctxMenu.ShowAtPosition(e.AbsolutePosition, fyne.CurrentApp().Driver().CanvasForObject(t)) } func (t *Tracklist) onSetFavorite(trackID string, fav bool) { From 9ddb5e6e69b79e5773d9bf9b45952514a320bf7b Mon Sep 17 00:00:00 2001 From: Drew Weymouth Date: Wed, 26 Mar 2025 17:06:56 -0700 Subject: [PATCH 09/45] reuse context menu across both types of tracklist --- ui/browsing/nowplayingpage.go | 12 +++ ui/controller/connectactions.go | 12 +++ ui/widgets/playqueuelist.go | 149 +++++++++----------------------- 3 files changed, 67 insertions(+), 106 deletions(-) diff --git a/ui/browsing/nowplayingpage.go b/ui/browsing/nowplayingpage.go index 842f926..fd331f0 100644 --- a/ui/browsing/nowplayingpage.go +++ b/ui/browsing/nowplayingpage.go @@ -166,6 +166,18 @@ func NewNowPlayingPage( a.relatedList.OnPlaySelectionNext = func(items []mediaprovider.MediaItem) { a.pm.LoadItems(items, backend.InsertNext, false) } + a.relatedList.OnPlaySongRadio = func(track *mediaprovider.Track) { + go func() { + if err := a.pm.PlaySimilarSongs(track.ID); err != nil { + fyne.Do(func() { + a.contr.ToastProvider.ShowErrorToast(lang.L("Unable to play song radio")) + }) + } + }() + } + a.relatedList.OnShowTrackInfo = func(track *mediaprovider.Track) { + a.contr.ShowTrackInfoDialog(track) + } a.lyricsViewer = widgets.NewLyricsViewer() a.statusLabel = widget.NewLabel(lang.L("Stopped")) diff --git a/ui/controller/connectactions.go b/ui/controller/connectactions.go index e6d08d7..fb284c4 100644 --- a/ui/controller/connectactions.go +++ b/ui/controller/connectactions.go @@ -9,6 +9,8 @@ import ( "github.com/dweymouth/supersonic/sharedutil" "github.com/dweymouth/supersonic/ui/widgets" + "fyne.io/fyne/v2" + "fyne.io/fyne/v2/lang" "fyne.io/fyne/v2/widget" ) @@ -179,4 +181,14 @@ func (c *Controller) ConnectPlayQueuelistActions(list *widgets.PlayQueueList) { } list.OnSetRating = c.SetTrackRatings list.OnSetFavorite = c.SetTrackFavorites + list.OnPlaySongRadio = func(track *mediaprovider.Track) { + go func() { + if err := c.App.PlaybackManager.PlaySimilarSongs(track.ID); err != nil { + fyne.Do(func() { + c.ToastProvider.ShowErrorToast(lang.L("Unable to play song radio")) + }) + } + }() + } + list.OnShowTrackInfo = c.ShowTrackInfoDialog } diff --git a/ui/widgets/playqueuelist.go b/ui/widgets/playqueuelist.go index 32a3f9e..f8532b2 100644 --- a/ui/widgets/playqueuelist.go +++ b/ui/widgets/playqueuelist.go @@ -42,6 +42,7 @@ type PlayQueueList struct { OnPlayItemAt func(idx int) OnPlaySelection func(items []mediaprovider.MediaItem, shuffle bool) OnPlaySelectionNext func(items []mediaprovider.MediaItem) + OnPlaySongRadio func(track *mediaprovider.Track) OnAddToQueue func(items []mediaprovider.MediaItem) OnAddToPlaylist func(trackIDs []string) OnSetFavorite func(trackIDs []string, fav bool) @@ -54,11 +55,8 @@ type PlayQueueList struct { OnReorderItems func(idxs []int, reorderTo int) useNonQueueMenu bool - menu *widget.PopUpMenu // ctx menu for when only tracks are selected - radiosMenu *widget.PopUpMenu // ctx menu for when selection contains radios - ratingSubmenu *fyne.MenuItem - infoMenuItem *fyne.MenuItem - shareMenuItem *fyne.MenuItem + menu *util.TrackContextMenu // ctx menu for when only tracks are selected + radiosMenu *widget.PopUpMenu // ctx menu for when selection contains radios nowPlayingID string @@ -256,82 +254,24 @@ func (p *PlayQueueList) onShowContextMenu(e *fyne.PointEvent, trackIdx int) { } } - var menu *widget.PopUpMenu if allTracks { p.ensureTracksMenu() - p.ratingSubmenu.Disabled = p.DisableRating - p.infoMenuItem.Disabled = len(selected) != 1 - p.shareMenuItem.Disabled = p.DisableSharing || len(selected) != 1 - menu = p.menu + p.menu.SetRatingDisabled(p.DisableRating) + p.menu.SetInfoDisabled(len(selected) != 1) + p.menu.SetShareDisabled(p.DisableSharing || len(selected) != 1) + p.menu.ShowAtPosition(e.AbsolutePosition, fyne.CurrentApp().Driver().CanvasForObject(p)) } else { p.ensureRadiosMenu() - menu = p.radiosMenu + p.radiosMenu.ShowAtPosition(e.AbsolutePosition) } - menu.ShowAtPosition(e.AbsolutePosition) + } func (p *PlayQueueList) ensureTracksMenu() { if p.menu != nil { return } - var menuItems []*fyne.MenuItem - if p.useNonQueueMenu { - menuItems = append(menuItems, p.createQueueActionMenuItems()...) - menuItems = append(menuItems, fyne.NewMenuItemSeparator()) - } - - playlist := fyne.NewMenuItem(lang.L("Add to playlist")+"...", func() { - if p.OnAddToPlaylist != nil { - p.OnAddToPlaylist(p.selectedItemIDs()) - } - }) - playlist.Icon = myTheme.PlaylistIcon - download := fyne.NewMenuItem(lang.L("Download")+"...", func() { - if p.OnDownload != nil { - p.OnDownload(p.selectedTracks(), "Selected tracks") - } - }) - download.Icon = theme.DownloadIcon() - p.infoMenuItem = fyne.NewMenuItem(lang.L("Show info")+"...", func() { - if p.OnShowTrackInfo != nil { - p.OnShowTrackInfo(p.selectedTracks()[0]) - } - }) - p.infoMenuItem.Icon = theme.InfoIcon() - p.shareMenuItem = fyne.NewMenuItem(lang.L("Share")+"...", func() { - if p.OnShare != nil { - p.OnShare(p.selectedTracks()) - } - }) - p.shareMenuItem.Icon = myTheme.ShareIcon - favorite := fyne.NewMenuItem(lang.L("Set favorite"), func() { - if p.OnSetFavorite != nil { - p.OnSetFavorite(p.selectedItemIDs(), true) - } - }) - favorite.Icon = myTheme.FavoriteIcon - unfavorite := fyne.NewMenuItem(lang.L("Unset favorite"), func() { - if p.OnSetFavorite != nil { - p.OnSetFavorite(p.selectedItemIDs(), false) - } - }) - unfavorite.Icon = myTheme.NotFavoriteIcon - p.ratingSubmenu = util.NewRatingSubmenu(func(rating int) { - if p.OnSetRating != nil { - p.OnSetRating(p.selectedItemIDs(), rating) - } - }) - menuItems = append(menuItems, - playlist, - download, - p.infoMenuItem, - p.shareMenuItem, - fyne.NewMenuItemSeparator(), - favorite, - unfavorite, - p.ratingSubmenu, - ) - + var auxItems []*fyne.MenuItem if !p.useNonQueueMenu { remove := fyne.NewMenuItem(lang.L("Remove from queue"), func() { if p.OnRemoveFromQueue != nil { @@ -339,15 +279,40 @@ func (p *PlayQueueList) ensureTracksMenu() { } }) remove.Icon = theme.ContentRemoveIcon() - menuItems = append(menuItems, - fyne.NewMenuItemSeparator(), - remove) + auxItems = append(auxItems, remove) + } + p.menu = util.NewTrackContextMenu(!p.useNonQueueMenu, auxItems) + p.menu.OnPlay = func(shuffle bool) { + p.OnPlaySelection(p.selectedItems(), shuffle) + } + p.menu.OnAddToQueue = func(next bool) { + if next { + p.OnPlaySelectionNext(p.selectedItems()) + } else { + p.OnAddToQueue(p.selectedItems()) + } + } + p.menu.OnPlaySongRadio = func() { + p.OnPlaySongRadio(p.selectedTracks()[0]) + } + p.menu.OnDownload = func() { + p.OnDownload(p.selectedTracks(), "Selected tracks") + } + p.menu.OnAddToPlaylist = func() { + p.OnAddToPlaylist(p.selectedItemIDs()) + } + p.menu.OnShowInfo = func() { + p.OnShowTrackInfo(p.selectedTracks()[0]) + } + p.menu.OnShare = func() { + p.OnShare(p.selectedTracks()) + } + p.menu.OnSetRating = func(rating int) { + p.OnSetRating(p.selectedItemIDs(), rating) + } + p.menu.OnFavorite = func(fav bool) { + p.OnSetFavorite(p.selectedItemIDs(), fav) } - - p.menu = widget.NewPopUpMenu( - fyne.NewMenu("", menuItems...), - fyne.CurrentApp().Driver().CanvasForObject(p), - ) } func (p *PlayQueueList) ensureRadiosMenu() { @@ -366,34 +331,6 @@ func (p *PlayQueueList) ensureRadiosMenu() { ) } -func (p *PlayQueueList) createQueueActionMenuItems() []*fyne.MenuItem { - play := fyne.NewMenuItem(lang.L("Play"), func() { - if p.OnPlaySelection != nil { - p.OnPlaySelection(p.selectedItems(), false) - } - }) - play.Icon = theme.MediaPlayIcon() - shuffle := fyne.NewMenuItem(lang.L("Shuffle"), func() { - if p.OnPlaySelection != nil { - p.OnPlaySelection(p.selectedItems(), true) - } - }) - shuffle.Icon = myTheme.ShuffleIcon - playNext := fyne.NewMenuItem(lang.L("Play next"), func() { - if p.OnPlaySelection != nil { - p.OnPlaySelectionNext(p.selectedItems()) - } - }) - playNext.Icon = myTheme.PlayNextIcon - add := fyne.NewMenuItem(lang.L("Add to queue"), func() { - if p.OnPlaySelection != nil { - p.OnAddToQueue(p.selectedItems()) - } - }) - add.Icon = theme.ContentAddIcon() - return []*fyne.MenuItem{play, shuffle, playNext, add} -} - func (t *PlayQueueList) selectedItems() []mediaprovider.MediaItem { t.tracksMutex.RLock() defer t.tracksMutex.RUnlock() From 31c23490d1fc1caee1a7f2a2504aec8f02c06f50 Mon Sep 17 00:00:00 2001 From: Drew Weymouth Date: Sat, 29 Mar 2025 13:25:56 -0700 Subject: [PATCH 10/45] add context menu actions to search dialog, refactor controller --- backend/mediaprovider/jellyfin/searchall.go | 20 +- backend/mediaprovider/model.go | 4 + backend/mediaprovider/subsonic/searchall.go | 5 + ui/controller/controller.go | 409 -------------------- ui/controller/playlist.go | 147 +++++++ ui/controller/searchdialog.go | 178 +++++++++ ui/controller/serverconnection.go | 250 ++++++++++++ ui/dialogs/quicksearch.go | 90 ++++- ui/dialogs/searchdialog.go | 13 +- 9 files changed, 693 insertions(+), 423 deletions(-) create mode 100644 ui/controller/playlist.go create mode 100644 ui/controller/searchdialog.go create mode 100644 ui/controller/serverconnection.go diff --git a/backend/mediaprovider/jellyfin/searchall.go b/backend/mediaprovider/jellyfin/searchall.go index 09908d2..1494818 100644 --- a/backend/mediaprovider/jellyfin/searchall.go +++ b/backend/mediaprovider/jellyfin/searchall.go @@ -11,7 +11,7 @@ import ( "github.com/dweymouth/supersonic/sharedutil" ) -func (s *jellyfinMediaProvider) SearchAll(searchQuery string, maxResults int) ([]*mediaprovider.SearchResult, error) { +func (j *jellyfinMediaProvider) SearchAll(searchQuery string, maxResults int) ([]*mediaprovider.SearchResult, error) { limit := maxResults / 3 var wg sync.WaitGroup var albums []*jellyfin.Album @@ -22,19 +22,19 @@ func (s *jellyfinMediaProvider) SearchAll(searchQuery string, maxResults int) ([ wg.Add(1) go func() { - albumResult, _ := s.client.Search(searchQuery, jellyfin.TypeAlbum, jellyfin.Paging{Limit: limit}) + albumResult, _ := j.client.Search(searchQuery, jellyfin.TypeAlbum, jellyfin.Paging{Limit: limit}) albums = albumResult.Albums wg.Done() }() wg.Add(1) go func() { - artistResult, _ := s.client.Search(searchQuery, jellyfin.TypeArtist, jellyfin.Paging{Limit: limit}) + artistResult, _ := j.client.Search(searchQuery, jellyfin.TypeArtist, jellyfin.Paging{Limit: limit}) artists = artistResult.Artists wg.Done() }() wg.Add(1) go func() { - songResult, _ := s.client.Search(searchQuery, jellyfin.TypeSong, jellyfin.Paging{Limit: limit}) + songResult, _ := j.client.Search(searchQuery, jellyfin.TypeSong, jellyfin.Paging{Limit: limit}) songs = songResult.Songs wg.Done() }() @@ -44,7 +44,7 @@ func (s *jellyfinMediaProvider) SearchAll(searchQuery string, maxResults int) ([ wg.Add(1) go func() { - p, e := s.client.GetPlaylists() + p, e := j.client.GetPlaylists() if e == nil { playlists = sharedutil.FilterSlice(p, func(p *jellyfin.Playlist) bool { return helpers.AllTermsMatch(strings.ToLower(sanitize.Accents(p.Name)), queryLowerWords) @@ -55,7 +55,7 @@ func (s *jellyfinMediaProvider) SearchAll(searchQuery string, maxResults int) ([ wg.Add(1) go func() { - g, e := s.client.GetGenres(jellyfin.Paging{}) + g, e := j.client.GetGenres(jellyfin.Paging{}) if e == nil { genres = sharedutil.FilterSlice(g, func(g jellyfin.NameID) bool { return helpers.AllTermsMatch(strings.ToLower(sanitize.Accents(g.Name)), queryLowerWords) @@ -66,13 +66,13 @@ func (s *jellyfinMediaProvider) SearchAll(searchQuery string, maxResults int) ([ wg.Wait() - results := mergeResults(albums, artists, songs, playlists, genres) + results := j.mergeResults(albums, artists, songs, playlists, genres) helpers.RankSearchResults(results, searchQuery, queryLowerWords) return results, nil } -func mergeResults( +func (j *jellyfinMediaProvider) mergeResults( albums []*jellyfin.Album, artists []*jellyfin.Artist, songs []*jellyfin.Song, @@ -93,6 +93,7 @@ func mergeResults( Name: al.Name, ArtistName: strings.Join(sharedutil.MapSlice(al.Artists, getArtistNames), ","), Size: al.ChildCount, + Item: toAlbum(al), }) } @@ -103,6 +104,7 @@ func mergeResults( CoverID: ar.ID, Name: ar.Name, Size: ar.AlbumCount, + Item: toArtist(ar), }) } @@ -114,6 +116,7 @@ func mergeResults( Name: tr.Name, ArtistName: strings.Join(sharedutil.MapSlice(tr.Artists, getArtistNames), ","), Size: int(tr.RunTimeTicks / 10_000_000), + Item: toTrack(tr), }) } @@ -124,6 +127,7 @@ func mergeResults( CoverID: pl.ID, Name: pl.Name, Size: pl.SongCount, + Item: j.toPlaylist(pl), }) } diff --git a/backend/mediaprovider/model.go b/backend/mediaprovider/model.go index 4c65c80..a39477d 100644 --- a/backend/mediaprovider/model.go +++ b/backend/mediaprovider/model.go @@ -279,4 +279,8 @@ type SearchResult struct { // Unset for ContentTypes Artist, Playlist, Genre, and RadioStation ArtistName string + + // The actual item corresponding to this search result + // *mediaprovider.Artist for ContentTypeArtist, etc + Item any } diff --git a/backend/mediaprovider/subsonic/searchall.go b/backend/mediaprovider/subsonic/searchall.go index ffb099f..e7f7d78 100644 --- a/backend/mediaprovider/subsonic/searchall.go +++ b/backend/mediaprovider/subsonic/searchall.go @@ -101,6 +101,7 @@ func mergeResults( Name: al.Name, ArtistName: getNameString(al.Artist, al.Artists), Size: al.SongCount, + Item: toAlbum(al), }) } @@ -111,6 +112,7 @@ func mergeResults( CoverID: ar.CoverArt, Name: ar.Name, Size: ar.AlbumCount, + Item: toArtistFromID3(ar), }) } @@ -122,6 +124,7 @@ func mergeResults( Name: tr.Title, ArtistName: getNameString(tr.Artist, tr.Artists), Size: tr.Duration, + Item: toTrack(tr), }) } @@ -132,6 +135,7 @@ func mergeResults( CoverID: pl.CoverArt, Name: pl.Name, Size: pl.SongCount, + Item: toPlaylist(pl), }) } @@ -149,6 +153,7 @@ func mergeResults( Type: mediaprovider.ContentTypeRadioStation, ID: r.ID, Name: r.Name, + Item: r, }) } diff --git a/ui/controller/controller.go b/ui/controller/controller.go index b6ab0b2..246f61b 100644 --- a/ui/controller/controller.go +++ b/ui/controller/controller.go @@ -2,7 +2,6 @@ package controller import ( "archive/zip" - "context" "fmt" "image" "image/color" @@ -11,7 +10,6 @@ import ( "net/url" "os" "path/filepath" - "strconv" "sync" "time" @@ -30,7 +28,6 @@ import ( "fyne.io/fyne/v2/container" "fyne.io/fyne/v2/dialog" "fyne.io/fyne/v2/lang" - "fyne.io/fyne/v2/layout" "fyne.io/fyne/v2/theme" "fyne.io/fyne/v2/widget" ) @@ -260,327 +257,6 @@ func (m *Controller) GetArtistTracks(artistID string) []*mediaprovider.Track { return nil } -func (m *Controller) PromptForFirstServer() { - d := dialogs.NewAddEditServerDialog(lang.L("Connect to Server"), false, nil, m.MainWindow.Canvas().Focus) - pop := widget.NewModalPopUp(d, m.MainWindow.Canvas()) - d.OnSubmit = func() { - d.DisableSubmit() - go func() { - if m.testConnectionAndUpdateDialogText(d) { - // connection is good - pop.Hide() - m.doModalClosed() - conn := backend.ServerConnection{ - ServerType: d.ServerType, - Hostname: d.Host, - AltHostname: d.AltHost, - Username: d.Username, - LegacyAuth: d.LegacyAuth, - } - server := m.App.ServerManager.AddServer(d.Nickname, conn) - if err := m.trySetPasswordAndConnectToServer(server, d.Password); err != nil { - log.Printf("error connecting to server: %s", err.Error()) - } - } - d.EnableSubmit() - }() - } - m.haveModal = true - pop.Show() -} - -// Show dialog to select playlist. -// Depending on the results of that dialog, potentially create a new playlist -// Add tracks to the user-specified playlist -func (m *Controller) DoAddTracksToPlaylistWorkflow(trackIDs []string) { - sp := dialogs.NewSelectPlaylistDialog(m.App.ServerManager.Server, m.App.ImageManager, - m.App.ServerManager.LoggedInUser, m.App.Config.Application.AddToPlaylistSkipDuplicates) - pop := widget.NewModalPopUp(sp.SearchDialog, m.MainWindow.Canvas()) - sp.SetOnDismiss(func() { - pop.Hide() - m.doModalClosed() - }) - sp.SetOnNavigateTo(func(contentType mediaprovider.ContentType, id string) { - notifySuccess := func(n int) { - fyne.Do(func() { - msg := lang.LocalizePluralKey("playlist.addedtracks", - "Added tracks to playlist", n, map[string]string{"trackCount": strconv.Itoa(n)}) - m.ToastProvider.ShowSuccessToast(msg) - }) - } - notifyError := func() { - fyne.Do(func() { - m.ToastProvider.ShowErrorToast( - lang.L("An error occurred adding tracks to the playlist"), - ) - }) - } - pop.Hide() - m.App.Config.Application.AddToPlaylistSkipDuplicates = sp.SkipDuplicates - if id == "" /* creating new playlist */ { - go func() { - err := m.App.ServerManager.Server.CreatePlaylist(sp.SearchDialog.SearchQuery(), trackIDs) - if err == nil { - notifySuccess(len(trackIDs)) - } else { - log.Printf("error adding tracks to playlist: %s", err.Error()) - notifyError() - } - }() - } else { - m.App.Config.Application.DefaultPlaylistID = id - if sp.SkipDuplicates { - go func() { - currentTrackIDs := make(map[string]struct{}) - if selectedPlaylist, err := m.App.ServerManager.Server.GetPlaylist(id); err != nil { - log.Printf("error getting playlist: %s", err.Error()) - notifyError() - } else { - for _, track := range selectedPlaylist.Tracks { - currentTrackIDs[track.ID] = struct{}{} - } - filterTrackIDs := sharedutil.FilterSlice(trackIDs, func(trackID string) bool { - _, ok := currentTrackIDs[trackID] - return !ok - }) - err := m.App.ServerManager.Server.AddPlaylistTracks(id, filterTrackIDs) - if err == nil { - notifySuccess(len(filterTrackIDs)) - } else { - log.Printf("error adding tracks to playlist: %s", err.Error()) - notifyError() - } - } - }() - } else { - go func() { - err := m.App.ServerManager.Server.AddPlaylistTracks(id, trackIDs) - if err == nil { - notifySuccess(len(trackIDs)) - } else { - log.Printf("error adding tracks to playlist: %s", err.Error()) - notifyError() - } - }() - } - } - - }) - m.ClosePopUpOnEscape(pop) - m.haveModal = true - min := sp.MinSize() - height := fyne.Max(min.Height, fyne.Min(min.Height*1.5, m.MainWindow.Canvas().Size().Height*0.7)) - sp.SearchDialog.Show() - pop.Resize(fyne.NewSize(min.Width, height)) - pop.Show() - m.MainWindow.Canvas().Focus(sp.GetSearchEntry()) -} - -func (m *Controller) DoEditPlaylistWorkflow(playlist *mediaprovider.Playlist) { - canMakePublic := m.App.ServerManager.Server.CanMakePublicPlaylist() - dlg := dialogs.NewEditPlaylistDialog(playlist, canMakePublic) - pop := widget.NewModalPopUp(dlg, m.MainWindow.Canvas()) - m.ClosePopUpOnEscape(pop) - dlg.OnCanceled = func() { - pop.Hide() - m.doModalClosed() - } - dlg.OnDeletePlaylist = func() { - pop.Hide() - dialog.ShowCustomConfirm(lang.L("Confirm Delete Playlist"), lang.L("OK"), lang.L("Cancel"), layout.NewSpacer(), /*custom content*/ - func(ok bool) { - if !ok { - pop.Show() - } else { - m.doModalClosed() - go func() { - if err := m.App.ServerManager.Server.DeletePlaylist(playlist.ID); err != nil { - log.Printf("error deleting playlist: %s", err.Error()) - } else if rte := m.CurPageFunc(); rte.Page == Playlist && rte.Arg == playlist.ID { - // navigate to playlists page if user is still on the page of the deleted playlist - fyne.Do(func() { m.NavigateTo(PlaylistsRoute()) }) - } - }() - } - }, m.MainWindow) - } - dlg.OnUpdateMetadata = func() { - pop.Hide() - m.doModalClosed() - go func() { - err := m.App.ServerManager.Server.EditPlaylist(playlist.ID, dlg.Name, dlg.Description, dlg.IsPublic) - if err != nil { - log.Printf("error updating playlist: %s", err.Error()) - } else if rte := m.CurPageFunc(); rte.Page == Playlist && rte.Arg == playlist.ID { - // if user is on playlist page, reload to get the updates - fyne.Do(m.ReloadFunc) - } - }() - } - m.haveModal = true - pop.Show() -} - -// DoConnectToServerWorkflow does the workflow for connecting to the last active server on startup -func (c *Controller) DoConnectToServerWorkflow(server *backend.ServerConfig) { - pass, err := c.App.ServerManager.GetServerPassword(server.ID) - if err != nil { - log.Printf("error getting password from keyring: %v", err) - c.PromptForLoginAndConnect() - return - } - - // try connecting to last used server - set up cancelable modal dialog - canceled := false - ctx, cancel := context.WithCancel(context.Background()) - dlg := dialog.NewCustom(lang.L("Connecting"), lang.L("Cancel"), - widget.NewLabel(fmt.Sprintf(lang.L("Connecting to")+" %s", server.Nickname)), c.MainWindow) - dlg.SetOnClosed(func() { - canceled = true - cancel() - }) - c.haveModal = true - dlg.Show() - - // try to connect - go func() { - defer cancel() // make sure to free up ctx resources if user does not cancel - - if err := c.tryConnectToServer(ctx, server, pass); err != nil { - fyne.Do(func() { - dlg.Hide() - c.haveModal = false - if canceled { - c.PromptForLoginAndConnect() - } else { - // connection failure - dlg := dialog.NewError(err, c.MainWindow) - dlg.SetOnClosed(func() { - c.PromptForLoginAndConnect() - }) - c.haveModal = true - dlg.Show() - } - }) - } else { - fyne.Do(func() { - dlg.Hide() - c.haveModal = false - }) - } - }() -} - -func (m *Controller) PromptForLoginAndConnect() { - d := dialogs.NewLoginDialog(m.App.Config.Servers, m.App.ServerManager.GetServerPassword) - pop := widget.NewModalPopUp(d, m.MainWindow.Canvas()) - d.OnSubmit = func(server *backend.ServerConfig, password string) { - d.DisableSubmit() - d.SetInfoText(lang.L("Testing connection") + "...") - go func() { - ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) - defer cancel() - - err := m.App.ServerManager.TestConnectionAndAuth(ctx, server.ServerConnection, password) - fyne.Do(func() { - if err == backend.ErrUnreachable { - d.SetErrorText(lang.L("Server unreachable")) - } else if err != nil { - d.SetErrorText(lang.L("Authentication failed")) - } else { - pop.Hide() - m.trySetPasswordAndConnectToServer(server, password) - m.doModalClosed() - } - d.EnableSubmit() - }) - }() - } - d.OnEditServer = func(server *backend.ServerConfig) { - pop.Hide() - editD := dialogs.NewAddEditServerDialog(lang.L("Edit server"), true, server, m.MainWindow.Canvas().Focus) - editPop := widget.NewModalPopUp(editD, m.MainWindow.Canvas()) - editD.OnSubmit = func() { - d.DisableSubmit() - go func() { - success := m.testConnectionAndUpdateDialogText(editD) - fyne.Do(func() { - if success { - // connection is good - editPop.Hide() - server.Hostname = editD.Host - server.AltHostname = editD.AltHost - server.Nickname = editD.Nickname - server.Username = editD.Username - server.LegacyAuth = editD.LegacyAuth - m.trySetPasswordAndConnectToServer(server, editD.Password) - m.doModalClosed() - } - d.EnableSubmit() - }) - }() - } - editD.OnCancel = func() { - editPop.Hide() - pop.Show() - } - editPop.Show() - } - d.OnNewServer = func() { - pop.Hide() - newD := dialogs.NewAddEditServerDialog(lang.L("Add Server"), true, nil, m.MainWindow.Canvas().Focus) - newPop := widget.NewModalPopUp(newD, m.MainWindow.Canvas()) - newD.OnSubmit = func() { - d.DisableSubmit() - go func() { - success := m.testConnectionAndUpdateDialogText(newD) - fyne.Do(func() { - if success { - // connection is good - newPop.Hide() - conn := backend.ServerConnection{ - ServerType: newD.ServerType, - Hostname: newD.Host, - AltHostname: newD.AltHost, - Username: newD.Username, - LegacyAuth: newD.LegacyAuth, - } - server := m.App.ServerManager.AddServer(newD.Nickname, conn) - m.trySetPasswordAndConnectToServer(server, newD.Password) - m.doModalClosed() - } - d.EnableSubmit() - }) - }() - } - newD.OnCancel = func() { - newPop.Hide() - pop.Show() - } - newPop.Show() - } - d.OnDeleteServer = func(server *backend.ServerConfig) { - pop.Hide() - dialog.ShowConfirm(lang.L("Confirm Delete Server"), - fmt.Sprintf(lang.L("Are you sure you want to delete the server")+" %q?", server.Nickname), - func(ok bool) { - if ok { - m.App.ServerManager.DeleteServer(server.ID) - m.App.DeleteServerCacheDir(server.ID) - d.SetServers(m.App.Config.Servers) - } - if len(m.App.Config.Servers) == 0 { - m.PromptForFirstServer() - } else { - pop.Show() - } - }, m.MainWindow) - - } - m.haveModal = true - pop.Show() -} - func (c *Controller) ShowAboutDialog() { dlg := dialogs.NewAboutDialog(c.AppVersion) pop := widget.NewModalPopUp(dlg, c.MainWindow.Canvas()) @@ -643,91 +319,6 @@ func (c *Controller) ShowSettingsDialog(themeUpdateCallbk func(), themeFiles map pop.Show() } -func (c *Controller) ShowQuickSearch() { - qs := dialogs.NewQuickSearch(c.App.ServerManager.Server, c.App.ImageManager) - pop := widget.NewModalPopUp(qs.SearchDialog, c.MainWindow.Canvas()) - qs.SetOnDismiss(func() { - pop.Hide() - c.doModalClosed() - }) - qs.SetOnNavigateTo(func(contentType mediaprovider.ContentType, id string) { - pop.Hide() - c.doModalClosed() - switch contentType { - case mediaprovider.ContentTypeAlbum: - c.NavigateTo(AlbumRoute(id)) - case mediaprovider.ContentTypeArtist: - c.NavigateTo(ArtistRoute(id)) - case mediaprovider.ContentTypeTrack: - go c.App.PlaybackManager.PlayTrack(id) - case mediaprovider.ContentTypePlaylist: - c.NavigateTo(PlaylistRoute(id)) - case mediaprovider.ContentTypeGenre: - c.NavigateTo(GenreRoute(id)) - case mediaprovider.ContentTypeRadioStation: - if rp, ok := c.App.ServerManager.Server.(mediaprovider.RadioProvider); ok { - if radio, err := rp.GetRadioStation(id); err == nil { - go c.App.PlaybackManager.PlayRadioStation(radio) - } - } - } - }) - c.ClosePopUpOnEscape(pop) - c.haveModal = true - min := qs.MinSize() - height := fyne.Max(min.Height, fyne.Min(min.Height*1.5, c.MainWindow.Canvas().Size().Height*0.7)) - qs.SearchDialog.Show() - pop.Resize(fyne.NewSize(min.Width, height)) - pop.Show() - c.MainWindow.Canvas().Focus(qs.GetSearchEntry()) -} - -func (c *Controller) trySetPasswordAndConnectToServer(server *backend.ServerConfig, password string) error { - if err := c.App.ServerManager.SetServerPassword(server, password); err != nil { - log.Printf("error setting keyring credentials: %v", err) - // Don't return an error; fall back to just using the password in-memory - // User will need to log in with the password on subsequent runs. - } - return c.tryConnectToServer(context.Background(), server, password) -} - -// try to connect to the given server, with the configured timeout added to the context -func (c *Controller) tryConnectToServer(ctx context.Context, server *backend.ServerConfig, password string) error { - timeout := time.Duration(c.App.Config.Application.RequestTimeoutSeconds) * time.Second - ctx, cancel := context.WithTimeout(ctx, timeout) - defer cancel() - if err := c.App.ServerManager.TestConnectionAndAuth(ctx, server.ServerConnection, password); err != nil { - return err - } - if err := c.App.ServerManager.ConnectToServer(server, password); err != nil { - log.Printf("error connecting to server: %v", err) - return err - } - return nil -} - -func (c *Controller) testConnectionAndUpdateDialogText(dlg *dialogs.AddEditServerDialog) bool { - dlg.SetInfoText(lang.L("Testing connection") + "...") - conn := backend.ServerConnection{ - ServerType: dlg.ServerType, - Hostname: dlg.Host, - AltHostname: dlg.AltHost, - Username: dlg.Username, - LegacyAuth: dlg.LegacyAuth, - } - ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) - defer cancel() - err := c.App.ServerManager.TestConnectionAndAuth(ctx, conn, dlg.Password) - if err == backend.ErrUnreachable { - dlg.SetErrorText(lang.L("Could not reach server") + fmt.Sprintf(" (%s?)", lang.L("wrong URL"))) - return false - } else if err != nil { - dlg.SetErrorText(lang.L("Authentication failed") + fmt.Sprintf(" (%s)", lang.L("wrong username/password"))) - return false - } - return true -} - func (c *Controller) doModalClosed() { c.haveModal = false if c.runOnModalClosed != nil { diff --git a/ui/controller/playlist.go b/ui/controller/playlist.go new file mode 100644 index 0000000..1d666f2 --- /dev/null +++ b/ui/controller/playlist.go @@ -0,0 +1,147 @@ +package controller + +import ( + "log" + "strconv" + + "fyne.io/fyne/v2" + "fyne.io/fyne/v2/dialog" + "fyne.io/fyne/v2/lang" + "fyne.io/fyne/v2/layout" + "fyne.io/fyne/v2/widget" + "github.com/dweymouth/supersonic/backend/mediaprovider" + "github.com/dweymouth/supersonic/sharedutil" + "github.com/dweymouth/supersonic/ui/dialogs" +) + +// Show dialog to select playlist. +// Depending on the results of that dialog, potentially create a new playlist +// Add tracks to the user-specified playlist +func (m *Controller) DoAddTracksToPlaylistWorkflow(trackIDs []string) { + sp := dialogs.NewSelectPlaylistDialog(m.App.ServerManager.Server, m.App.ImageManager, + m.App.ServerManager.LoggedInUser, m.App.Config.Application.AddToPlaylistSkipDuplicates) + pop := widget.NewModalPopUp(sp.SearchDialog, m.MainWindow.Canvas()) + sp.SetOnDismiss(func() { + pop.Hide() + m.doModalClosed() + }) + sp.SetOnNavigateTo(func(contentType mediaprovider.ContentType, id string) { + notifySuccess := func(n int) { + fyne.Do(func() { + msg := lang.LocalizePluralKey("playlist.addedtracks", + "Added tracks to playlist", n, map[string]string{"trackCount": strconv.Itoa(n)}) + m.ToastProvider.ShowSuccessToast(msg) + }) + } + notifyError := func() { + fyne.Do(func() { + m.ToastProvider.ShowErrorToast( + lang.L("An error occurred adding tracks to the playlist"), + ) + }) + } + pop.Hide() + m.App.Config.Application.AddToPlaylistSkipDuplicates = sp.SkipDuplicates + if id == "" /* creating new playlist */ { + go func() { + err := m.App.ServerManager.Server.CreatePlaylist(sp.SearchDialog.SearchQuery(), trackIDs) + if err == nil { + notifySuccess(len(trackIDs)) + } else { + log.Printf("error adding tracks to playlist: %s", err.Error()) + notifyError() + } + }() + } else { + m.App.Config.Application.DefaultPlaylistID = id + if sp.SkipDuplicates { + go func() { + currentTrackIDs := make(map[string]struct{}) + if selectedPlaylist, err := m.App.ServerManager.Server.GetPlaylist(id); err != nil { + log.Printf("error getting playlist: %s", err.Error()) + notifyError() + } else { + for _, track := range selectedPlaylist.Tracks { + currentTrackIDs[track.ID] = struct{}{} + } + filterTrackIDs := sharedutil.FilterSlice(trackIDs, func(trackID string) bool { + _, ok := currentTrackIDs[trackID] + return !ok + }) + err := m.App.ServerManager.Server.AddPlaylistTracks(id, filterTrackIDs) + if err == nil { + notifySuccess(len(filterTrackIDs)) + } else { + log.Printf("error adding tracks to playlist: %s", err.Error()) + notifyError() + } + } + }() + } else { + go func() { + err := m.App.ServerManager.Server.AddPlaylistTracks(id, trackIDs) + if err == nil { + notifySuccess(len(trackIDs)) + } else { + log.Printf("error adding tracks to playlist: %s", err.Error()) + notifyError() + } + }() + } + } + + }) + m.ClosePopUpOnEscape(pop) + m.haveModal = true + min := sp.MinSize() + height := fyne.Max(min.Height, fyne.Min(min.Height*1.5, m.MainWindow.Canvas().Size().Height*0.7)) + sp.SearchDialog.Show() + pop.Resize(fyne.NewSize(min.Width, height)) + pop.Show() + m.MainWindow.Canvas().Focus(sp.GetSearchEntry()) +} + +func (m *Controller) DoEditPlaylistWorkflow(playlist *mediaprovider.Playlist) { + canMakePublic := m.App.ServerManager.Server.CanMakePublicPlaylist() + dlg := dialogs.NewEditPlaylistDialog(playlist, canMakePublic) + pop := widget.NewModalPopUp(dlg, m.MainWindow.Canvas()) + m.ClosePopUpOnEscape(pop) + dlg.OnCanceled = func() { + pop.Hide() + m.doModalClosed() + } + dlg.OnDeletePlaylist = func() { + pop.Hide() + dialog.ShowCustomConfirm(lang.L("Confirm Delete Playlist"), lang.L("OK"), lang.L("Cancel"), layout.NewSpacer(), /*custom content*/ + func(ok bool) { + if !ok { + pop.Show() + } else { + m.doModalClosed() + go func() { + if err := m.App.ServerManager.Server.DeletePlaylist(playlist.ID); err != nil { + log.Printf("error deleting playlist: %s", err.Error()) + } else if rte := m.CurPageFunc(); rte.Page == Playlist && rte.Arg == playlist.ID { + // navigate to playlists page if user is still on the page of the deleted playlist + fyne.Do(func() { m.NavigateTo(PlaylistsRoute()) }) + } + }() + } + }, m.MainWindow) + } + dlg.OnUpdateMetadata = func() { + pop.Hide() + m.doModalClosed() + go func() { + err := m.App.ServerManager.Server.EditPlaylist(playlist.ID, dlg.Name, dlg.Description, dlg.IsPublic) + if err != nil { + log.Printf("error updating playlist: %s", err.Error()) + } else if rte := m.CurPageFunc(); rte.Page == Playlist && rte.Arg == playlist.ID { + // if user is on playlist page, reload to get the updates + fyne.Do(m.ReloadFunc) + } + }() + } + m.haveModal = true + pop.Show() +} diff --git a/ui/controller/searchdialog.go b/ui/controller/searchdialog.go new file mode 100644 index 0000000..b7c44d3 --- /dev/null +++ b/ui/controller/searchdialog.go @@ -0,0 +1,178 @@ +package controller + +import ( + "log" + + "fyne.io/fyne/v2" + "fyne.io/fyne/v2/widget" + "github.com/dweymouth/supersonic/backend" + "github.com/dweymouth/supersonic/backend/mediaprovider" + "github.com/dweymouth/supersonic/sharedutil" + "github.com/dweymouth/supersonic/ui/dialogs" +) + +func (c *Controller) ShowQuickSearch() { + qs := dialogs.NewQuickSearch(c.App.ServerManager.Server, c.App.ImageManager) + pop := widget.NewModalPopUp(qs.SearchDialog, c.MainWindow.Canvas()) + qs.SetOnDismiss(func() { + pop.Hide() + c.doModalClosed() + }) + + qs.SetOnNavigateTo(func(contentType mediaprovider.ContentType, id string) { + pop.Hide() + c.doModalClosed() + switch contentType { + case mediaprovider.ContentTypeAlbum: + c.NavigateTo(AlbumRoute(id)) + case mediaprovider.ContentTypeArtist: + c.NavigateTo(ArtistRoute(id)) + case mediaprovider.ContentTypeTrack: + go c.App.PlaybackManager.PlayTrack(id) + case mediaprovider.ContentTypePlaylist: + c.NavigateTo(PlaylistRoute(id)) + case mediaprovider.ContentTypeGenre: + c.NavigateTo(GenreRoute(id)) + case mediaprovider.ContentTypeRadioStation: + if rp, ok := c.App.ServerManager.Server.(mediaprovider.RadioProvider); ok { + if radio, err := rp.GetRadioStation(id); err == nil { + go c.App.PlaybackManager.PlayRadioStation(radio) + } + } + } + }) + qs.OnPlay = func(t mediaprovider.ContentType, id string, item any, shuffle bool) { + switch t { + case mediaprovider.ContentTypeTrack: + c.App.PlaybackManager.LoadTracks( + []*mediaprovider.Track{item.(*mediaprovider.Track)}, + backend.Replace, false /*shuffle*/) + c.App.PlaybackManager.PlayFromBeginning() + case mediaprovider.ContentTypeAlbum: + go c.App.PlaybackManager.PlayAlbum(id, 0, shuffle) + case mediaprovider.ContentTypeArtist: + go c.App.PlaybackManager.PlayArtistDiscography(id, shuffle) + case mediaprovider.ContentTypePlaylist: + go c.App.PlaybackManager.PlayPlaylist(id, 0, shuffle) + case mediaprovider.ContentTypeGenre: + go c.App.PlaybackManager.PlayRandomSongs(id /*genre name*/) + case mediaprovider.ContentTypeRadioStation: + go c.App.PlaybackManager.PlayRadioStation(item.(*mediaprovider.RadioStation)) + } + } + qs.OnAddToQueue = c.handleSearchDialogOnAddToQueue + qs.OnAddToPlaylist = c.handleSearchDialogOnAddToPlaylist + qs.OnDownload = func(track *mediaprovider.Track) { + c.ShowDownloadDialog([]*mediaprovider.Track{track}, track.Metadata().Name) + } + qs.OnPlaySongRadio = func(track *mediaprovider.Track) { + go func() { + tracks, err := c.GetSongRadioTracks(track) + if err != nil { + c.App.PlaybackManager.LoadTracks(tracks, backend.Replace, false) + c.App.PlaybackManager.PlayFromBeginning() + } + }() + } + qs.OnSetFavorite = func(trackID string, fav bool) { + go c.SetTrackFavorites([]string{trackID}, fav) + } + qs.OnSetRating = func(trackID string, rating int) { + go c.SetTrackRatings([]string{trackID}, rating) + } + qs.OnShare = func(trackID string) { + c.ShowShareDialog(trackID) + } + qs.OnShowTrackInfo = func(track *mediaprovider.Track) { + c.ShowTrackInfoDialog(track) + } + + c.ClosePopUpOnEscape(pop) + c.haveModal = true + min := qs.MinSize() + height := fyne.Max(min.Height, fyne.Min(min.Height*1.5, c.MainWindow.Canvas().Size().Height*0.7)) + qs.SearchDialog.Show() + pop.Resize(fyne.NewSize(min.Width, height)) + pop.Show() + c.MainWindow.Canvas().Focus(qs.GetSearchEntry()) +} + +func (c *Controller) handleSearchDialogOnAddToQueue(t mediaprovider.ContentType, id string, item any, next bool) { + insertMode := backend.Append + if next { + insertMode = backend.InsertNext + } + switch t { + case mediaprovider.ContentTypeTrack: + c.App.PlaybackManager.LoadTracks( + []*mediaprovider.Track{item.(*mediaprovider.Track)}, + insertMode, false /*shuffle*/) + case mediaprovider.ContentTypeAlbum: + go c.App.PlaybackManager.LoadAlbum(id, insertMode, false) + case mediaprovider.ContentTypeArtist: + go func() { + tracks := c.GetArtistTracks(id) + c.App.PlaybackManager.LoadTracks(tracks, insertMode, false) + }() + case mediaprovider.ContentTypePlaylist: + go c.App.PlaybackManager.LoadPlaylist(id, insertMode, false) + case mediaprovider.ContentTypeGenre: + go func() { + tr, err := c.App.ServerManager.Server.GetRandomTracks(id /*genre name*/, c.App.Config.Application.EnqueueBatchSize) + if err != nil { + c.App.PlaybackManager.LoadTracks(tr, insertMode, false /*shuffle*/) + } + }() + case mediaprovider.ContentTypeRadioStation: + c.App.PlaybackManager.LoadRadioStation(item.(*mediaprovider.RadioStation), insertMode) + } +} + +func (c *Controller) handleSearchDialogOnAddToPlaylist(t mediaprovider.ContentType, id string, item any) { + switch t { + case mediaprovider.ContentTypeTrack: + c.DoAddTracksToPlaylistWorkflow([]string{id}) + case mediaprovider.ContentTypeAlbum: + go func() { + album, err := c.App.ServerManager.Server.GetAlbum(id) + if err == nil && len(album.Tracks) > 0 { + trackIDs := sharedutil.MapSlice(album.Tracks, func(t *mediaprovider.Track) string { + return t.ID + }) + fyne.Do(func() { c.DoAddTracksToPlaylistWorkflow(trackIDs) }) + } + }() + case mediaprovider.ContentTypeArtist: + go func() { + tracks := c.GetArtistTracks(id) + if len(tracks) > 0 { + trackIDs := sharedutil.MapSlice(tracks, func(t *mediaprovider.Track) string { + return t.ID + }) + fyne.Do(func() { c.DoAddTracksToPlaylistWorkflow(trackIDs) }) + } + }() + case mediaprovider.ContentTypePlaylist: + go func() { + playlist, err := c.App.ServerManager.Server.GetPlaylist(id) + if err == nil && len(playlist.Tracks) > 0 { + trackIDs := sharedutil.MapSlice(playlist.Tracks, func(t *mediaprovider.Track) string { + return t.ID + }) + fyne.Do(func() { c.DoAddTracksToPlaylistWorkflow(trackIDs) }) + } + }() + case mediaprovider.ContentTypeGenre: + go func() { + tracks, err := c.App.ServerManager.Server.GetRandomTracks(id /*genre name*/, c.App.Config.Application.EnqueueBatchSize) + if err == nil && len(tracks) > 0 { + trackIDs := sharedutil.MapSlice(tracks, func(t *mediaprovider.Track) string { + return t.ID + }) + fyne.Do(func() { c.DoAddTracksToPlaylistWorkflow(trackIDs) }) + } + }() + case mediaprovider.ContentTypeRadioStation: + log.Println("Cannot add radio station to playlist") + } +} diff --git a/ui/controller/serverconnection.go b/ui/controller/serverconnection.go new file mode 100644 index 0000000..480ff90 --- /dev/null +++ b/ui/controller/serverconnection.go @@ -0,0 +1,250 @@ +package controller + +import ( + "context" + "fmt" + "log" + "time" + + "fyne.io/fyne/v2" + "fyne.io/fyne/v2/dialog" + "fyne.io/fyne/v2/lang" + "fyne.io/fyne/v2/widget" + "github.com/dweymouth/supersonic/backend" + "github.com/dweymouth/supersonic/ui/dialogs" +) + +func (m *Controller) PromptForFirstServer() { + d := dialogs.NewAddEditServerDialog(lang.L("Connect to Server"), false, nil, m.MainWindow.Canvas().Focus) + pop := widget.NewModalPopUp(d, m.MainWindow.Canvas()) + d.OnSubmit = func() { + d.DisableSubmit() + go func() { + if m.testConnectionAndUpdateDialogText(d) { + // connection is good + pop.Hide() + m.doModalClosed() + conn := backend.ServerConnection{ + ServerType: d.ServerType, + Hostname: d.Host, + AltHostname: d.AltHost, + Username: d.Username, + LegacyAuth: d.LegacyAuth, + } + server := m.App.ServerManager.AddServer(d.Nickname, conn) + if err := m.trySetPasswordAndConnectToServer(server, d.Password); err != nil { + log.Printf("error connecting to server: %s", err.Error()) + } + } + d.EnableSubmit() + }() + } + m.haveModal = true + pop.Show() +} + +// DoConnectToServerWorkflow does the workflow for connecting to the last active server on startup +func (c *Controller) DoConnectToServerWorkflow(server *backend.ServerConfig) { + pass, err := c.App.ServerManager.GetServerPassword(server.ID) + if err != nil { + log.Printf("error getting password from keyring: %v", err) + c.PromptForLoginAndConnect() + return + } + + // try connecting to last used server - set up cancelable modal dialog + canceled := false + ctx, cancel := context.WithCancel(context.Background()) + dlg := dialog.NewCustom(lang.L("Connecting"), lang.L("Cancel"), + widget.NewLabel(fmt.Sprintf(lang.L("Connecting to")+" %s", server.Nickname)), c.MainWindow) + dlg.SetOnClosed(func() { + canceled = true + cancel() + }) + c.haveModal = true + dlg.Show() + + // try to connect + go func() { + defer cancel() // make sure to free up ctx resources if user does not cancel + + if err := c.tryConnectToServer(ctx, server, pass); err != nil { + fyne.Do(func() { + dlg.Hide() + c.haveModal = false + if canceled { + c.PromptForLoginAndConnect() + } else { + // connection failure + dlg := dialog.NewError(err, c.MainWindow) + dlg.SetOnClosed(func() { + c.PromptForLoginAndConnect() + }) + c.haveModal = true + dlg.Show() + } + }) + } else { + fyne.Do(func() { + dlg.Hide() + c.haveModal = false + }) + } + }() +} + +func (m *Controller) PromptForLoginAndConnect() { + d := dialogs.NewLoginDialog(m.App.Config.Servers, m.App.ServerManager.GetServerPassword) + pop := widget.NewModalPopUp(d, m.MainWindow.Canvas()) + d.OnSubmit = func(server *backend.ServerConfig, password string) { + d.DisableSubmit() + d.SetInfoText(lang.L("Testing connection") + "...") + go func() { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + err := m.App.ServerManager.TestConnectionAndAuth(ctx, server.ServerConnection, password) + fyne.Do(func() { + if err == backend.ErrUnreachable { + d.SetErrorText(lang.L("Server unreachable")) + } else if err != nil { + d.SetErrorText(lang.L("Authentication failed")) + } else { + pop.Hide() + m.trySetPasswordAndConnectToServer(server, password) + m.doModalClosed() + } + d.EnableSubmit() + }) + }() + } + d.OnEditServer = func(server *backend.ServerConfig) { + pop.Hide() + editD := dialogs.NewAddEditServerDialog(lang.L("Edit server"), true, server, m.MainWindow.Canvas().Focus) + editPop := widget.NewModalPopUp(editD, m.MainWindow.Canvas()) + editD.OnSubmit = func() { + d.DisableSubmit() + go func() { + success := m.testConnectionAndUpdateDialogText(editD) + fyne.Do(func() { + if success { + // connection is good + editPop.Hide() + server.Hostname = editD.Host + server.AltHostname = editD.AltHost + server.Nickname = editD.Nickname + server.Username = editD.Username + server.LegacyAuth = editD.LegacyAuth + m.trySetPasswordAndConnectToServer(server, editD.Password) + m.doModalClosed() + } + d.EnableSubmit() + }) + }() + } + editD.OnCancel = func() { + editPop.Hide() + pop.Show() + } + editPop.Show() + } + d.OnNewServer = func() { + pop.Hide() + newD := dialogs.NewAddEditServerDialog(lang.L("Add Server"), true, nil, m.MainWindow.Canvas().Focus) + newPop := widget.NewModalPopUp(newD, m.MainWindow.Canvas()) + newD.OnSubmit = func() { + d.DisableSubmit() + go func() { + success := m.testConnectionAndUpdateDialogText(newD) + fyne.Do(func() { + if success { + // connection is good + newPop.Hide() + conn := backend.ServerConnection{ + ServerType: newD.ServerType, + Hostname: newD.Host, + AltHostname: newD.AltHost, + Username: newD.Username, + LegacyAuth: newD.LegacyAuth, + } + server := m.App.ServerManager.AddServer(newD.Nickname, conn) + m.trySetPasswordAndConnectToServer(server, newD.Password) + m.doModalClosed() + } + d.EnableSubmit() + }) + }() + } + newD.OnCancel = func() { + newPop.Hide() + pop.Show() + } + newPop.Show() + } + d.OnDeleteServer = func(server *backend.ServerConfig) { + pop.Hide() + dialog.ShowConfirm(lang.L("Confirm Delete Server"), + fmt.Sprintf(lang.L("Are you sure you want to delete the server")+" %q?", server.Nickname), + func(ok bool) { + if ok { + m.App.ServerManager.DeleteServer(server.ID) + m.App.DeleteServerCacheDir(server.ID) + d.SetServers(m.App.Config.Servers) + } + if len(m.App.Config.Servers) == 0 { + m.PromptForFirstServer() + } else { + pop.Show() + } + }, m.MainWindow) + + } + m.haveModal = true + pop.Show() +} + +func (c *Controller) trySetPasswordAndConnectToServer(server *backend.ServerConfig, password string) error { + if err := c.App.ServerManager.SetServerPassword(server, password); err != nil { + log.Printf("error setting keyring credentials: %v", err) + // Don't return an error; fall back to just using the password in-memory + // User will need to log in with the password on subsequent runs. + } + return c.tryConnectToServer(context.Background(), server, password) +} + +// try to connect to the given server, with the configured timeout added to the context +func (c *Controller) tryConnectToServer(ctx context.Context, server *backend.ServerConfig, password string) error { + timeout := time.Duration(c.App.Config.Application.RequestTimeoutSeconds) * time.Second + ctx, cancel := context.WithTimeout(ctx, timeout) + defer cancel() + if err := c.App.ServerManager.TestConnectionAndAuth(ctx, server.ServerConnection, password); err != nil { + return err + } + if err := c.App.ServerManager.ConnectToServer(server, password); err != nil { + log.Printf("error connecting to server: %v", err) + return err + } + return nil +} + +func (c *Controller) testConnectionAndUpdateDialogText(dlg *dialogs.AddEditServerDialog) bool { + dlg.SetInfoText(lang.L("Testing connection") + "...") + conn := backend.ServerConnection{ + ServerType: dlg.ServerType, + Hostname: dlg.Host, + AltHostname: dlg.AltHost, + Username: dlg.Username, + LegacyAuth: dlg.LegacyAuth, + } + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + err := c.App.ServerManager.TestConnectionAndAuth(ctx, conn, dlg.Password) + if err == backend.ErrUnreachable { + dlg.SetErrorText(lang.L("Could not reach server") + fmt.Sprintf(" (%s?)", lang.L("wrong URL"))) + return false + } else if err != nil { + dlg.SetErrorText(lang.L("Authentication failed") + fmt.Sprintf(" (%s)", lang.L("wrong username/password"))) + return false + } + return true +} diff --git a/ui/dialogs/quicksearch.go b/ui/dialogs/quicksearch.go index 8a557e5..96113da 100644 --- a/ui/dialogs/quicksearch.go +++ b/ui/dialogs/quicksearch.go @@ -5,31 +5,115 @@ import ( "fyne.io/fyne/v2" "fyne.io/fyne/v2/lang" + "fyne.io/fyne/v2/theme" + "fyne.io/fyne/v2/widget" "github.com/dweymouth/supersonic/backend/mediaprovider" + myTheme "github.com/dweymouth/supersonic/ui/theme" "github.com/dweymouth/supersonic/ui/util" ) type QuickSearch struct { SearchDialog *SearchDialog + results []*mediaprovider.SearchResult mp mediaprovider.MediaProvider + + OnPlay func(t mediaprovider.ContentType, id string, item any, shuffle bool) + OnAddToQueue func(t mediaprovider.ContentType, id string, item any, next bool) + OnAddToPlaylist func(t mediaprovider.ContentType, id string, item any) + OnSetFavorite func(trackID string, fav bool) + OnSetRating func(trackID string, rating int) + OnDownload func(track *mediaprovider.Track) + OnShare func(trackID string) + OnPlaySongRadio func(track *mediaprovider.Track) + OnShowTrackInfo func(track *mediaprovider.Track) } func NewQuickSearch(mp mediaprovider.MediaProvider, im util.ImageFetcher) *QuickSearch { q := &QuickSearch{mp: mp} q.SearchDialog = NewSearchDialog(im, lang.L("Search Everywhere"), lang.L("Close"), q.onSearched) + q.SearchDialog.OnShowContextMenu = q.showMenu return q } func (q *QuickSearch) onSearched(query string) []*mediaprovider.SearchResult { - var results []*mediaprovider.SearchResult if query != "" { if res, err := q.mp.SearchAll(query, 20); err != nil { + q.results = nil log.Printf("Error searching: %s", err.Error()) } else { - results = res + q.results = res } } - return results + return q.results +} + +func (q *QuickSearch) showMenu(idx int, pos fyne.Position) { + cType := q.results[idx].Type + id := q.results[idx].ID + item := q.results[idx].Item + + canvas := fyne.CurrentApp().Driver().CanvasForObject(q.SearchDialog) + + switch cType { + case mediaprovider.ContentTypeTrack: + menu := util.NewTrackContextMenu(false, nil) + menu.OnPlay = func(shuffle bool) { + q.OnPlay(cType, id, item, shuffle) + } + menu.OnAddToQueue = func(next bool) { + q.OnAddToQueue(cType, id, item, next) + } + menu.OnAddToPlaylist = func() { + q.OnAddToPlaylist(cType, id, item) + } + menu.OnDownload = func() { + q.OnDownload(item.(*mediaprovider.Track)) + } + menu.OnFavorite = func(fav bool) { + q.OnSetFavorite(id, fav) + } + menu.OnSetRating = func(rating int) { + q.OnSetRating(id, rating) + } + menu.OnPlaySongRadio = func() { + q.OnPlaySongRadio(item.(*mediaprovider.Track)) + } + menu.OnShowInfo = func() { + q.OnShowTrackInfo(item.(*mediaprovider.Track)) + } + menu.OnShare = func() { + q.OnShare(id) + } + menu.ShowAtPosition(pos, canvas) + default: + play := fyne.NewMenuItem(lang.L("Play"), func() { + q.OnPlay(cType, id, item, false) + }) + play.Icon = theme.MediaPlayIcon() + shuffle := fyne.NewMenuItem(lang.L("Shuffle"), func() { + q.OnPlay(cType, id, item, true) + }) + shuffle.Icon = myTheme.ShuffleIcon + playNext := fyne.NewMenuItem(lang.L("Play next"), func() { + q.OnAddToQueue(cType, id, item, true) + }) + playNext.Icon = myTheme.PlayNextIcon + add := fyne.NewMenuItem(lang.L("Add to queue"), func() { + q.OnAddToQueue(cType, id, item, false) + }) + add.Icon = theme.ContentAddIcon() + menu := fyne.NewMenu("", play, shuffle, playNext, add) + + if cType != mediaprovider.ContentTypeRadioStation { + playlist := fyne.NewMenuItem(lang.L("Add to playlist")+"...", func() { + q.OnAddToPlaylist(cType, id, item) + }) + playlist.Icon = myTheme.PlaylistIcon + menu.Items = append(menu.Items, playlist) + } + + widget.ShowPopUpMenuAtPosition(menu, canvas, pos) + } } func (q *QuickSearch) SetOnDismiss(onDismiss func()) { diff --git a/ui/dialogs/searchdialog.go b/ui/dialogs/searchdialog.go index 58801c9..411c974 100644 --- a/ui/dialogs/searchdialog.go +++ b/ui/dialogs/searchdialog.go @@ -30,9 +30,10 @@ type SearchDialog struct { // of the dismiss buttons ActionItem fyne.CanvasObject - OnDismiss func() - OnNavigateTo func(mediaprovider.ContentType, string) - OnSearched func(string) []*mediaprovider.SearchResult + OnDismiss func() + OnNavigateTo func(mediaprovider.ContentType, string) + OnShowContextMenu func(itemIdx int, pos fyne.Position) + OnSearched func(string) []*mediaprovider.SearchResult imgSource util.ImageFetcher resultsMutex sync.RWMutex @@ -326,6 +327,12 @@ func (q *searchResult) Tapped(_ *fyne.PointEvent) { q.parent.onSelected(q.index) } +func (q *searchResult) TappedSecondary(e *fyne.PointEvent) { + if q.parent.OnShowContextMenu != nil { + q.parent.OnShowContextMenu(q.index, e.AbsolutePosition) + } +} + func (q *searchResult) CreateRenderer() fyne.WidgetRenderer { if q.content == nil { q.content = container.NewBorder(nil, nil, container.NewCenter(q.image), nil, From 4f731833c06b282b8b81dbdddda56c667fe22006 Mon Sep 17 00:00:00 2001 From: Drew Weymouth Date: Wed, 12 Mar 2025 16:47:48 -0700 Subject: [PATCH 11/45] very WIP for DLNA casting --- backend/app.go | 106 +++++++------ backend/player/dlna/dlnaplayer.go | 243 ++++++++++++++++++++++++++++++ go.mod | 4 + go.sum | 6 + ui/controller/controller.go | 97 ++++++------ ui/controller/visualizations.go | 8 +- 6 files changed, 363 insertions(+), 101 deletions(-) create mode 100644 backend/player/dlna/dlnaplayer.go diff --git a/backend/app.go b/backend/app.go index d93309f..dbf1c99 100644 --- a/backend/app.go +++ b/backend/app.go @@ -11,16 +11,18 @@ import ( "path/filepath" "reflect" "runtime" - "slices" "strings" "time" "github.com/dweymouth/supersonic/backend/ipc" "github.com/dweymouth/supersonic/backend/mediaprovider" "github.com/dweymouth/supersonic/backend/player" + "github.com/dweymouth/supersonic/backend/player/dlna" "github.com/dweymouth/supersonic/backend/player/mpv" "github.com/dweymouth/supersonic/backend/util" "github.com/google/uuid" + "github.com/supersonic-app/go-upnpcast/device" + "github.com/supersonic-app/go-upnpcast/services" "github.com/20after4/configdir" "github.com/zalando/go-keyring" @@ -43,7 +45,7 @@ type App struct { ServerManager *ServerManager ImageManager *ImageManager PlaybackManager *PlaybackManager - LocalPlayer *mpv.Player + LocalPlayer player.BasePlayer UpdateChecker UpdateChecker MPRISHandler *MPRISHandler WinSMTC *SMTC @@ -273,7 +275,11 @@ func (a *App) initMPV() error { if err := p.Init(c.InMemoryCacheSizeMB); err != nil { return fmt.Errorf("failed to initialize mpv player: %s", err.Error()) } - a.LocalPlayer = p + // a.LocalPlayer = p + devices, _ := device.SearchMediaRenderers(context.Background(), 10, services.AVTransport) + if len(devices) > 0 { + a.LocalPlayer, _ = dlna.NewDLNAPlayer(devices[0]) + } return nil } @@ -281,55 +287,57 @@ func (a *App) setupMPV() error { a.Config.LocalPlayback.Volume = clamp(a.Config.LocalPlayback.Volume, 0, 100) a.LocalPlayer.SetVolume(a.Config.LocalPlayback.Volume) - devs, err := a.LocalPlayer.ListAudioDevices() - if err != nil { - return err - } - - desiredDevice := a.Config.LocalPlayback.AudioDeviceName - var desiredDeviceAvailable bool - for _, dev := range devs { - if dev.Name == desiredDevice { - desiredDeviceAvailable = true - break + /* + devs, err := a.LocalPlayer.ListAudioDevices() + if err != nil { + return err } - } - if !desiredDeviceAvailable { - // The audio device the user has configured is not available. - // Use the default (autoselect) device but leave the setting unchanged, - // in case the device is later available on a subsequent run of the app - // (e.g. a USB audio device that is currently unplugged) - desiredDevice = "auto" - } - a.LocalPlayer.SetAudioDevice(desiredDevice) - rgainOpts := []string{ReplayGainNone, ReplayGainAlbum, ReplayGainTrack, ReplayGainAuto} - if !slices.Contains(rgainOpts, a.Config.ReplayGain.Mode) { - a.Config.ReplayGain.Mode = ReplayGainNone - } - mode := player.ReplayGainNone - switch a.Config.ReplayGain.Mode { - case ReplayGainAlbum: - mode = player.ReplayGainAlbum - case ReplayGainTrack: - mode = player.ReplayGainTrack - case ReplayGainAuto: - mode = player.ReplayGainTrack - } + desiredDevice := a.Config.LocalPlayback.AudioDeviceName + var desiredDeviceAvailable bool + for _, dev := range devs { + if dev.Name == desiredDevice { + desiredDeviceAvailable = true + break + } + } + if !desiredDeviceAvailable { + // The audio device the user has configured is not available. + // Use the default (autoselect) device but leave the setting unchanged, + // in case the device is later available on a subsequent run of the app + // (e.g. a USB audio device that is currently unplugged) + desiredDevice = "auto" + } + a.LocalPlayer.SetAudioDevice(desiredDevice) - a.LocalPlayer.SetReplayGainOptions(player.ReplayGainOptions{ - Mode: mode, - PreventClipping: a.Config.ReplayGain.PreventClipping, - PreampGain: a.Config.ReplayGain.PreampGainDB, - }) - a.LocalPlayer.SetAudioExclusive(a.Config.LocalPlayback.AudioExclusive) + rgainOpts := []string{ReplayGainNone, ReplayGainAlbum, ReplayGainTrack, ReplayGainAuto} + if !slices.Contains(rgainOpts, a.Config.ReplayGain.Mode) { + a.Config.ReplayGain.Mode = ReplayGainNone + } + mode := player.ReplayGainNone + switch a.Config.ReplayGain.Mode { + case ReplayGainAlbum: + mode = player.ReplayGainAlbum + case ReplayGainTrack: + mode = player.ReplayGainTrack + case ReplayGainAuto: + mode = player.ReplayGainTrack + } - eq := &mpv.ISO15BandEqualizer{ - EQPreamp: a.Config.LocalPlayback.EqualizerPreamp, - Disabled: !a.Config.LocalPlayback.EqualizerEnabled, - } - copy(eq.BandGains[:], a.Config.LocalPlayback.GraphicEqualizerBands) - a.LocalPlayer.SetEqualizer(eq) + a.LocalPlayer.SetReplayGainOptions(player.ReplayGainOptions{ + Mode: mode, + PreventClipping: a.Config.ReplayGain.PreventClipping, + PreampGain: a.Config.ReplayGain.PreampGainDB, + }) + a.LocalPlayer.SetAudioExclusive(a.Config.LocalPlayback.AudioExclusive) + + eq := &mpv.ISO15BandEqualizer{ + EQPreamp: a.Config.LocalPlayback.EqualizerPreamp, + Disabled: !a.Config.LocalPlayback.EqualizerEnabled, + } + copy(eq.BandGains[:], a.Config.LocalPlayback.GraphicEqualizerBands) + a.LocalPlayer.SetEqualizer(eq) + */ return nil } @@ -450,7 +458,7 @@ func (a *App) Shutdown() { a.PlaybackManager.DisableCallbacks() a.PlaybackManager.Stop() // will trigger scrobble check a.cancel() - a.LocalPlayer.Destroy() + //a.LocalPlayer.Destroy() } func (a *App) SavePlayQueueIfEnabled() { diff --git a/backend/player/dlna/dlnaplayer.go b/backend/player/dlna/dlnaplayer.go new file mode 100644 index 0000000..23b6bb9 --- /dev/null +++ b/backend/player/dlna/dlnaplayer.go @@ -0,0 +1,243 @@ +package dlna + +import ( + "context" + "errors" + "fmt" + "io" + "log" + "net" + "net/http" + "os" + "sync" + "sync/atomic" + + "github.com/dweymouth/supersonic/backend/player" + "github.com/supersonic-app/go-upnpcast/device" + "github.com/supersonic-app/go-upnpcast/services/avtransport" +) + +const ( + stopped = 0 + playing = 1 + paused = 2 +) + +var unimplemented = errors.New("unimplemented") + +type DLNAPlayer struct { + player.BasePlayerCallbackImpl + + avTransport *avtransport.Client + + state int // stopped, playing, paused + seeking bool +} + +func NewDLNAPlayer(device *device.MediaRenderer) (*DLNAPlayer, error) { + avt, err := device.AVTransportClient() + if err != nil { + return nil, err + } + return &DLNAPlayer{avTransport: avt}, nil +} + +func (d *DLNAPlayer) SetVolume(vol int) error { + return unimplemented +} + +func (d *DLNAPlayer) GetVolume() int { + return 0 +} + +func (d *DLNAPlayer) PlayFile(urlstr string) error { + ensureSetupProxies() + + proxyURLLock.Lock() + dlnaProxyCurrent.url = urlstr + proxyURLLock.Unlock() + + media := avtransport.MediaItem{ + URL: "http://" + localIP + ":8080/current", + Title: "Supersonic media item", + } + log.Printf("URL %s", media.URL) + + err := d.avTransport.SetAVTransportMedia(context.Background(), &media) + if err != nil { + return err + } + if err := d.avTransport.Play(context.Background()); err != nil { + return err + } + d.state = playing + d.InvokeOnPlaying() + return nil +} + +func (d *DLNAPlayer) SetNextFile(url string) error { + var media *avtransport.MediaItem + if url != "" { + ensureSetupProxies() + + proxyURLLock.Lock() + dlnaProxyCurrent.url = url + proxyURLLock.Unlock() + + media = &avtransport.MediaItem{ + URL: "http://" + localIP + ":8080/next", + } + } + return d.avTransport.SetNextAVTransportMedia(context.Background(), media) +} + +func (d *DLNAPlayer) Continue() error { + if err := d.avTransport.Play(context.Background()); err != nil { + return err + } + d.state = playing + d.InvokeOnPlaying() + return nil +} + +func (d *DLNAPlayer) Pause() error { + if err := d.avTransport.Pause(context.Background()); err != nil { + return err + } + d.state = paused + d.InvokeOnPaused() + return nil +} + +func (d *DLNAPlayer) Stop() error { + if err := d.avTransport.Pause(context.Background()); err != nil { + return err + } + d.state = stopped + d.InvokeOnStopped() + return nil +} + +func (d *DLNAPlayer) SeekSeconds(secs float64) error { + d.seeking = true + if err := d.avTransport.Seek(context.Background(), int(secs)); err != nil { + d.seeking = false + return err + } + d.seeking = false + d.InvokeOnSeek() + return nil +} + +func (d *DLNAPlayer) IsSeeking() bool { + return d.seeking +} + +func (d *DLNAPlayer) GetStatus() player.Status { + state := player.Stopped + if d.state == playing { + state = player.Playing + } else if d.state == paused { + state = player.Paused + } + + // TODO - the rest + + return player.Status{ + State: state, + } +} + +func getLocalIP() (string, error) { + interfaces, err := net.Interfaces() + if err != nil { + return "", err + } + + for _, iface := range interfaces { + if iface.Flags&net.FlagUp == 0 || iface.Flags&net.FlagLoopback != 0 { + continue + } + + addrs, err := iface.Addrs() + if err != nil { + return "", err + } + + for _, addr := range addrs { + ipnet, ok := addr.(*net.IPNet) + if ok && !ipnet.IP.IsLoopback() && ipnet.IP.To4() != nil { + return ipnet.IP.String(), nil + } + } + } + + return "", fmt.Errorf("no suitable interface found") +} + +var ( + localIP string + proxyURLLock sync.Mutex + dlnaProxyCurrent proxy + dlnaProxyNext proxy + proxyActive atomic.Bool +) + +func ensureSetupProxies() { + if proxyActive.Swap(true) { + return // already active + } + + localIP, _ = getLocalIP() + log.Println(localIP) + + mux := http.NewServeMux() + mux.HandleFunc("/current", dlnaProxyCurrent.handleRequest) + mux.HandleFunc("/next", dlnaProxyNext.handleRequest) + go http.ListenAndServe(":8080", mux) +} + +type proxy struct { + url string +} + +func (p *proxy) handleRequest(w http.ResponseWriter, r *http.Request) { + // Create a new request to the target server + proxyURLLock.Lock() + url := p.url + proxyURLLock.Unlock() + log.Println("Got request for " + url) + proxyReq, err := http.NewRequest(r.Method, url, r.Body) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + // Copy headers from the original request to the new request + proxyReq.Header = r.Header + + // Create an HTTP client and send the request + client := &http.Client{} + resp, err := client.Do(proxyReq) + if err != nil { + http.Error(w, err.Error(), http.StatusBadGateway) + return + } + defer resp.Body.Close() + + // Copy headers from the response to the writer + for name, values := range resp.Header { + for _, value := range values { + w.Header().Add(name, value) + } + } + + // Set the status code + w.WriteHeader(resp.StatusCode) + + // Copy the response body to the writer + _, err = io.Copy(w, resp.Body) + if err != nil { + fmt.Fprintln(os.Stderr, "Error copying response body:", err) + } +} diff --git a/go.mod b/go.mod index 672b93e..9e487dc 100644 --- a/go.mod +++ b/go.mod @@ -18,6 +18,7 @@ require ( github.com/quarckster/go-mpris-server v1.0.3 github.com/supersonic-app/go-mpv v0.1.0 github.com/supersonic-app/go-subsonic v0.0.0-20241224013245-9b2841f3711d + github.com/supersonic-app/go-upnpcast v0.0.0-20250312000014-e4f7242a07ce github.com/zalando/go-keyring v0.2.6 golang.org/x/net v0.25.0 golang.org/x/sys v0.30.0 @@ -39,8 +40,10 @@ require ( github.com/go-gl/glfw/v3.3/glfw v0.0.0-20240506104042-037f3cc74f2a // indirect github.com/go-text/render v0.2.0 // indirect github.com/go-text/typesetting v0.2.1 // indirect + github.com/h2non/filetype v1.1.3 // indirect github.com/jeandeaual/go-locale v0.0.0-20241217141322-fcc2cadd6f08 // indirect github.com/jsummers/gobmp v0.0.0-20230614200233-a9de23ed2e25 // indirect + github.com/koron/go-ssdp v0.0.4 // indirect github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 // indirect github.com/nicksnyder/go-i18n/v2 v2.5.1 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect @@ -53,4 +56,5 @@ require ( gopkg.in/yaml.v3 v3.0.1 // indirect ) +replace github.com/supersonic-app/go-upnpcast v0.0.0-20250312000014-e4f7242a07ce => ../go-upnpcast replace fyne.io/fyne/v2 v2.6.0-beta1 => github.com/dweymouth/fyne/v2 v2.3.0-rc1.0.20250308154116-c32a0de49bde diff --git a/go.sum b/go.sum index ac2322e..e8a2382 100644 --- a/go.sum +++ b/go.sum @@ -57,10 +57,14 @@ github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 h1:El6M4kTTCOh6aBiKaU github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510/go.mod h1:pupxD2MaaD3pAXIBCelhxNneeOaAeabZDe5s4K6zSpQ= github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I= github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/h2non/filetype v1.1.3 h1:FKkx9QbD7HR/zjK1Ia5XiBsq9zdLi5Kf3zGyFTAFkGg= +github.com/h2non/filetype v1.1.3/go.mod h1:319b3zT68BvV+WRj7cwy856M2ehB3HqNOt6sy1HndBY= github.com/jeandeaual/go-locale v0.0.0-20241217141322-fcc2cadd6f08 h1:wMeVzrPO3mfHIWLZtDcSaGAe2I4PW9B/P5nMkRSwCAc= github.com/jeandeaual/go-locale v0.0.0-20241217141322-fcc2cadd6f08/go.mod h1:ZDXo8KHryOWSIqnsb/CiDq7hQUYryCgdVnxbj8tDG7o= github.com/jsummers/gobmp v0.0.0-20230614200233-a9de23ed2e25 h1:YLvr1eE6cdCqjOe972w/cYF+FjW34v27+9Vo5106B4M= github.com/jsummers/gobmp v0.0.0-20230614200233-a9de23ed2e25/go.mod h1:kLgvv7o6UM+0QSf0QjAse3wReFDsb9qbZJdfexWlrQw= +github.com/koron/go-ssdp v0.0.4 h1:1IDwrghSKYM7yLf7XCzbByg2sJ/JcNOZRXS2jczTwz0= +github.com/koron/go-ssdp v0.0.4/go.mod h1:oDXq+E5IL5q0U8uSBcoAXzTzInwy5lEgC91HoKtbmZk= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 h1:zYyBkD/k9seD2A7fsi6Oo2LfFZAehjjQMERAvZLEDnQ= @@ -97,6 +101,8 @@ github.com/supersonic-app/go-mpv v0.1.0 h1:U+cCnLQxmpqx5mY6nMlC0J4uIdCCXUbAjpjS0 github.com/supersonic-app/go-mpv v0.1.0/go.mod h1:1bQz6kBQumJopXEbkiqoLxIXLy7F7yWFBvknvpAtIC0= github.com/supersonic-app/go-subsonic v0.0.0-20241224013245-9b2841f3711d h1:70+Nn7yh+cfeKqqXVTdpneFqXuvrBLyP7U6GVUsjTU4= github.com/supersonic-app/go-subsonic v0.0.0-20241224013245-9b2841f3711d/go.mod h1:D+OWPXeD9owcdcoXATv5YPBGWxxVvn5k98rt5B4wMc4= +github.com/supersonic-app/go-upnpcast v0.0.0-20250312000014-e4f7242a07ce h1:6R8/JRwRVrR9t2YPTgikS39lRf3gPp/MPcbx+lIf3rA= +github.com/supersonic-app/go-upnpcast v0.0.0-20250312000014-e4f7242a07ce/go.mod h1:Wscg4vEzF9x6i4ltL2Qkvx4VhzXfkBBl8uoG0YSAJMU= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= github.com/yuin/goldmark v1.7.8 h1:iERMLn0/QJeHFhxSt3p6PeN9mGnvIKSpG9YYorDMnic= github.com/yuin/goldmark v1.7.8/go.mod h1:uzxRWxtg69N339t3louHJ7+O03ezfj6PlliRlaOzY1E= diff --git a/ui/controller/controller.go b/ui/controller/controller.go index 246f61b..1ef9529 100644 --- a/ui/controller/controller.go +++ b/ui/controller/controller.go @@ -16,8 +16,6 @@ import ( fynetooltip "github.com/dweymouth/fyne-tooltip" "github.com/dweymouth/supersonic/backend" "github.com/dweymouth/supersonic/backend/mediaprovider" - "github.com/dweymouth/supersonic/backend/player" - "github.com/dweymouth/supersonic/backend/player/mpv" "github.com/dweymouth/supersonic/sharedutil" "github.com/dweymouth/supersonic/ui/dialogs" myTheme "github.com/dweymouth/supersonic/ui/theme" @@ -270,53 +268,56 @@ func (c *Controller) ShowAboutDialog() { } func (c *Controller) ShowSettingsDialog(themeUpdateCallbk func(), themeFiles map[string]string) { - devs, err := c.App.LocalPlayer.ListAudioDevices() - if err != nil { - log.Printf("error listing audio devices: %v", err) - devs = []mpv.AudioDevice{{Name: "auto", Description: lang.L("Autoselect device")}} - } + /* + devs, err := c.App.LocalPlayer.ListAudioDevices() + if err != nil { + log.Printf("error listing audio devices: %v", err) + devs = []mpv.AudioDevice{{Name: "auto", Description: lang.L("Autoselect device")}} + } - curPlayer := c.App.PlaybackManager.CurrentPlayer() - _, isReplayGainPlayer := curPlayer.(player.ReplayGainPlayer) - _, isEqualizerPlayer := curPlayer.(*mpv.Player) - _, canSavePlayQueue := c.App.ServerManager.Server.(mediaprovider.CanSavePlayQueue) - isLocalPlayer := isEqualizerPlayer - bands := c.App.LocalPlayer.Equalizer().BandFrequencies() - dlg := dialogs.NewSettingsDialog(c.App.Config, - devs, themeFiles, bands, - c.App.ServerManager.Server.ClientDecidesScrobble(), - isLocalPlayer, isReplayGainPlayer, isEqualizerPlayer, canSavePlayQueue, - c.MainWindow) - dlg.OnReplayGainSettingsChanged = func() { - c.App.PlaybackManager.SetReplayGainOptions(c.App.Config.ReplayGain) - } - dlg.OnAudioExclusiveSettingChanged = func() { - c.App.LocalPlayer.SetAudioExclusive(c.App.Config.LocalPlayback.AudioExclusive) - } - dlg.OnAudioDeviceSettingChanged = func() { - c.App.LocalPlayer.SetAudioDevice(c.App.Config.LocalPlayback.AudioDeviceName) - } - dlg.OnThemeSettingChanged = themeUpdateCallbk - dlg.OnEqualizerSettingsChanged = func() { - // currently we only have one equalizer type - eq := c.App.LocalPlayer.Equalizer().(*mpv.ISO15BandEqualizer) - eq.Disabled = !c.App.Config.LocalPlayback.EqualizerEnabled - eq.EQPreamp = c.App.Config.LocalPlayback.EqualizerPreamp - copy(eq.BandGains[:], c.App.Config.LocalPlayback.GraphicEqualizerBands) - c.App.LocalPlayer.SetEqualizer(eq) - } - dlg.OnPageNeedsRefresh = c.RefreshPageFunc - pop := widget.NewModalPopUp(dlg, c.MainWindow.Canvas()) - fynetooltip.AddPopUpToolTipLayer(pop) - dlg.OnDismiss = func() { - pop.Hide() - fynetooltip.DestroyPopUpToolTipLayer(pop) - c.doModalClosed() - c.App.SaveConfigFile() - } - c.ClosePopUpOnEscape(pop) - c.haveModal = true - pop.Show() + curPlayer := c.App.PlaybackManager.CurrentPlayer() + _, isReplayGainPlayer := curPlayer.(player.ReplayGainPlayer) + _, isEqualizerPlayer := curPlayer.(*mpv.Player) + _, canSavePlayQueue := c.App.ServerManager.Server.(mediaprovider.CanSavePlayQueue) + isLocalPlayer := isEqualizerPlayer + bands := c.App.LocalPlayer.Equalizer().BandFrequencies() + + dlg := dialogs.NewSettingsDialog(c.App.Config, + devs, themeFiles, bands, + c.App.ServerManager.Server.ClientDecidesScrobble(), + isLocalPlayer, isReplayGainPlayer, isEqualizerPlayer, canSavePlayQueue, + c.MainWindow) + dlg.OnReplayGainSettingsChanged = func() { + c.App.PlaybackManager.SetReplayGainOptions(c.App.Config.ReplayGain) + } + dlg.OnAudioExclusiveSettingChanged = func() { + c.App.LocalPlayer.SetAudioExclusive(c.App.Config.LocalPlayback.AudioExclusive) + } + dlg.OnAudioDeviceSettingChanged = func() { + c.App.LocalPlayer.SetAudioDevice(c.App.Config.LocalPlayback.AudioDeviceName) + } + dlg.OnThemeSettingChanged = themeUpdateCallbk + dlg.OnEqualizerSettingsChanged = func() { + // currently we only have one equalizer type + eq := c.App.LocalPlayer.Equalizer().(*mpv.ISO15BandEqualizer) + eq.Disabled = !c.App.Config.LocalPlayback.EqualizerEnabled + eq.EQPreamp = c.App.Config.LocalPlayback.EqualizerPreamp + copy(eq.BandGains[:], c.App.Config.LocalPlayback.GraphicEqualizerBands) + c.App.LocalPlayer.SetEqualizer(eq) + } + dlg.OnPageNeedsRefresh = c.RefreshPageFunc + pop := widget.NewModalPopUp(dlg, c.MainWindow.Canvas()) + fynetooltip.AddPopUpToolTipLayer(pop) + dlg.OnDismiss = func() { + pop.Hide() + fynetooltip.DestroyPopUpToolTipLayer(pop) + c.doModalClosed() + c.App.SaveConfigFile() + } + c.ClosePopUpOnEscape(pop) + c.haveModal = true + pop.Show() + */ } func (c *Controller) doModalClosed() { diff --git a/ui/controller/visualizations.go b/ui/controller/visualizations.go index b63cdf6..1e043fb 100644 --- a/ui/controller/visualizations.go +++ b/ui/controller/visualizations.go @@ -71,13 +71,13 @@ func (c *Controller) stopVisualizationAnim() { if c.visualizationAnim != nil { c.visualizationAnim.Stop() c.visualizationAnim = nil - c.App.LocalPlayer.SetPeaksEnabled(false) + // c.App.LocalPlayer.SetPeaksEnabled(false) } } func (c *Controller) startVisualizationAnim() { if c.visualizationAnim == nil { - c.App.LocalPlayer.SetPeaksEnabled(true) + //c.App.LocalPlayer.SetPeaksEnabled(true) c.visualizationAnim = fyne.NewAnimation( time.Duration(math.MaxInt64), /*until stopped*/ c.tickVisualizations) @@ -86,8 +86,8 @@ func (c *Controller) startVisualizationAnim() { } func (c *Controller) tickVisualizations(_ float32) { - lP, rP, lRMS, rRMS := c.App.LocalPlayer.GetPeaks() + //lP, rP, lRMS, rRMS := c.App.LocalPlayer.GetPeaks() if c.visualizationData.peakMeter != nil { - c.visualizationData.peakMeter.UpdatePeaks(lP, rP, lRMS, rRMS) + //c.visualizationData.peakMeter.UpdatePeaks(lP, rP, lRMS, rRMS) } } From 8bd1c8183d20a6d72caeb1a0e888cf20aa54672f Mon Sep 17 00:00:00 2001 From: Drew Weymouth Date: Fri, 14 Mar 2025 16:35:42 -0700 Subject: [PATCH 12/45] more work for preparing for casting --- backend/app.go | 117 +++++++++++++++++--------------- backend/playbackengine.go | 74 +++++++++++++++----- backend/playbackmanager.go | 64 +++++++++++++++-- backend/player/player.go | 46 ++++++------- ui/controller/controller.go | 98 +++++++++++++------------- ui/controller/visualizations.go | 13 ++-- 6 files changed, 261 insertions(+), 151 deletions(-) diff --git a/backend/app.go b/backend/app.go index dbf1c99..f8e126c 100644 --- a/backend/app.go +++ b/backend/app.go @@ -11,18 +11,16 @@ import ( "path/filepath" "reflect" "runtime" + "slices" "strings" "time" "github.com/dweymouth/supersonic/backend/ipc" "github.com/dweymouth/supersonic/backend/mediaprovider" "github.com/dweymouth/supersonic/backend/player" - "github.com/dweymouth/supersonic/backend/player/dlna" "github.com/dweymouth/supersonic/backend/player/mpv" "github.com/dweymouth/supersonic/backend/util" "github.com/google/uuid" - "github.com/supersonic-app/go-upnpcast/device" - "github.com/supersonic-app/go-upnpcast/services" "github.com/20after4/configdir" "github.com/zalando/go-keyring" @@ -45,7 +43,7 @@ type App struct { ServerManager *ServerManager ImageManager *ImageManager PlaybackManager *PlaybackManager - LocalPlayer player.BasePlayer + LocalPlayer *mpv.Player UpdateChecker UpdateChecker MPRISHandler *MPRISHandler WinSMTC *SMTC @@ -154,6 +152,21 @@ func StartupApp(appName, displayAppName, appVersion, appVersionTag, latestReleas a.LrcLibFetcher = NewLrcLibFetcher(a.cacheDir, a.Config.Application.CustomLrcLibUrl, timeout) } + // Periodically scan for remote players + go func() { + t := time.NewTicker(5 * time.Minute) + for { + a.PlaybackManager.ScanRemotePlayers(a.bgrndCtx) + select { + case <-a.bgrndCtx.Done(): + t.Stop() + return + case <-t.C: + continue + } + } + }() + a.PlaybackManager.OnPlaying(func() { SetSystemSleepDisabled(true) }) @@ -275,11 +288,7 @@ func (a *App) initMPV() error { if err := p.Init(c.InMemoryCacheSizeMB); err != nil { return fmt.Errorf("failed to initialize mpv player: %s", err.Error()) } - // a.LocalPlayer = p - devices, _ := device.SearchMediaRenderers(context.Background(), 10, services.AVTransport) - if len(devices) > 0 { - a.LocalPlayer, _ = dlna.NewDLNAPlayer(devices[0]) - } + a.LocalPlayer = p return nil } @@ -287,57 +296,55 @@ func (a *App) setupMPV() error { a.Config.LocalPlayback.Volume = clamp(a.Config.LocalPlayback.Volume, 0, 100) a.LocalPlayer.SetVolume(a.Config.LocalPlayback.Volume) - /* - devs, err := a.LocalPlayer.ListAudioDevices() - if err != nil { - return err - } + devs, err := a.LocalPlayer.ListAudioDevices() + if err != nil { + return err + } - desiredDevice := a.Config.LocalPlayback.AudioDeviceName - var desiredDeviceAvailable bool - for _, dev := range devs { - if dev.Name == desiredDevice { - desiredDeviceAvailable = true - break - } + desiredDevice := a.Config.LocalPlayback.AudioDeviceName + var desiredDeviceAvailable bool + for _, dev := range devs { + if dev.Name == desiredDevice { + desiredDeviceAvailable = true + break } - if !desiredDeviceAvailable { - // The audio device the user has configured is not available. - // Use the default (autoselect) device but leave the setting unchanged, - // in case the device is later available on a subsequent run of the app - // (e.g. a USB audio device that is currently unplugged) - desiredDevice = "auto" - } - a.LocalPlayer.SetAudioDevice(desiredDevice) + } + if !desiredDeviceAvailable { + // The audio device the user has configured is not available. + // Use the default (autoselect) device but leave the setting unchanged, + // in case the device is later available on a subsequent run of the app + // (e.g. a USB audio device that is currently unplugged) + desiredDevice = "auto" + } + a.LocalPlayer.SetAudioDevice(desiredDevice) - rgainOpts := []string{ReplayGainNone, ReplayGainAlbum, ReplayGainTrack, ReplayGainAuto} - if !slices.Contains(rgainOpts, a.Config.ReplayGain.Mode) { - a.Config.ReplayGain.Mode = ReplayGainNone - } - mode := player.ReplayGainNone - switch a.Config.ReplayGain.Mode { - case ReplayGainAlbum: - mode = player.ReplayGainAlbum - case ReplayGainTrack: - mode = player.ReplayGainTrack - case ReplayGainAuto: - mode = player.ReplayGainTrack - } + rgainOpts := []string{ReplayGainNone, ReplayGainAlbum, ReplayGainTrack, ReplayGainAuto} + if !slices.Contains(rgainOpts, a.Config.ReplayGain.Mode) { + a.Config.ReplayGain.Mode = ReplayGainNone + } + mode := player.ReplayGainNone + switch a.Config.ReplayGain.Mode { + case ReplayGainAlbum: + mode = player.ReplayGainAlbum + case ReplayGainTrack: + mode = player.ReplayGainTrack + case ReplayGainAuto: + mode = player.ReplayGainTrack + } - a.LocalPlayer.SetReplayGainOptions(player.ReplayGainOptions{ - Mode: mode, - PreventClipping: a.Config.ReplayGain.PreventClipping, - PreampGain: a.Config.ReplayGain.PreampGainDB, - }) - a.LocalPlayer.SetAudioExclusive(a.Config.LocalPlayback.AudioExclusive) + a.LocalPlayer.SetReplayGainOptions(player.ReplayGainOptions{ + Mode: mode, + PreventClipping: a.Config.ReplayGain.PreventClipping, + PreampGain: a.Config.ReplayGain.PreampGainDB, + }) + a.LocalPlayer.SetAudioExclusive(a.Config.LocalPlayback.AudioExclusive) - eq := &mpv.ISO15BandEqualizer{ - EQPreamp: a.Config.LocalPlayback.EqualizerPreamp, - Disabled: !a.Config.LocalPlayback.EqualizerEnabled, - } - copy(eq.BandGains[:], a.Config.LocalPlayback.GraphicEqualizerBands) - a.LocalPlayer.SetEqualizer(eq) - */ + eq := &mpv.ISO15BandEqualizer{ + EQPreamp: a.Config.LocalPlayback.EqualizerPreamp, + Disabled: !a.Config.LocalPlayback.EqualizerEnabled, + } + copy(eq.BandGains[:], a.Config.LocalPlayback.GraphicEqualizerBands) + a.LocalPlayer.SetEqualizer(eq) return nil } diff --git a/backend/playbackengine.go b/backend/playbackengine.go index d7a644c..dd7f6a1 100644 --- a/backend/playbackengine.go +++ b/backend/playbackengine.go @@ -58,6 +58,9 @@ type playbackEngine struct { noIncrementNextTrackChange bool // true iff the nowPlayingIndex should not be incremented on the next onTrackChange alreadyScrobbled bool // true iff the previously-playing track was already scrobbled + pendingPlayerChange bool + pendingPlayerChangeTimePos float64 + // to pass to onSongChange listeners; clear once listeners have been called lastScrobbled *mediaprovider.Track scrobbleCfg *ScrobbleConfig @@ -102,23 +105,8 @@ func NewPlaybackEngine( case "One": pm.loopMode = LoopOne } - p.OnTrackChange(pm.handleOnTrackChange) - p.OnSeek(func() { - pm.doUpdateTimePos(true) - pm.invokeNoArgCallbacks(pm.onSeek) - }) - p.OnStopped(pm.handleOnStopped) - p.OnPaused(func() { - pm.playTimeStopwatch.Stop() - pm.stopPollTimePos() - pm.invokeNoArgCallbacks(pm.onPaused) - }) - p.OnPlaying(func() { - pm.playTimeStopwatch.Start() - pm.startPollTimePos() - pm.invokeNoArgCallbacks(pm.onPlaying) - }) + pm.registerPlayerCallbacks(p) s.OnLogout(func() { pm.StopAndClearPlayQueue() }) @@ -126,6 +114,51 @@ func NewPlaybackEngine( return pm } +func (p *playbackEngine) registerPlayerCallbacks(pl player.BasePlayer) { + pl.OnTrackChange(p.handleOnTrackChange) + pl.OnSeek(func() { + p.doUpdateTimePos(true) + p.invokeNoArgCallbacks(p.onSeek) + }) + pl.OnStopped(p.handleOnStopped) + pl.OnPaused(func() { + p.playTimeStopwatch.Stop() + p.stopPollTimePos() + p.invokeNoArgCallbacks(p.onPaused) + }) + pl.OnPlaying(func() { + p.playTimeStopwatch.Start() + p.startPollTimePos() + p.invokeNoArgCallbacks(p.onPlaying) + }) +} + +func (p *playbackEngine) SetPlayer(pl player.BasePlayer) { + needToUnpause := false + + stat := p.player.GetStatus() + switch stat.State { + case player.Stopped: + // nothing + case player.Playing: + p.Pause() + fallthrough + case player.Paused: + p.pendingPlayerChangeTimePos = stat.TimePos + p.pendingPlayerChange = true + needToUnpause = true + } + + p.player = pl + p.registerPlayerCallbacks(pl) + + if needToUnpause { + p.PlayTrackAt(p.nowPlayingIdx) + p.SeekSeconds(p.pendingPlayerChangeTimePos) + p.pendingPlayerChange = false + } +} + func (p *playbackEngine) PlayTrackAt(idx int) error { if idx < 0 || idx >= len(p.playQueue) { return errors.New("track index out of range") @@ -238,6 +271,15 @@ func (p *playbackEngine) Pause() error { } func (p *playbackEngine) Continue() error { + if p.pendingPlayerChange { + err := p.PlayTrackAt(p.nowPlayingIdx) + if p.pendingPlayerChangeTimePos != 0 { + p.SeekSeconds(p.pendingPlayerChangeTimePos) + } + p.pendingPlayerChange = false + return err + } + if p.PlayerStatus().State == player.Stopped { return p.PlayTrackAt(0) } diff --git a/backend/playbackmanager.go b/backend/playbackmanager.go index 95debbb..d66a580 100644 --- a/backend/playbackmanager.go +++ b/backend/playbackmanager.go @@ -7,12 +7,16 @@ import ( "math/rand" "runtime" "slices" + "sync" "time" "github.com/dweymouth/supersonic/backend/mediaprovider" "github.com/dweymouth/supersonic/backend/player" + "github.com/dweymouth/supersonic/backend/player/dlna" "github.com/dweymouth/supersonic/backend/player/mpv" "github.com/dweymouth/supersonic/sharedutil" + "github.com/supersonic-app/go-upnpcast/device" + "github.com/supersonic-app/go-upnpcast/services" ) // A high-level MediaProvider-aware playback engine, serves as an @@ -22,11 +26,21 @@ type PlaybackManager struct { cmdQueue *playbackCommandQueue cfg *AppConfig + localPlayer player.BasePlayer + remotePlayersLock sync.Mutex + remotePlayers []remotePlayer + autoplay bool lastPlayTime float64 } +type remotePlayer struct { + Name string + Protocol string + new func() (player.BasePlayer, error) +} + func NewPlaybackManager( ctx context.Context, s *ServerManager, @@ -39,10 +53,11 @@ func NewPlaybackManager( e := NewPlaybackEngine(ctx, s, p, playbackCfg, scrobbleCfg, transcodeCfg) q := NewCommandQueue() pm := &PlaybackManager{ - engine: e, - cmdQueue: q, - cfg: appCfg, - autoplay: playbackCfg.Autoplay, + engine: e, + cmdQueue: q, + cfg: appCfg, + autoplay: playbackCfg.Autoplay, + localPlayer: p, } pm.addOnTrackChangeHook() go pm.runCmdQueue(ctx) @@ -81,6 +96,47 @@ func (p *PlaybackManager) addOnTrackChangeHook() { }) } +func (p *PlaybackManager) ScanRemotePlayers(ctx context.Context) { + devices, _ := device.SearchMediaRenderers(ctx, 10, services.AVTransport, services.RenderingControl) + + var discovered []remotePlayer + for _, d := range devices { + p := remotePlayer{ + Name: d.FriendlyName, + Protocol: "DLNA", + new: func() (player.BasePlayer, error) { + return dlna.NewDLNAPlayer(d) + }, + } + discovered = append(discovered, p) + } + + p.remotePlayersLock.Lock() + p.remotePlayers = discovered + p.remotePlayersLock.Unlock() +} + +func (p *PlaybackManager) RemotePlayers() []remotePlayer { + p.remotePlayersLock.Lock() + players := p.remotePlayers + p.remotePlayersLock.Unlock() + return players +} + +func (p *PlaybackManager) SetRemotePlayer(rp *remotePlayer) error { + if rp == nil { + p.engine.SetPlayer(p.localPlayer) + return nil + } + + player, err := rp.new() + if err != nil { + return err + } + p.engine.SetPlayer(player) + return nil +} + func (p *PlaybackManager) CurrentPlayer() player.BasePlayer { return p.engine.CurrentPlayer() } diff --git a/backend/player/player.go b/backend/player/player.go index 778c941..ec65dcf 100644 --- a/backend/player/player.go +++ b/backend/player/player.go @@ -84,66 +84,66 @@ func (r ReplayGainMode) String() string { } type BasePlayerCallbackImpl struct { - onPaused []func() - onStopped []func() - onPlaying []func() - onSeek []func() - onTrackChange []func() + onPaused func() + onStopped func() + onPlaying func() + onSeek func() + onTrackChange func() } -// Registers a callback which is invoked when the player transitions to the Paused state. +// Sets a callback which is invoked when the player transitions to the Paused state. func (p *BasePlayerCallbackImpl) OnPaused(cb func()) { - p.onPaused = append(p.onPaused, cb) + p.onPaused = cb } -// Registers a callback which is invoked when the player transitions to the Stopped state. +// Sets a callback which is invoked when the player transitions to the Stopped state. func (p *BasePlayerCallbackImpl) OnStopped(cb func()) { - p.onStopped = append(p.onStopped, cb) + p.onStopped = cb } -// Registers a callback which is invoked when the player transitions to the Playing state. +// Sets a callback which is invoked when the player transitions to the Playing state. func (p *BasePlayerCallbackImpl) OnPlaying(cb func()) { - p.onPlaying = append(p.onPlaying, cb) + p.onPlaying = cb } // Registers a callback which is invoked whenever a seek event occurs. func (p *BasePlayerCallbackImpl) OnSeek(cb func()) { - p.onSeek = append(p.onSeek, cb) + p.onSeek = 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). func (p *BasePlayerCallbackImpl) OnTrackChange(cb func()) { - p.onTrackChange = append(p.onTrackChange, cb) + p.onTrackChange = cb } func (p *BasePlayerCallbackImpl) InvokeOnPaused() { - for _, cb := range p.onPaused { - cb() + if p.onPaused != nil { + p.onPaused() } } func (p *BasePlayerCallbackImpl) InvokeOnPlaying() { - for _, cb := range p.onPlaying { - cb() + if p.onPlaying != nil { + p.onPlaying() } } func (p *BasePlayerCallbackImpl) InvokeOnStopped() { - for _, cb := range p.onStopped { - cb() + if p.onStopped != nil { + p.onStopped() } } func (p *BasePlayerCallbackImpl) InvokeOnSeek() { - for _, cb := range p.onSeek { - cb() + if p.onSeek != nil { + p.onSeek() } } func (p *BasePlayerCallbackImpl) InvokeOnTrackChange() { - for _, cb := range p.onTrackChange { - cb() + if p.onTrackChange != nil { + p.onTrackChange() } } diff --git a/ui/controller/controller.go b/ui/controller/controller.go index 1ef9529..d08dfde 100644 --- a/ui/controller/controller.go +++ b/ui/controller/controller.go @@ -16,6 +16,8 @@ import ( fynetooltip "github.com/dweymouth/fyne-tooltip" "github.com/dweymouth/supersonic/backend" "github.com/dweymouth/supersonic/backend/mediaprovider" + "github.com/dweymouth/supersonic/backend/player" + "github.com/dweymouth/supersonic/backend/player/mpv" "github.com/dweymouth/supersonic/sharedutil" "github.com/dweymouth/supersonic/ui/dialogs" myTheme "github.com/dweymouth/supersonic/ui/theme" @@ -268,56 +270,56 @@ func (c *Controller) ShowAboutDialog() { } func (c *Controller) ShowSettingsDialog(themeUpdateCallbk func(), themeFiles map[string]string) { - /* - devs, err := c.App.LocalPlayer.ListAudioDevices() - if err != nil { - log.Printf("error listing audio devices: %v", err) - devs = []mpv.AudioDevice{{Name: "auto", Description: lang.L("Autoselect device")}} - } - curPlayer := c.App.PlaybackManager.CurrentPlayer() - _, isReplayGainPlayer := curPlayer.(player.ReplayGainPlayer) - _, isEqualizerPlayer := curPlayer.(*mpv.Player) - _, canSavePlayQueue := c.App.ServerManager.Server.(mediaprovider.CanSavePlayQueue) - isLocalPlayer := isEqualizerPlayer - bands := c.App.LocalPlayer.Equalizer().BandFrequencies() + devs, err := c.App.LocalPlayer.ListAudioDevices() + if err != nil { + log.Printf("error listing audio devices: %v", err) + devs = []mpv.AudioDevice{{Name: "auto", Description: lang.L("Autoselect device")}} + } + + curPlayer := c.App.PlaybackManager.CurrentPlayer() + _, isReplayGainPlayer := curPlayer.(player.ReplayGainPlayer) + _, isEqualizerPlayer := curPlayer.(*mpv.Player) + _, canSavePlayQueue := c.App.ServerManager.Server.(mediaprovider.CanSavePlayQueue) + isLocalPlayer := isEqualizerPlayer + bands := c.App.LocalPlayer.Equalizer().BandFrequencies() + + dlg := dialogs.NewSettingsDialog(c.App.Config, + devs, themeFiles, bands, + c.App.ServerManager.Server.ClientDecidesScrobble(), + isLocalPlayer, isReplayGainPlayer, isEqualizerPlayer, canSavePlayQueue, + c.MainWindow) + dlg.OnReplayGainSettingsChanged = func() { + c.App.PlaybackManager.SetReplayGainOptions(c.App.Config.ReplayGain) + } + dlg.OnAudioExclusiveSettingChanged = func() { + c.App.LocalPlayer.SetAudioExclusive(c.App.Config.LocalPlayback.AudioExclusive) + } + dlg.OnAudioDeviceSettingChanged = func() { + c.App.LocalPlayer.SetAudioDevice(c.App.Config.LocalPlayback.AudioDeviceName) + } + dlg.OnThemeSettingChanged = themeUpdateCallbk + dlg.OnEqualizerSettingsChanged = func() { + // currently we only have one equalizer type + eq := c.App.LocalPlayer.Equalizer().(*mpv.ISO15BandEqualizer) + eq.Disabled = !c.App.Config.LocalPlayback.EqualizerEnabled + eq.EQPreamp = c.App.Config.LocalPlayback.EqualizerPreamp + copy(eq.BandGains[:], c.App.Config.LocalPlayback.GraphicEqualizerBands) + c.App.LocalPlayer.SetEqualizer(eq) + } + dlg.OnPageNeedsRefresh = c.RefreshPageFunc + pop := widget.NewModalPopUp(dlg, c.MainWindow.Canvas()) + fynetooltip.AddPopUpToolTipLayer(pop) + dlg.OnDismiss = func() { + pop.Hide() + fynetooltip.DestroyPopUpToolTipLayer(pop) + c.doModalClosed() + c.App.SaveConfigFile() + } + c.ClosePopUpOnEscape(pop) + c.haveModal = true + pop.Show() - dlg := dialogs.NewSettingsDialog(c.App.Config, - devs, themeFiles, bands, - c.App.ServerManager.Server.ClientDecidesScrobble(), - isLocalPlayer, isReplayGainPlayer, isEqualizerPlayer, canSavePlayQueue, - c.MainWindow) - dlg.OnReplayGainSettingsChanged = func() { - c.App.PlaybackManager.SetReplayGainOptions(c.App.Config.ReplayGain) - } - dlg.OnAudioExclusiveSettingChanged = func() { - c.App.LocalPlayer.SetAudioExclusive(c.App.Config.LocalPlayback.AudioExclusive) - } - dlg.OnAudioDeviceSettingChanged = func() { - c.App.LocalPlayer.SetAudioDevice(c.App.Config.LocalPlayback.AudioDeviceName) - } - dlg.OnThemeSettingChanged = themeUpdateCallbk - dlg.OnEqualizerSettingsChanged = func() { - // currently we only have one equalizer type - eq := c.App.LocalPlayer.Equalizer().(*mpv.ISO15BandEqualizer) - eq.Disabled = !c.App.Config.LocalPlayback.EqualizerEnabled - eq.EQPreamp = c.App.Config.LocalPlayback.EqualizerPreamp - copy(eq.BandGains[:], c.App.Config.LocalPlayback.GraphicEqualizerBands) - c.App.LocalPlayer.SetEqualizer(eq) - } - dlg.OnPageNeedsRefresh = c.RefreshPageFunc - pop := widget.NewModalPopUp(dlg, c.MainWindow.Canvas()) - fynetooltip.AddPopUpToolTipLayer(pop) - dlg.OnDismiss = func() { - pop.Hide() - fynetooltip.DestroyPopUpToolTipLayer(pop) - c.doModalClosed() - c.App.SaveConfigFile() - } - c.ClosePopUpOnEscape(pop) - c.haveModal = true - pop.Show() - */ } func (c *Controller) doModalClosed() { diff --git a/ui/controller/visualizations.go b/ui/controller/visualizations.go index 1e043fb..4fbfdae 100644 --- a/ui/controller/visualizations.go +++ b/ui/controller/visualizations.go @@ -7,6 +7,7 @@ import ( "fyne.io/fyne/v2" "fyne.io/fyne/v2/lang" "github.com/dweymouth/supersonic/backend/player" + "github.com/dweymouth/supersonic/backend/player/mpv" "github.com/dweymouth/supersonic/ui/shortcuts" "github.com/dweymouth/supersonic/ui/util" "github.com/dweymouth/supersonic/ui/visualizations" @@ -21,11 +22,13 @@ type visualizationData struct { } func (c *Controller) initVisualizations() { - c.App.LocalPlayer.OnStopped(c.stopVisualizationAnim) - c.App.LocalPlayer.OnPaused(c.stopVisualizationAnim) - c.App.LocalPlayer.OnPlaying(func() { - if c.peakMeter != nil { - c.startVisualizationAnim() + c.App.PlaybackManager.OnStopped(c.stopVisualizationAnim) + c.App.PlaybackManager.OnPaused(c.stopVisualizationAnim) + c.App.PlaybackManager.OnPlaying(func() { + if _, ok := c.App.PlaybackManager.CurrentPlayer().(*mpv.Player); ok { + if c.peakMeter != nil { + c.startVisualizationAnim() + } } }) } From 1ad45a33db96042c6e9bc6e2480a8831b25ac07d Mon Sep 17 00:00:00 2001 From: Drew Weymouth Date: Fri, 14 Mar 2025 18:51:14 -0700 Subject: [PATCH 13/45] more progress - player switching working --- backend/playbackengine.go | 1 + res/bundled.go | 5 +++++ res/bundled_gen.sh | 1 + res/icons/publicdomain/cast.svg | 4 ++++ ui/bottompanel.go | 6 ++++++ ui/controller/controller.go | 20 ++++++++++++++++++++ ui/theme/theme.go | 1 + ui/widgets/auxcontrols.go | 22 ++++++++++++++++++++-- 8 files changed, 58 insertions(+), 2 deletions(-) create mode 100644 res/icons/publicdomain/cast.svg diff --git a/backend/playbackengine.go b/backend/playbackengine.go index dd7f6a1..397515b 100644 --- a/backend/playbackengine.go +++ b/backend/playbackengine.go @@ -157,6 +157,7 @@ func (p *playbackEngine) SetPlayer(pl player.BasePlayer) { p.SeekSeconds(p.pendingPlayerChangeTimePos) p.pendingPlayerChange = false } + p.invokeNoArgCallbacks(p.onPlayerChange) } func (p *playbackEngine) PlayTrackAt(idx int) error { diff --git a/res/bundled.go b/res/bundled.go index dfb3f95..590f9a0 100644 --- a/res/bundled.go +++ b/res/bundled.go @@ -20,6 +20,11 @@ var ResPlaybuttonPng = &fyne.StaticResource{ StaticContent: []byte( "\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR\x00\x00\x00\x80\x00\x00\x00\x80\b\x03\x00\x00\x00\xf4\xe0\x91\xf9\x00\x00\x00\x04gAMA\x00\x00\xb1\x8f\v\xfca\x05\x00\x00\x00 cHRM\x00\x00z&\x00\x00\x80\x84\x00\x00\xfa\x00\x00\x00\x80\xe8\x00\x00u0\x00\x00\xea`\x00\x00:\x98\x00\x00\x17p\x9c\xbaQ<\x00\x00\x01\xcePLTE\x00\x00\x00\"\xd7)!\xd3(#\xe2+!\xd6( \xce'\"\xd9)!\xd5(\"\xd6)\x1b\xae!!\xd2(\"\xd8)\x1b\xb0!\"\xd8)!\xd5(!\xd5(\"\xd7)!\xd6(!\xd6(!\xd6(!\xd6(\"\xd7)!\xd6(\"\xdb)!\xd6(!\xd6(!\xd6(!\xd6(!\xd6(\"\xd7)\"\xd6)\"\xd6)\"\xd7)!\xd6(!\xd6(!\xd6(!\xd6(!\xd6(\"\xd7)!\xd6(!\xd5(!\xd6(\"\xd7)!\xd6(#\xde*!\xd5(!\xd6(!\xd6(!\xd6( \xcd'!\xd3(!\xd6(!\xd6(!\xd2(!\xd5(!\xd6(!\xd6(!\xd6(!\xd6(\"\xd7)!\xd6(!\xd6(\"\xd7)!\xd6(!\xd5(!\xd6(\"\xd6)\"\xd7)\"\xd6)!\xd6(!\xd6(\"\xd6)!\xd6(!\xd5(!\xd6(\"\xd6)!\xd6(!\xd6(!\xd6(\"\xd6)!\xd5(!\xd6(\"\xd6)\"\xd7)\"\xd7)!\xd6(\"\xd7)!\xd6(!\xd5(\"\xd6)!\xd6(!\xd5(\"\xd7)!\xd6(!\xd6(!\xd6(!\xd6(!\xd5(!\xd6(!\xd4(!\xd6(!\xd5(!\xd6(!\xd6(!\xd5(\"\xd7)\"\xd7)!\xd6(!\xd6(\"\xd7)\"\xd7)\"\xd6)!\xd6(!\xd6(\"\xd7)\"\xd6) \xd6'(\xd8/)\xd80\x1f\xd6&>\xdcD\x97\xec\x9b\xc3\xf4\xc5\xc6\xf4Ǐ\xea\x93?\xdcE\xc5\xf4\xc7\xff\xff\xff\xfe\xfe\xfe\xdc\xf8\xddz\xe6~-\xd94\x97\xec\x9a\xfa\xfe\xfa\xba\xf2\xbcQ\xdfW\xec\xfb\xec\x90\xeb\x944\xda:9\xdb?\xe0\xf9\xe1\xfc\xfe\xfc\xcf\xf6\xd0f\xe3k$\xd7+<\xdbB\xe4\xfa\xe5\xf2\xfc\xf2\xa7\xef\xa9D\xddI\xbf\xf3\xc1&\xd7-\x98원\xea\x93(2|%\x00\x00\x00qtRNS\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01\x04\x141S\x7f\x93\xbe\xcf\xdd\x01\x1cEq\xa6\xcb\xe7\xf8\xfe\v3f\xa9\xd9\xfe\x05'e\xb4\xee\xfd\x01\x11L\xa0\xe4\x01\x16\xc7\xf6\x15o\xd0\xfb\x14\xca\nM\xbf\xfc\x01)\xa1\xf5\ro(\xa4\xfb\x04Q\xd7\x0f\x80\xf0\"\xfc*\xbdCC\xdd\xd6\"\x0e\x03\xfd)\rp\tN\x15\xc9p\xfc\x17\xce\x12Ɵ\n3\xed\xa8\xd8\x03\xa60\x92\x9c\x8e\xa7 \x00\x00\x00\x01bKGD\x7fH\xbfq\xe5\x00\x00\x00\atIME\a\xe7\x01\x13\x01%2\x99\xbf\xbb\v\x00\x00\x04[IDATx\xda\xe5\x9bgC\x13A\x10\x86wÑP\x83\xb4H\rx\x82tA\x9a\"\x1d%H\x93Б\xa2\"Ho\xb7\x89\x82\xf5,\xd8{\x03\xfd\xb7\xe6\x12\x90\x00Iؽ\xdd\xcb|\xf0\xfd\xbe\xf7>\x99\x9d\xdcm\x99A\x88Q8ޚp*1)9%զ\x90=)\xb6Ԕ\xe4\xa4\xc4S\t\xd6x\xcc\xfa<6\xf3\xd3ִ\xf4\x8c̬l{\x0e9\xa6\x1c{vVfFz\x9a\xf5\xb4A\x10\x18瞑\xcf\xe6\xd9\xf3I\b\xe5\xdb\xf3\xce\xcagr\xb1x\x06|\xae\xa0\xb0\xa8\xb8\x84P\xa8\xa4\xb8\xa8\xb0\xe0\x9cP\x04\\Zv\xbe\xbc\xa2\xc4Ec\xaf\xc9URQ~\xbe\xacT\x14\x83\xe9B\xa5\\UMk\xbe\xaf\xea*\xb9\xf2\x82I\x84}DM\xed\xc5K\xac\xf6\x9a.]\xac\xad\x89\xe0E\xc0R\x9d|Y\x97\xbd\x17\xe1\xb2\\'qM\x04\xaeohl\xd2k\xaf\xa9\xa9\xb1\xa1^?\x01nnim\xa3μ\xc0r\xb5\xb5\xb64\xebD\xc0W\xae\x1e\xbc\xec\xf4K\xb1]\xbd\xa2\x87\x00G\xb6;:\xf8\xed5u8\xda#\x99\x11\xf0\xb5\xce.\xce\xe8\xfb\xcdCW\xe75F\x02\xdc\xdd\xd3+\xcc\xdfC\xd0\xdb\xd3\xcdB\x80\xa5\xeb\x8e>q\xf6\x9a\xfa\x1c\xd7\xe9\xff\x90\xd8\xec\xec\x17k\xaf\xa9\xdfi\xa6$\xc0x\xa0K@\xf6\x1f\x95\xd25@\xf7\x91\xc4xpH\xbc\xbd\xa6\xa1A\x1a\x02\xe3\xfc\xe9\b\x8c\xf4\xa7!0\xd6\xffd\x02lq\x0e\x1b\xe9OȰ\xd3\x12\x8a\x00\x8f\xf4\x1b\x90\xff\xfeR\xfaGB\x00H\xa3\x0ec\xed59F\xa5\xa0\xfec=7\x8c\a\xb8\xd13\x16\x84\x00\xe3\xce^\xe3\xfd\t\xe9\xed\f\x92\x88\xd2\xf8\xb0\xc0\xefOp\xb9\x86\xc7\x03\x86@\x9ap\x84\xc5\xdfC\xe0\x98\b@\x80\xa3&\x05\xad?NV\xc7d\xd4\xf1I\xb08m\xe1\xf2'\xc4\xe6\xb4\x1c\x9b\x80\xa9V\x83\xdf\x00\xfeRZ\xa7\x8eLB\xb4\xf9f[\xf8\xfc\ti\xbbi\x8e>\x1c\x80[\x8da\xca@\x9f\\\x8d\xb7\x0e\x85 &V\xe6\xda\x7f\xb0\xabI\x8e\x8d\xf1\x030U\xde\x0e\xaf?!\xb7+\xfd\xb6\x8dX\x9a\xbe\x13n\x80;\xd3~\x8bT\xd3\xccl\xb8\xfd\t\x99\x9d1\x1d\x04@\x0e{\x00ݢ\x8d\x81A\x00\xea6\xed\xc2\xdd(\x80\x9d\xdd\xff\x1d\x00z\n\x80\x93\x90\xe9o\b\xfe\"\x02\x7f\x15\x83\x7f\x8c\xc0?\xc7\xe0\v\x12\xf0%\x19\xf8\xa2\x14|Y\x0e\xbe1\x01ߚ\x81oN\xc1\xb7\xe7\xe0\a\x14\xe0G4\xe0\x87T\xe0\xc7t\xf0\a\x95\xe0G\xb5\xe0\x87\xd5\xcc\xc7\xf5\xbfwv\xfe\b=\xaeg\xbe\xb0\xd8\xdd\x15{a\x01\x7fe\x03~i\x05~m\a\x7fq\t~u\v\x7fy\r~}\x0f_\xc0\x00^\xc2\x01_\xc4\x02_\xc6\x03^\xc8\x04_\xca\x05_\xcc\x06^\xce\a_\xd0\b_\xd2\t_\xd4\n^\xd6\v_\xd8\f_\xda\r_\xdc\x0e_\xde\x0f\xdf\xe0\x00\xdf\xe2\x81\xc0\x9b\\\x10|\x9b\x0f|\xa3\x13\x02o\xf5B\xf0\xcdn\b\xbc\xdd\x0f\xc17<\"\xf0\x96O/\x02lӫw\"`\xdb~}\f\xa0\x8d\xcf>\x04\xd8\xd6o\x1f\x03h\xf3\xfb>\x84\xe0\xf6\xff\xbfN\xd2\xc9+\xf1{\x02!\x00\x00\x00%tEXtdate:create\x002023-01-19T01:37:03+00:00/\x9b0\x17\x00\x00\x00%tEXtdate:modify\x002023-01-19T01:36:39+00:00\x9b\xfb\xbb8\x00\x00\x00\x00IEND\xaeB`\x82"), } +var ResCastSvg = &fyne.StaticResource{ + StaticName: "cast.svg", + StaticContent: []byte( + "\n\n \n"), +} var ResDiscSvg = &fyne.StaticResource{ StaticName: "disc.svg", StaticContent: []byte( diff --git a/res/bundled_gen.sh b/res/bundled_gen.sh index a963c50..67c52fa 100755 --- a/res/bundled_gen.sh +++ b/res/bundled_gen.sh @@ -3,6 +3,7 @@ fyne bundle -package res -prefix Res appicon-256.png > bundled.go fyne bundle -append -prefix Res icons/coreui/playlist-add-next.svg >> bundled.go fyne bundle -append -prefix Res icons/freepik/playbutton.png >> bundled.go +fyne bundle -append -prefix Res icons/publicdomain/cast.svg >> bundled.go 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 diff --git a/res/icons/publicdomain/cast.svg b/res/icons/publicdomain/cast.svg new file mode 100644 index 0000000..0a32d9c --- /dev/null +++ b/res/icons/publicdomain/cast.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/ui/bottompanel.go b/ui/bottompanel.go index 80da01b..cdccac4 100644 --- a/ui/bottompanel.go +++ b/ui/bottompanel.go @@ -3,6 +3,7 @@ package ui import ( "github.com/dweymouth/supersonic/backend" "github.com/dweymouth/supersonic/backend/mediaprovider" + "github.com/dweymouth/supersonic/backend/player/mpv" "github.com/dweymouth/supersonic/ui/controller" "github.com/dweymouth/supersonic/ui/layouts" "github.com/dweymouth/supersonic/ui/util" @@ -111,6 +112,10 @@ func NewBottomPanel(pm *backend.PlaybackManager, im *backend.ImageManager, contr pm.OnVolumeChange(func(vol int) { fyne.Do(func() { bp.AuxControls.VolumeControl.SetVolume(vol) }) }) + pm.OnPlayerChange(func() { + _, local := pm.CurrentPlayer().(*mpv.Player) + fyne.Do(func() { bp.AuxControls.SetIsRemotePlayer(!local) }) + }) bp.AuxControls.VolumeControl.OnSetVolume = func(v int) { pm.SetVolume(v) } @@ -121,6 +126,7 @@ func NewBottomPanel(pm *backend.PlaybackManager, im *backend.ImageManager, contr pm.SetAutoplay(autoplay) } bp.AuxControls.OnShowPlayQueue(contr.ShowPopUpPlayQueue) + bp.AuxControls.OnShowCastMenu(contr.ShowCastMenu) bp.imageLoader = util.NewThumbnailLoader(im, bp.NowPlaying.SetImage) diff --git a/ui/controller/controller.go b/ui/controller/controller.go index d08dfde..c9fff4b 100644 --- a/ui/controller/controller.go +++ b/ui/controller/controller.go @@ -151,6 +151,26 @@ func (m *Controller) HaveModal() bool { return m.haveModal } +func (m *Controller) ShowCastMenu() { + devices := m.App.PlaybackManager.RemotePlayers() + menu := fyne.NewMenu("") + menu.Items = append(menu.Items, fyne.NewMenuItem(lang.L("Local player"), func() { + m.App.PlaybackManager.SetRemotePlayer(nil) + })) + for _, d := range devices { + _d := d + menu.Items = append(menu.Items, fyne.NewMenuItem(d.Name, func() { + m.App.PlaybackManager.SetRemotePlayer(&_d) + })) + } + pop := widget.NewPopUpMenu(menu, m.MainWindow.Canvas()) + canvasSize := m.MainWindow.Canvas().Size() + pop.ShowAtPosition(fyne.NewPos( + canvasSize.Width-pop.MinSize().Width-10, + canvasSize.Height-pop.MinSize().Height-100, + )) +} + func (m *Controller) ShowPopUpPlayQueue() { m.popUpQueueMutex.Lock() if m.popUpQueue == nil { diff --git a/ui/theme/theme.go b/ui/theme/theme.go index a9a1bcc..5df2935 100644 --- a/ui/theme/theme.go +++ b/ui/theme/theme.go @@ -39,6 +39,7 @@ var ( AlbumIcon fyne.Resource = theme.NewThemedResource(res.ResDiscSvg) ArtistIcon fyne.Resource = theme.NewThemedResource(res.ResPeopleSvg) AutoplayIcon fyne.Resource = theme.NewThemedResource(res.ResInfinitySvg) + CastIcon fyne.Resource = theme.NewThemedResource(res.ResCastSvg) 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 ce17cd5..94b4ff4 100644 --- a/ui/widgets/auxcontrols.go +++ b/ui/widgets/auxcontrols.go @@ -25,6 +25,7 @@ type AuxControls struct { VolumeControl *VolumeControl autoplay *IconButton loop *IconButton + cast *IconButton showQueue *IconButton container *fyne.Container @@ -35,10 +36,17 @@ func NewAuxControls(initialVolume int, initialLoopMode backend.LoopMode, initial VolumeControl: NewVolumeControl(initialVolume), autoplay: NewIconButton(myTheme.AutoplayIcon, nil), loop: NewIconButton(myTheme.RepeatIcon, nil), + cast: NewIconButton(myTheme.CastIcon, nil), showQueue: NewIconButton(myTheme.PlayQueueIcon, nil), } + a.loop.IconSize = IconButtonSizeSmaller a.loop.SetToolTip(lang.L("Repeat")) + a.SetLoopMode(initialLoopMode) + + a.cast.IconSize = IconButtonSizeSmaller + a.cast.SetToolTip(lang.L("Cast to device")) + a.autoplay.Highlighted = initialAutoplay //a.autoplay.IconSize = IconButtonSizeSmaller a.autoplay.SetToolTip(lang.L("Autoplay")) @@ -48,9 +56,10 @@ func NewAuxControls(initialVolume int, initialLoopMode backend.LoopMode, initial a.OnChangeAutoplay(a.autoplay.Highlighted) } } - a.SetLoopMode(initialLoopMode) + a.showQueue.IconSize = IconButtonSizeSmaller a.showQueue.SetToolTip(lang.L("Show play queue")) + a.container = container.NewHBox( layout.NewSpacer(), container.NewVBox( @@ -58,7 +67,7 @@ func NewAuxControls(initialVolume int, initialLoopMode backend.LoopMode, initial a.VolumeControl, container.New( layout.NewCustomPaddedHBoxLayout(theme.Padding()*1.5), - layout.NewSpacer(), a.autoplay, a.loop, a.showQueue, util.NewHSpace(5)), + layout.NewSpacer(), a.autoplay, a.loop, a.cast, a.showQueue, util.NewHSpace(5)), layout.NewSpacer(), ), ) @@ -88,6 +97,11 @@ func (a *AuxControls) SetLoopMode(mode backend.LoopMode) { } } +func (a *AuxControls) SetIsRemotePlayer(isRemote bool) { + a.cast.Highlighted = isRemote + a.cast.Refresh() +} + func (a *AuxControls) SetAutoplay(autoplay bool) { if autoplay == a.autoplay.Highlighted { return @@ -100,6 +114,10 @@ func (a *AuxControls) OnShowPlayQueue(f func()) { a.showQueue.OnTapped = f } +func (a *AuxControls) OnShowCastMenu(f func()) { + a.cast.OnTapped = f +} + type volumeSlider struct { widget.Slider From 5a37b1e39fdd54809426ee8495b4164fb49f81f8 Mon Sep 17 00:00:00 2001 From: Drew Weymouth Date: Sat, 15 Mar 2025 14:12:05 -0700 Subject: [PATCH 14/45] gapless DLNA working --- backend/app.go | 4 +- backend/playbackengine.go | 46 ++++++---- backend/player/dlna/dlnaplayer.go | 148 +++++++++++++++++++++--------- backend/player/mpv/player.go | 5 +- backend/player/player.go | 4 +- ui/browsing/nowplayingpage.go | 4 +- 6 files changed, 143 insertions(+), 68 deletions(-) diff --git a/backend/app.go b/backend/app.go index f8e126c..d2fe784 100644 --- a/backend/app.go +++ b/backend/app.go @@ -404,7 +404,7 @@ func (a *App) SetupWindowsSMTC(hwnd uintptr) { }) a.PlaybackManager.OnSeek(func() { dur := a.PlaybackManager.NowPlaying().Metadata().Duration - smtc.UpdatePosition(int(a.PlaybackManager.CurrentPlayer().GetStatus().TimePos*1000), dur*1000) + smtc.UpdatePosition(int(a.PlaybackManager.PlayerStatus().TimePos*1000), dur*1000) }) a.PlaybackManager.OnPlaying(func() { smtc.SetEnabled(true) @@ -465,7 +465,7 @@ func (a *App) Shutdown() { a.PlaybackManager.DisableCallbacks() a.PlaybackManager.Stop() // will trigger scrobble check a.cancel() - //a.LocalPlayer.Destroy() + a.LocalPlayer.Destroy() } func (a *App) SavePlayQueueIfEnabled() { diff --git a/backend/playbackengine.go b/backend/playbackengine.go index 397515b..ecd7719 100644 --- a/backend/playbackengine.go +++ b/backend/playbackengine.go @@ -54,9 +54,13 @@ type playbackEngine struct { loopMode LoopMode // flags for handleOnTrackChange / handleOnStopped callbacks - reset to false in the callbacks - wasStopped bool // true iff player was stopped before handleOnTrackChange invocation - noIncrementNextTrackChange bool // true iff the nowPlayingIndex should not be incremented on the next onTrackChange - alreadyScrobbled bool // true iff the previously-playing track was already scrobbled + wasStopped bool // true iff player was stopped before handleOnTrackChange invocation + alreadyScrobbled bool // true iff the previously-playing track was already scrobbled + + // if >= 0, track number that was requested by PlayTrackAt + // onTrackChange callback should set nowPlayingIdx to this, + // and reset this to -1 + pendingTrackChangeNum int pendingPlayerChange bool pendingPlayerChangeTimePos float64 @@ -136,7 +140,7 @@ func (p *playbackEngine) registerPlayerCallbacks(pl player.BasePlayer) { func (p *playbackEngine) SetPlayer(pl player.BasePlayer) { needToUnpause := false - stat := p.player.GetStatus() + stat := p.PlayerStatus() switch stat.State { case player.Stopped: // nothing @@ -149,6 +153,7 @@ func (p *playbackEngine) SetPlayer(pl player.BasePlayer) { needToUnpause = true } + oldVol := p.player.GetVolume() p.player = pl p.registerPlayerCallbacks(pl) @@ -158,6 +163,12 @@ func (p *playbackEngine) SetPlayer(pl player.BasePlayer) { p.pendingPlayerChange = false } p.invokeNoArgCallbacks(p.onPlayerChange) + vol := pl.GetVolume() + if oldVol != vol { + for _, cb := range p.onVolumeChange { + cb(vol) + } + } } func (p *playbackEngine) PlayTrackAt(idx int) error { @@ -167,11 +178,8 @@ func (p *playbackEngine) PlayTrackAt(idx int) error { // scrobble current track if needed p.checkScrobble() p.alreadyScrobbled = true - p.noIncrementNextTrackChange = true + p.pendingTrackChangeNum = idx err := p.setTrack(idx, false) - if err == nil { - p.nowPlayingIdx = idx - } return err } @@ -222,14 +230,14 @@ func (p *playbackEngine) CurrentPlayer() player.BasePlayer { } func (p *playbackEngine) SeekNext() error { - if p.CurrentPlayer().GetStatus().State == player.Stopped { + if p.PlayerStatus().State == player.Stopped { return nil } return p.PlayTrackAt(p.nowPlayingIdx + 1) } func (p *playbackEngine) SeekBackOrPrevious() error { - if p.nowPlayingIdx == 0 || p.player.GetStatus().TimePos > 3 { + if p.nowPlayingIdx == 0 || p.PlayerStatus().TimePos > 3 { return p.player.SeekSeconds(0) } return p.PlayTrackAt(p.nowPlayingIdx - 1) @@ -237,7 +245,7 @@ func (p *playbackEngine) SeekBackOrPrevious() error { func (p *playbackEngine) SeekFwdBackN(n int) error { idx := p.nowPlayingIdx - if n < 0 && p.player.GetStatus().TimePos > 3 { + if n < 0 && p.PlayerStatus().TimePos > 3 { n += 1 // first seek back is just seek to beginning of current } if n == 0 || (idx == 0 && n < 0) { @@ -511,21 +519,23 @@ func (p *playbackEngine) handleOnTrackChange() { p.checkScrobble() } - if p.player.GetStatus().State == player.Playing { + if p.PlayerStatus().State == player.Playing { p.playTimeStopwatch.Start() } - if !p.noIncrementNextTrackChange && (p.wasStopped || p.loopMode != LoopOne) { + if p.pendingTrackChangeNum < 0 && (p.wasStopped || p.loopMode != LoopOne) { p.nowPlayingIdx++ if p.loopMode == LoopAll && p.nowPlayingIdx == len(p.playQueue) { p.nowPlayingIdx = 0 // wrapped around } + } else if p.pendingTrackChangeNum >= 0 { + p.nowPlayingIdx = p.pendingTrackChangeNum + p.pendingTrackChangeNum = -1 } nowPlaying := p.playQueue[p.nowPlayingIdx] _, isRadio := nowPlaying.(*mediaprovider.RadioStation) p.isRadio = isRadio // reset flags - p.noIncrementNextTrackChange = false p.wasStopped = false p.alreadyScrobbled = false @@ -594,9 +604,11 @@ func (p *playbackEngine) setNextTrackAfterQueueUpdate() { func (p *playbackEngine) setTrack(idx int, next bool) error { if urlP, ok := p.player.(player.URLPlayer); ok { url := "" + var meta mediaprovider.MediaItemMetadata if idx >= 0 { var err error item := p.playQueue[idx] + meta = item.Metadata() if tr, ok := item.(*mediaprovider.Track); ok { url, err = p.sm.Server.GetStreamURL(tr.ID, p.transcodeCfg.ForceRawFile) } else { @@ -607,9 +619,9 @@ func (p *playbackEngine) setTrack(idx int, next bool) error { } } if next { - return urlP.SetNextFile(url) + return urlP.SetNextFile(url, meta) } - return urlP.PlayFile(url) + return urlP.PlayFile(url, meta) } else if trP, ok := p.player.(player.TrackPlayer); ok { var track *mediaprovider.Track if idx >= 0 { @@ -744,7 +756,7 @@ func (p *playbackEngine) doUpdateTimePos(seeked bool) { if p.callbacksDisabled { return } - s := p.player.GetStatus() + s := p.PlayerStatus() if s.TimePos > p.latestTrackPosition { p.latestTrackPosition = s.TimePos } diff --git a/backend/player/dlna/dlnaplayer.go b/backend/player/dlna/dlnaplayer.go index 23b6bb9..b013a29 100644 --- a/backend/player/dlna/dlnaplayer.go +++ b/backend/player/dlna/dlnaplayer.go @@ -2,6 +2,8 @@ package dlna import ( "context" + "crypto/md5" + "encoding/base64" "errors" "fmt" "io" @@ -9,12 +11,16 @@ import ( "net" "net/http" "os" + "strings" "sync" "sync/atomic" + "time" + "github.com/dweymouth/supersonic/backend/mediaprovider" "github.com/dweymouth/supersonic/backend/player" "github.com/supersonic-app/go-upnpcast/device" "github.com/supersonic-app/go-upnpcast/services/avtransport" + "github.com/supersonic-app/go-upnpcast/services/renderingcontrol" ) const ( @@ -28,10 +34,25 @@ var unimplemented = errors.New("unimplemented") type DLNAPlayer struct { player.BasePlayerCallbackImpl - avTransport *avtransport.Client + avTransport *avtransport.Client + renderControl *renderingcontrol.Client state int // stopped, playing, paused seeking bool + + curTrackMeta mediaprovider.MediaItemMetadata + nextTrackMeta mediaprovider.MediaItemMetadata + + lastSeekSecs int + seekedAt time.Time + + proxyServer *http.Server + proxyActive atomic.Bool + localIP string + proxyPort int + + proxyURLLock sync.Mutex + proxyURLs map[string]string } func NewDLNAPlayer(device *device.MediaRenderer) (*DLNAPlayer, error) { @@ -39,29 +60,38 @@ func NewDLNAPlayer(device *device.MediaRenderer) (*DLNAPlayer, error) { if err != nil { return nil, err } - return &DLNAPlayer{avTransport: avt}, nil + rc, err := device.RenderingControlClient() + if err != nil { + return nil, err + } + return &DLNAPlayer{ + avTransport: avt, + renderControl: rc, + proxyURLs: make(map[string]string), + }, nil } func (d *DLNAPlayer) SetVolume(vol int) error { - return unimplemented + return d.renderControl.SetVolume(context.Background(), vol) } func (d *DLNAPlayer) GetVolume() int { - return 0 + vol, _ := d.renderControl.GetVolume(context.Background()) + return vol } -func (d *DLNAPlayer) PlayFile(urlstr string) error { - ensureSetupProxies() +func (d *DLNAPlayer) PlayFile(urlstr string, meta mediaprovider.MediaItemMetadata) error { + d.ensureSetupProxy() - proxyURLLock.Lock() - dlnaProxyCurrent.url = urlstr - proxyURLLock.Unlock() + log.Println("playing track " + meta.Name) + + d.curTrackMeta = meta + key := d.addURLToProxy(urlstr) media := avtransport.MediaItem{ - URL: "http://" + localIP + ":8080/current", - Title: "Supersonic media item", + URL: d.urlForItem(key), + Title: meta.Name, } - log.Printf("URL %s", media.URL) err := d.avTransport.SetAVTransportMedia(context.Background(), &media) if err != nil { @@ -71,26 +101,41 @@ func (d *DLNAPlayer) PlayFile(urlstr string) error { return err } d.state = playing + d.seekedAt = time.Now() + d.lastSeekSecs = 0 d.InvokeOnPlaying() + d.InvokeOnTrackChange() return nil } -func (d *DLNAPlayer) SetNextFile(url string) error { +func (d *DLNAPlayer) SetNextFile(url string, meta mediaprovider.MediaItemMetadata) error { var media *avtransport.MediaItem + d.nextTrackMeta = meta if url != "" { - ensureSetupProxies() - - proxyURLLock.Lock() - dlnaProxyCurrent.url = url - proxyURLLock.Unlock() + d.ensureSetupProxy() + key := d.addURLToProxy(url) media = &avtransport.MediaItem{ - URL: "http://" + localIP + ":8080/next", + URL: d.urlForItem(key), + Title: meta.Name, } } return d.avTransport.SetNextAVTransportMedia(context.Background(), media) } +func (d *DLNAPlayer) addURLToProxy(url string) string { + hash := md5.Sum([]byte(url)) + key := base64.StdEncoding.EncodeToString(hash[:]) + d.proxyURLLock.Lock() + d.proxyURLs[key] = url + d.proxyURLLock.Unlock() + return key +} + +func (d *DLNAPlayer) urlForItem(key string) string { + return fmt.Sprintf("http://%s:%d/%s", d.localIP, d.proxyPort, key) +} + func (d *DLNAPlayer) Continue() error { if err := d.avTransport.Play(context.Background()); err != nil { return err @@ -125,6 +170,8 @@ func (d *DLNAPlayer) SeekSeconds(secs float64) error { return err } d.seeking = false + d.seekedAt = time.Now() + d.lastSeekSecs = int(secs) d.InvokeOnSeek() return nil } @@ -141,10 +188,12 @@ func (d *DLNAPlayer) GetStatus() player.Status { state = player.Paused } - // TODO - the rest + time := time.Now().Sub(d.seekedAt) + time.Duration(d.lastSeekSecs)*time.Second return player.Status{ - State: state, + State: state, + TimePos: time.Seconds(), + Duration: float64(d.curTrackMeta.Duration), } } @@ -175,38 +224,51 @@ func getLocalIP() (string, error) { return "", fmt.Errorf("no suitable interface found") } -var ( - localIP string - proxyURLLock sync.Mutex - dlnaProxyCurrent proxy - dlnaProxyNext proxy - proxyActive atomic.Bool -) - -func ensureSetupProxies() { - if proxyActive.Swap(true) { - return // already active +func (d *DLNAPlayer) ensureSetupProxy() error { + if d.proxyActive.Swap(true) { + return nil // already active } - localIP, _ = getLocalIP() - log.Println(localIP) + var err error + d.localIP, err = getLocalIP() + if err != nil { + return err + } - mux := http.NewServeMux() - mux.HandleFunc("/current", dlnaProxyCurrent.handleRequest) - mux.HandleFunc("/next", dlnaProxyNext.handleRequest) - go http.ListenAndServe(":8080", mux) + listener, err := net.Listen("tcp", ":0") + if err != nil { + return err + } + d.proxyPort = listener.Addr().(*net.TCPAddr).Port + + d.proxyServer = &http.Server{ + Handler: http.HandlerFunc(d.handleRequest), + } + + go d.proxyServer.Serve(listener) + return nil } type proxy struct { url string } -func (p *proxy) handleRequest(w http.ResponseWriter, r *http.Request) { +func (d *DLNAPlayer) handleRequest(w http.ResponseWriter, r *http.Request) { + var url string + key := strings.TrimPrefix(r.URL.Path, "/") + d.proxyURLLock.Lock() + if u, ok := d.proxyURLs[key]; ok { + url = u + } + d.proxyURLLock.Unlock() + + if url == "" { + w.WriteHeader(http.StatusNotFound) + w.Write([]byte("404")) + return + } + // Create a new request to the target server - proxyURLLock.Lock() - url := p.url - proxyURLLock.Unlock() - log.Println("Got request for " + url) proxyReq, err := http.NewRequest(r.Method, url, r.Body) if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) diff --git a/backend/player/mpv/player.go b/backend/player/mpv/player.go index e97535b..4b2ab55 100644 --- a/backend/player/mpv/player.go +++ b/backend/player/mpv/player.go @@ -7,6 +7,7 @@ import ( "math" "strconv" + "github.com/dweymouth/supersonic/backend/mediaprovider" "github.com/dweymouth/supersonic/backend/player" "github.com/supersonic-app/go-mpv" ) @@ -133,7 +134,7 @@ func (p *Player) Init(maxCacheMB int) error { } // Plays the specified file, clearing the previous play queue, if any. -func (p *Player) PlayFile(url string) error { +func (p *Player) PlayFile(url string, _ mediaprovider.MediaItemMetadata) error { if !p.initialized { return ErrUnitialized } @@ -169,7 +170,7 @@ func (p *Player) Stop() error { return err } -func (p *Player) SetNextFile(url string) error { +func (p *Player) SetNextFile(url string, _ mediaprovider.MediaItemMetadata) error { if p.lenPlaylist > p.curPlaylistPos+1 { if err := p.mpv.Command([]string{"playlist-remove", strconv.Itoa(int(p.curPlaylistPos) + 1)}); err != nil { return err diff --git a/backend/player/player.go b/backend/player/player.go index ec65dcf..eb855cc 100644 --- a/backend/player/player.go +++ b/backend/player/player.go @@ -4,8 +4,8 @@ import "github.com/dweymouth/supersonic/backend/mediaprovider" type URLPlayer interface { BasePlayer - PlayFile(url string) error - SetNextFile(url string) error + PlayFile(url string, metadata mediaprovider.MediaItemMetadata) error + SetNextFile(url string, metadata mediaprovider.MediaItemMetadata) error } type TrackPlayer interface { diff --git a/ui/browsing/nowplayingpage.go b/ui/browsing/nowplayingpage.go index fd331f0..ae539e5 100644 --- a/ui/browsing/nowplayingpage.go +++ b/ui/browsing/nowplayingpage.go @@ -443,7 +443,7 @@ func (a *NowPlayingPage) Reload() { } switch a.tabs.SelectedIndex() { case 1: /*lyrics*/ - a.lastPlayPos = a.pm.CurrentPlayer().GetStatus().TimePos + a.lastPlayPos = a.pm.PlayerStatus().TimePos a.updateLyrics() case 2: /*related*/ a.updateRelatedList() @@ -527,7 +527,7 @@ func (a *NowPlayingPage) saveSelectedTab(tabNum int) { func (a *NowPlayingPage) formatStatusLine() { curPlayer := a.pm.CurrentPlayer() - playerStats := curPlayer.GetStatus() + playerStats := a.pm.PlayerStatus() lastStatus := a.statusLabel.Text stopped := lang.L("Stopped") state := stopped From e5c8027a82c2938837f41bc72ed7b5455f192a27 Mon Sep 17 00:00:00 2001 From: Drew Weymouth Date: Sat, 15 Mar 2025 14:46:17 -0700 Subject: [PATCH 15/45] shutdown proxy server when switching back to local; debounce vol slider --- backend/playbackengine.go | 4 ++++ backend/player/dlna/dlnaplayer.go | 6 ++++++ backend/player/jukebox/jukeboxplayer.go | 2 ++ backend/player/player.go | 2 ++ ui/widgets/auxcontrols.go | 21 +++++++++++++++++++++ 5 files changed, 35 insertions(+) diff --git a/backend/playbackengine.go b/backend/playbackengine.go index ecd7719..9ae9a5d 100644 --- a/backend/playbackengine.go +++ b/backend/playbackengine.go @@ -9,6 +9,7 @@ import ( "github.com/dweymouth/supersonic/backend/mediaprovider" "github.com/dweymouth/supersonic/backend/player" + "github.com/dweymouth/supersonic/backend/player/mpv" "github.com/dweymouth/supersonic/backend/util" "github.com/dweymouth/supersonic/sharedutil" ) @@ -154,6 +155,9 @@ func (p *playbackEngine) SetPlayer(pl player.BasePlayer) { } oldVol := p.player.GetVolume() + if _, isMPV := p.player.(*mpv.Player); !isMPV { + p.player.Destroy() + } p.player = pl p.registerPlayerCallbacks(pl) diff --git a/backend/player/dlna/dlnaplayer.go b/backend/player/dlna/dlnaplayer.go index b013a29..b9410ac 100644 --- a/backend/player/dlna/dlnaplayer.go +++ b/backend/player/dlna/dlnaplayer.go @@ -197,6 +197,12 @@ func (d *DLNAPlayer) GetStatus() player.Status { } } +func (d *DLNAPlayer) Destroy() { + if d.proxyServer != nil { + go d.proxyServer.Shutdown(context.Background()) + } +} + func getLocalIP() (string, error) { interfaces, err := net.Interfaces() if err != nil { diff --git a/backend/player/jukebox/jukeboxplayer.go b/backend/player/jukebox/jukeboxplayer.go index 3518beb..c3db780 100644 --- a/backend/player/jukebox/jukeboxplayer.go +++ b/backend/player/jukebox/jukeboxplayer.go @@ -140,6 +140,8 @@ func (j *JukeboxPlayer) GetStatus() player.Status { } } +func (j *JukeboxPlayer) Destroy() {} + func (j *JukeboxPlayer) startAndUpdateTime() error { beforeStart := time.Now() if err := j.provider.JukeboxStart(); err != nil { diff --git a/backend/player/player.go b/backend/player/player.go index eb855cc..9c941a5 100644 --- a/backend/player/player.go +++ b/backend/player/player.go @@ -27,6 +27,8 @@ type BasePlayer interface { GetStatus() Status + Destroy() + // Event API OnPaused(func()) OnStopped(func()) diff --git a/ui/widgets/auxcontrols.go b/ui/widgets/auxcontrols.go index 94b4ff4..bb50172 100644 --- a/ui/widgets/auxcontrols.go +++ b/ui/widgets/auxcontrols.go @@ -2,6 +2,7 @@ package widgets import ( "math" + "time" "fyne.io/fyne/v2" "fyne.io/fyne/v2/container" @@ -163,6 +164,10 @@ type VolumeControl struct { muted bool lastVol int + setVolDebouncer func() + delaySetVolume bool + pendingVolume int + container *fyne.Container } @@ -178,6 +183,15 @@ func NewVolumeControl(initialVol int) *VolumeControl { v.slider.Orientation = widget.Horizontal v.slider.Value = float64(v.lastVol) v.slider.OnChanged = v.onChanged + + // for players that are slow to respond to volume changes + // (e.g. DLNA), delay responding to the SetVolume call + // to avoid hiccuping from callback "echoes" + v.setVolDebouncer = util.NewDebouncer(100*time.Millisecond, func() { + v.delaySetVolume = false + v.SetVolume(v.pendingVolume) + }) + v.container = container.NewHBox(container.NewCenter(v.icon), v.slider) return v } @@ -185,6 +199,11 @@ func NewVolumeControl(initialVol int) *VolumeControl { // Sets the volume that is displayed in the slider. // Does not invoke OnSetVolume callback. func (v *VolumeControl) SetVolume(vol int) { + if v.delaySetVolume { + v.pendingVolume = vol + return + } + if (vol == v.lastVol && !v.muted) || (v.muted && vol == 0) { return } @@ -195,6 +214,8 @@ func (v *VolumeControl) SetVolume(vol int) { func (v *VolumeControl) onChanged(volume float64) { vol := int(volume) + v.delaySetVolume = true + v.setVolDebouncer() v.lastVol = vol v.muted = false v.updateIconForVolume(vol) From 436df0f1996c28b5494547071960a05b07546d9c Mon Sep 17 00:00:00 2001 From: Drew Weymouth Date: Sun, 16 Mar 2025 08:54:18 -0700 Subject: [PATCH 16/45] reorganize; add stopwatch to dlna player for play time tracking --- backend/playbackengine.go | 2 + backend/player/dlna/dlnaplayer.go | 80 ++++++++++++------------------- backend/util/util.go | 29 +++++++++++ 3 files changed, 61 insertions(+), 50 deletions(-) diff --git a/backend/playbackengine.go b/backend/playbackengine.go index 9ae9a5d..3cd5854 100644 --- a/backend/playbackengine.go +++ b/backend/playbackengine.go @@ -163,6 +163,8 @@ func (p *playbackEngine) SetPlayer(pl player.BasePlayer) { if needToUnpause { p.PlayTrackAt(p.nowPlayingIdx) + // TODO - find a better way to wait until the players are ready + time.Sleep(200 * time.Millisecond) p.SeekSeconds(p.pendingPlayerChangeTimePos) p.pendingPlayerChange = false } diff --git a/backend/player/dlna/dlnaplayer.go b/backend/player/dlna/dlnaplayer.go index b9410ac..49279c4 100644 --- a/backend/player/dlna/dlnaplayer.go +++ b/backend/player/dlna/dlnaplayer.go @@ -18,6 +18,7 @@ import ( "github.com/dweymouth/supersonic/backend/mediaprovider" "github.com/dweymouth/supersonic/backend/player" + "github.com/dweymouth/supersonic/backend/util" "github.com/supersonic-app/go-upnpcast/device" "github.com/supersonic-app/go-upnpcast/services/avtransport" "github.com/supersonic-app/go-upnpcast/services/renderingcontrol" @@ -43,8 +44,8 @@ type DLNAPlayer struct { curTrackMeta mediaprovider.MediaItemMetadata nextTrackMeta mediaprovider.MediaItemMetadata - lastSeekSecs int - seekedAt time.Time + lastStartTime int + stopwatch util.Stopwatch proxyServer *http.Server proxyActive atomic.Bool @@ -101,8 +102,9 @@ func (d *DLNAPlayer) PlayFile(urlstr string, meta mediaprovider.MediaItemMetadat return err } d.state = playing - d.seekedAt = time.Now() - d.lastSeekSecs = 0 + d.stopwatch.Reset() + d.stopwatch.Start() + d.lastStartTime = 0 d.InvokeOnPlaying() d.InvokeOnTrackChange() return nil @@ -123,24 +125,12 @@ func (d *DLNAPlayer) SetNextFile(url string, meta mediaprovider.MediaItemMetadat return d.avTransport.SetNextAVTransportMedia(context.Background(), media) } -func (d *DLNAPlayer) addURLToProxy(url string) string { - hash := md5.Sum([]byte(url)) - key := base64.StdEncoding.EncodeToString(hash[:]) - d.proxyURLLock.Lock() - d.proxyURLs[key] = url - d.proxyURLLock.Unlock() - return key -} - -func (d *DLNAPlayer) urlForItem(key string) string { - return fmt.Sprintf("http://%s:%d/%s", d.localIP, d.proxyPort, key) -} - func (d *DLNAPlayer) Continue() error { if err := d.avTransport.Play(context.Background()); err != nil { return err } d.state = playing + d.stopwatch.Start() d.InvokeOnPlaying() return nil } @@ -149,6 +139,7 @@ func (d *DLNAPlayer) Pause() error { if err := d.avTransport.Pause(context.Background()); err != nil { return err } + d.stopwatch.Stop() d.state = paused d.InvokeOnPaused() return nil @@ -158,6 +149,8 @@ func (d *DLNAPlayer) Stop() error { if err := d.avTransport.Pause(context.Background()); err != nil { return err } + d.stopwatch.Reset() + d.lastStartTime = 0 d.state = stopped d.InvokeOnStopped() return nil @@ -170,8 +163,13 @@ func (d *DLNAPlayer) SeekSeconds(secs float64) error { return err } d.seeking = false - d.seekedAt = time.Now() - d.lastSeekSecs = int(secs) + + d.lastStartTime = int(secs) + d.stopwatch.Reset() + if d.state == playing { + d.stopwatch.Start() + } + d.InvokeOnSeek() return nil } @@ -188,7 +186,7 @@ func (d *DLNAPlayer) GetStatus() player.Status { state = player.Paused } - time := time.Now().Sub(d.seekedAt) + time.Duration(d.lastSeekSecs)*time.Second + time := time.Duration(d.lastStartTime)*time.Second + d.stopwatch.Elapsed() return player.Status{ State: state, @@ -203,40 +201,13 @@ func (d *DLNAPlayer) Destroy() { } } -func getLocalIP() (string, error) { - interfaces, err := net.Interfaces() - if err != nil { - return "", err - } - - for _, iface := range interfaces { - if iface.Flags&net.FlagUp == 0 || iface.Flags&net.FlagLoopback != 0 { - continue - } - - addrs, err := iface.Addrs() - if err != nil { - return "", err - } - - for _, addr := range addrs { - ipnet, ok := addr.(*net.IPNet) - if ok && !ipnet.IP.IsLoopback() && ipnet.IP.To4() != nil { - return ipnet.IP.String(), nil - } - } - } - - return "", fmt.Errorf("no suitable interface found") -} - func (d *DLNAPlayer) ensureSetupProxy() error { if d.proxyActive.Swap(true) { return nil // already active } var err error - d.localIP, err = getLocalIP() + d.localIP, err = util.GetLocalIP() if err != nil { return err } @@ -255,8 +226,17 @@ func (d *DLNAPlayer) ensureSetupProxy() error { return nil } -type proxy struct { - url string +func (d *DLNAPlayer) addURLToProxy(url string) string { + hash := md5.Sum([]byte(url)) + key := base64.StdEncoding.EncodeToString(hash[:]) + d.proxyURLLock.Lock() + d.proxyURLs[key] = url + d.proxyURLLock.Unlock() + return key +} + +func (d *DLNAPlayer) urlForItem(key string) string { + return fmt.Sprintf("http://%s:%d/%s", d.localIP, d.proxyPort, key) } func (d *DLNAPlayer) handleRequest(w http.ResponseWriter, r *http.Request) { diff --git a/backend/util/util.go b/backend/util/util.go index 9111db8..bf573c8 100644 --- a/backend/util/util.go +++ b/backend/util/util.go @@ -1,7 +1,9 @@ package util import ( + "fmt" "io" + "net" "os" ) @@ -21,3 +23,30 @@ func CopyFile(srcPath, dstPath string) error { _, err = io.Copy(fout, fin) return err } + +func GetLocalIP() (string, error) { + interfaces, err := net.Interfaces() + if err != nil { + return "", err + } + + for _, iface := range interfaces { + if iface.Flags&net.FlagUp == 0 || iface.Flags&net.FlagLoopback != 0 { + continue + } + + addrs, err := iface.Addrs() + if err != nil { + return "", err + } + + for _, addr := range addrs { + ipnet, ok := addr.(*net.IPNet) + if ok && !ipnet.IP.IsLoopback() && ipnet.IP.To4() != nil { + return ipnet.IP.String(), nil + } + } + } + + return "", fmt.Errorf("no suitable interface found") +} From 417b1b262d5807378b606021768fc6229112bad5 Mon Sep 17 00:00:00 2001 From: Drew Weymouth Date: Sun, 16 Mar 2025 10:21:27 -0700 Subject: [PATCH 17/45] add timer to fire track change event --- backend/player/dlna/dlnaplayer.go | 101 +++++++++++++++++++++++++++++- 1 file changed, 98 insertions(+), 3 deletions(-) diff --git a/backend/player/dlna/dlnaplayer.go b/backend/player/dlna/dlnaplayer.go index 49279c4..5886af6 100644 --- a/backend/player/dlna/dlnaplayer.go +++ b/backend/player/dlna/dlnaplayer.go @@ -41,6 +41,7 @@ type DLNAPlayer struct { state int // stopped, playing, paused seeking bool + metaLock sync.Mutex curTrackMeta mediaprovider.MediaItemMetadata nextTrackMeta mediaprovider.MediaItemMetadata @@ -54,6 +55,10 @@ type DLNAPlayer struct { proxyURLLock sync.Mutex proxyURLs map[string]string + + timerActive atomic.Bool + timer *time.Timer + resetChan chan (time.Duration) } func NewDLNAPlayer(device *device.MediaRenderer) (*DLNAPlayer, error) { @@ -69,6 +74,7 @@ func NewDLNAPlayer(device *device.MediaRenderer) (*DLNAPlayer, error) { avTransport: avt, renderControl: rc, proxyURLs: make(map[string]string), + resetChan: make(chan time.Duration), }, nil } @@ -86,7 +92,9 @@ func (d *DLNAPlayer) PlayFile(urlstr string, meta mediaprovider.MediaItemMetadat log.Println("playing track " + meta.Name) + d.metaLock.Lock() d.curTrackMeta = meta + d.metaLock.Unlock() key := d.addURLToProxy(urlstr) media := avtransport.MediaItem{ @@ -102,6 +110,7 @@ func (d *DLNAPlayer) PlayFile(urlstr string, meta mediaprovider.MediaItemMetadat return err } d.state = playing + d.setTrackChangeTimer(time.Duration(meta.Duration) * time.Second) d.stopwatch.Reset() d.stopwatch.Start() d.lastStartTime = 0 @@ -112,7 +121,9 @@ func (d *DLNAPlayer) PlayFile(urlstr string, meta mediaprovider.MediaItemMetadat func (d *DLNAPlayer) SetNextFile(url string, meta mediaprovider.MediaItemMetadata) error { var media *avtransport.MediaItem + d.metaLock.Lock() d.nextTrackMeta = meta + d.metaLock.Unlock() if url != "" { d.ensureSetupProxy() @@ -129,7 +140,11 @@ func (d *DLNAPlayer) Continue() error { if err := d.avTransport.Play(context.Background()); err != nil { return err } + d.metaLock.Lock() + nextTrackChange := time.Duration(d.curTrackMeta.Duration)*time.Second - d.curPlayPos() + d.metaLock.Unlock() d.state = playing + d.setTrackChangeTimer(nextTrackChange) d.stopwatch.Start() d.InvokeOnPlaying() return nil @@ -139,6 +154,7 @@ func (d *DLNAPlayer) Pause() error { if err := d.avTransport.Pause(context.Background()); err != nil { return err } + d.setTrackChangeTimer(0) d.stopwatch.Stop() d.state = paused d.InvokeOnPaused() @@ -149,6 +165,7 @@ func (d *DLNAPlayer) Stop() error { if err := d.avTransport.Pause(context.Background()); err != nil { return err } + d.setTrackChangeTimer(0) d.stopwatch.Reset() d.lastStartTime = 0 d.state = stopped @@ -164,6 +181,10 @@ func (d *DLNAPlayer) SeekSeconds(secs float64) error { } d.seeking = false + d.metaLock.Lock() + nextTrackChange := time.Duration(d.curTrackMeta.Duration)*time.Second - time.Duration(secs)*time.Second + d.metaLock.Unlock() + d.setTrackChangeTimer(nextTrackChange) d.lastStartTime = int(secs) d.stopwatch.Reset() if d.state == playing { @@ -186,15 +207,17 @@ func (d *DLNAPlayer) GetStatus() player.Status { state = player.Paused } - time := time.Duration(d.lastStartTime)*time.Second + d.stopwatch.Elapsed() - return player.Status{ State: state, - TimePos: time.Seconds(), + TimePos: d.curPlayPos().Seconds(), Duration: float64(d.curTrackMeta.Duration), } } +func (d *DLNAPlayer) curPlayPos() time.Duration { + return time.Duration(d.lastStartTime)*time.Second + d.stopwatch.Elapsed() +} + func (d *DLNAPlayer) Destroy() { if d.proxyServer != nil { go d.proxyServer.Shutdown(context.Background()) @@ -235,6 +258,78 @@ func (d *DLNAPlayer) addURLToProxy(url string) string { return key } +func (d *DLNAPlayer) setTrackChangeTimer(dur time.Duration) { + if d.timerActive.Swap(true) { + // was active + log.Println("timer was active") + d.resetChan <- dur + log.Println("and reset") + return + } + if dur == 0 { + d.timerActive.Store(false) + return + } + log.Println("starting timer") + + d.timer = time.NewTimer(dur) + go func() { + for { + select { + case dur := <-d.resetChan: + if dur == 0 { + d.timerActive.Store(false) + if !d.timer.Stop() { + select { + case <-d.timer.C: + default: + } + } + d.timer = nil + return + } + // reset the timer + if !d.timer.Stop() { + select { + case <-d.timer.C: + default: + } + } + d.timer.Reset(dur) + case <-d.timer.C: + d.timerActive.Store(false) + d.timer = nil + d.handleOnTrackChange() + return + } + } + }() +} + +func (d *DLNAPlayer) handleOnTrackChange() { + stopping := false + d.metaLock.Lock() + if d.nextTrackMeta.ID == "" { + stopping = true + } + d.curTrackMeta = d.nextTrackMeta + d.nextTrackMeta = mediaprovider.MediaItemMetadata{} + nextTrackChange := time.Duration(d.curTrackMeta.Duration) * time.Second + d.metaLock.Unlock() + + if stopping { + d.lastStartTime = 0 + d.stopwatch.Reset() + d.InvokeOnStopped() + } else { + d.lastStartTime = 0 + d.stopwatch.Reset() + d.stopwatch.Start() + d.setTrackChangeTimer(nextTrackChange) + d.InvokeOnTrackChange() + } +} + func (d *DLNAPlayer) urlForItem(key string) string { return fmt.Sprintf("http://%s:%d/%s", d.localIP, d.proxyPort, key) } From 7828614d913e1a2901b3fd5e64a01892ce9051ca Mon Sep 17 00:00:00 2001 From: Drew Weymouth Date: Mon, 17 Mar 2025 08:53:40 -0700 Subject: [PATCH 18/45] add HTTP retries for DLNA player --- backend/app.go | 4 ++-- backend/playbackmanager.go | 4 ++-- backend/player/dlna/dlnaplayer.go | 7 +++++++ go.mod | 3 +++ go.sum | 4 ++++ 5 files changed, 18 insertions(+), 4 deletions(-) diff --git a/backend/app.go b/backend/app.go index d2fe784..6b233b8 100644 --- a/backend/app.go +++ b/backend/app.go @@ -153,16 +153,16 @@ func StartupApp(appName, displayAppName, appVersion, appVersionTag, latestReleas } // Periodically scan for remote players + go a.PlaybackManager.ScanRemotePlayers(a.bgrndCtx, 5 /*waitSec*/) go func() { t := time.NewTicker(5 * time.Minute) for { - a.PlaybackManager.ScanRemotePlayers(a.bgrndCtx) select { case <-a.bgrndCtx.Done(): t.Stop() return case <-t.C: - continue + a.PlaybackManager.ScanRemotePlayers(a.bgrndCtx, 10 /*waitSec*/) } } }() diff --git a/backend/playbackmanager.go b/backend/playbackmanager.go index d66a580..60df166 100644 --- a/backend/playbackmanager.go +++ b/backend/playbackmanager.go @@ -96,8 +96,8 @@ func (p *PlaybackManager) addOnTrackChangeHook() { }) } -func (p *PlaybackManager) ScanRemotePlayers(ctx context.Context) { - devices, _ := device.SearchMediaRenderers(ctx, 10, services.AVTransport, services.RenderingControl) +func (p *PlaybackManager) ScanRemotePlayers(ctx context.Context, waitSec int) { + devices, _ := device.SearchMediaRenderers(ctx, waitSec, services.AVTransport, services.RenderingControl) var discovered []remotePlayer for _, d := range devices { diff --git a/backend/player/dlna/dlnaplayer.go b/backend/player/dlna/dlnaplayer.go index 5886af6..aa51a0a 100644 --- a/backend/player/dlna/dlnaplayer.go +++ b/backend/player/dlna/dlnaplayer.go @@ -19,6 +19,7 @@ import ( "github.com/dweymouth/supersonic/backend/mediaprovider" "github.com/dweymouth/supersonic/backend/player" "github.com/dweymouth/supersonic/backend/util" + "github.com/hashicorp/go-retryablehttp" "github.com/supersonic-app/go-upnpcast/device" "github.com/supersonic-app/go-upnpcast/services/avtransport" "github.com/supersonic-app/go-upnpcast/services/renderingcontrol" @@ -62,14 +63,20 @@ type DLNAPlayer struct { } func NewDLNAPlayer(device *device.MediaRenderer) (*DLNAPlayer, error) { + retry := retryablehttp.NewClient() + retry.RetryMax = 3 + cli := retry.StandardClient() + avt, err := device.AVTransportClient() if err != nil { return nil, err } + avt.HTTPClient = cli rc, err := device.RenderingControlClient() if err != nil { return nil, err } + rc.HTTPClient = cli return &DLNAPlayer{ avTransport: avt, renderControl: rc, diff --git a/go.mod b/go.mod index 9e487dc..93c831c 100644 --- a/go.mod +++ b/go.mod @@ -41,6 +41,8 @@ require ( github.com/go-text/render v0.2.0 // indirect github.com/go-text/typesetting v0.2.1 // indirect github.com/h2non/filetype v1.1.3 // indirect + github.com/hashicorp/go-cleanhttp v0.5.2 // indirect + github.com/hashicorp/go-retryablehttp v0.7.7 // indirect github.com/jeandeaual/go-locale v0.0.0-20241217141322-fcc2cadd6f08 // indirect github.com/jsummers/gobmp v0.0.0-20230614200233-a9de23ed2e25 // indirect github.com/koron/go-ssdp v0.0.4 // indirect @@ -57,4 +59,5 @@ require ( ) replace github.com/supersonic-app/go-upnpcast v0.0.0-20250312000014-e4f7242a07ce => ../go-upnpcast + replace fyne.io/fyne/v2 v2.6.0-beta1 => github.com/dweymouth/fyne/v2 v2.3.0-rc1.0.20250308154116-c32a0de49bde diff --git a/go.sum b/go.sum index e8a2382..a28ff24 100644 --- a/go.sum +++ b/go.sum @@ -59,6 +59,10 @@ github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I= github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/h2non/filetype v1.1.3 h1:FKkx9QbD7HR/zjK1Ia5XiBsq9zdLi5Kf3zGyFTAFkGg= github.com/h2non/filetype v1.1.3/go.mod h1:319b3zT68BvV+WRj7cwy856M2ehB3HqNOt6sy1HndBY= +github.com/hashicorp/go-cleanhttp v0.5.2 h1:035FKYIWjmULyFRBKPs8TBQoi0x6d9G4xc9neXJWAZQ= +github.com/hashicorp/go-cleanhttp v0.5.2/go.mod h1:kO/YDlP8L1346E6Sodw+PrpBSV4/SoxCXGY6BqNFT48= +github.com/hashicorp/go-retryablehttp v0.7.7 h1:C8hUCYzor8PIfXHa4UrZkU4VvK8o9ISHxT2Q8+VepXU= +github.com/hashicorp/go-retryablehttp v0.7.7/go.mod h1:pkQpWZeYWskR+D1tR2O5OcBFOxfA7DoAO6xtkuQnHTk= github.com/jeandeaual/go-locale v0.0.0-20241217141322-fcc2cadd6f08 h1:wMeVzrPO3mfHIWLZtDcSaGAe2I4PW9B/P5nMkRSwCAc= github.com/jeandeaual/go-locale v0.0.0-20241217141322-fcc2cadd6f08/go.mod h1:ZDXo8KHryOWSIqnsb/CiDq7hQUYryCgdVnxbj8tDG7o= github.com/jsummers/gobmp v0.0.0-20230614200233-a9de23ed2e25 h1:YLvr1eE6cdCqjOe972w/cYF+FjW34v27+9Vo5106B4M= From 9ad40a1c2daeacda2d4bd8a2c5e21474f8a78cd7 Mon Sep 17 00:00:00 2001 From: Drew Weymouth Date: Tue, 18 Mar 2025 08:56:10 -0700 Subject: [PATCH 19/45] get rid of go.mod `replace` directive for local dev --- go.mod | 2 -- 1 file changed, 2 deletions(-) diff --git a/go.mod b/go.mod index 93c831c..1fbe980 100644 --- a/go.mod +++ b/go.mod @@ -58,6 +58,4 @@ require ( gopkg.in/yaml.v3 v3.0.1 // indirect ) -replace github.com/supersonic-app/go-upnpcast v0.0.0-20250312000014-e4f7242a07ce => ../go-upnpcast - replace fyne.io/fyne/v2 v2.6.0-beta1 => github.com/dweymouth/fyne/v2 v2.3.0-rc1.0.20250308154116-c32a0de49bde From ff455528d6c7507c4b82b618a529e38f5a2ab413 Mon Sep 17 00:00:00 2001 From: Drew Weymouth Date: Tue, 18 Mar 2025 17:49:27 -0700 Subject: [PATCH 20/45] shorten retry time, clean up logging --- backend/playbackmanager.go | 8 ++++---- backend/player/dlna/dlnaplayer.go | 28 +++++++++++++++++++++++----- 2 files changed, 27 insertions(+), 9 deletions(-) diff --git a/backend/playbackmanager.go b/backend/playbackmanager.go index 60df166..b4bece3 100644 --- a/backend/playbackmanager.go +++ b/backend/playbackmanager.go @@ -570,7 +570,7 @@ func (p *PlaybackManager) enqueueAutoplayTracks() { 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) + log.Printf("autoplay error: failed to get similar tracks: %v", err) } tracks = filterRecentlyPlayed(similar) } @@ -583,7 +583,7 @@ func (p *PlaybackManager) enqueueAutoplayTracks() { } byGenre, err := s.GetRandomTracks(g, p.cfg.EnqueueBatchSize) if err != nil { - log.Println("autoplay error: failed to get tracks by genre: %v", err) + log.Printf("autoplay error: failed to get tracks by genre: %v", err) } tracks = filterRecentlyPlayed(byGenre) if len(tracks) > 0 { @@ -598,7 +598,7 @@ func (p *PlaybackManager) enqueueAutoplayTracks() { // 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) + log.Printf("autoplay error: failed to get random tracks: %v", err) } tracks = filterRecentlyPlayed(random) } @@ -612,7 +612,7 @@ func (p *PlaybackManager) enqueueAutoplayTracks() { func (p *PlaybackManager) runCmdQueue(ctx context.Context) { logIfErr := func(action string, err error) { if err != nil { - log.Println("Playback error (%s): %v", action, err) + log.Printf("Playback error (%s): %v", action, err) } } for { diff --git a/backend/player/dlna/dlnaplayer.go b/backend/player/dlna/dlnaplayer.go index aa51a0a..cc32c5d 100644 --- a/backend/player/dlna/dlnaplayer.go +++ b/backend/player/dlna/dlnaplayer.go @@ -65,6 +65,8 @@ type DLNAPlayer struct { func NewDLNAPlayer(device *device.MediaRenderer) (*DLNAPlayer, error) { retry := retryablehttp.NewClient() retry.RetryMax = 3 + retry.RetryWaitMin = 100 * time.Millisecond + retry.Logger = retryLogger{} cli := retry.StandardClient() avt, err := device.AVTransportClient() @@ -97,8 +99,6 @@ func (d *DLNAPlayer) GetVolume() int { func (d *DLNAPlayer) PlayFile(urlstr string, meta mediaprovider.MediaItemMetadata) error { d.ensureSetupProxy() - log.Println("playing track " + meta.Name) - d.metaLock.Lock() d.curTrackMeta = meta d.metaLock.Unlock() @@ -268,16 +268,13 @@ func (d *DLNAPlayer) addURLToProxy(url string) string { func (d *DLNAPlayer) setTrackChangeTimer(dur time.Duration) { if d.timerActive.Swap(true) { // was active - log.Println("timer was active") d.resetChan <- dur - log.Println("and reset") return } if dur == 0 { d.timerActive.Store(false) return } - log.Println("starting timer") d.timer = time.NewTimer(dur) go func() { @@ -391,3 +388,24 @@ func (d *DLNAPlayer) handleRequest(w http.ResponseWriter, r *http.Request) { fmt.Fprintln(os.Stderr, "Error copying response body:", err) } } + +type retryLogger struct{} + +func (retryLogger) Error(msg string, keysAndValues ...interface{}) { + log.Println(msg, keysAndValues) +} + +func (retryLogger) Info(msg string, keysAndValues ...interface{}) { + log.Println(msg, keysAndValues) +} + +func (retryLogger) Warn(msg string, keysAndValues ...interface{}) { + log.Println(msg, keysAndValues) +} + +func (retryLogger) Debug(msg string, keysAndValues ...interface{}) { + // log only retries, not every request + if strings.Contains(msg, "retrying request") { + log.Println(msg, keysAndValues) + } +} From 6a7f06769db45799e1193569e861f79846411186 Mon Sep 17 00:00:00 2001 From: Drew Weymouth Date: Tue, 18 Mar 2025 17:58:15 -0700 Subject: [PATCH 21/45] ensure casting playback stops when quitting app --- backend/app.go | 2 +- backend/playbackcommands.go | 16 ++++++++++++---- backend/playbackmanager.go | 7 +++++++ 3 files changed, 20 insertions(+), 5 deletions(-) diff --git a/backend/app.go b/backend/app.go index 6b233b8..d2486bb 100644 --- a/backend/app.go +++ b/backend/app.go @@ -463,7 +463,7 @@ func (a *App) Shutdown() { a.WinSMTC.Shutdown() } a.PlaybackManager.DisableCallbacks() - a.PlaybackManager.Stop() // will trigger scrobble check + a.PlaybackManager.Shutdown() // will trigger scrobble check a.cancel() a.LocalPlayer.Destroy() } diff --git a/backend/playbackcommands.go b/backend/playbackcommands.go index cb06459..2d40a1b 100644 --- a/backend/playbackcommands.go +++ b/backend/playbackcommands.go @@ -31,10 +31,11 @@ const ( ) type playbackCommand struct { - Type playbackCommandType - Arg any - Arg2 any - Arg3 any + Type playbackCommandType + Arg any + Arg2 any + Arg3 any + OnDone func() } // playbackCommandQueue is a queue to accumulate player commands from the UI @@ -65,6 +66,13 @@ func (c *playbackCommandQueue) Stop() { playbackCommand{Type: cmdStop}) } +func (c *playbackCommandQueue) StopAndWait() { + done := make(chan struct{}) + c.filterCommandsAndAdd([]playbackCommandType{cmdContinue, cmdPause, cmdStop}, + playbackCommand{Type: cmdStop, OnDone: func() { close(done) }}) + <-done +} + func (c *playbackCommandQueue) Continue() { c.filterCommandsAndAdd([]playbackCommandType{cmdContinue, cmdPause, cmdStop}, playbackCommand{Type: cmdContinue}) diff --git a/backend/playbackmanager.go b/backend/playbackmanager.go index b4bece3..bf24a9a 100644 --- a/backend/playbackmanager.go +++ b/backend/playbackmanager.go @@ -512,6 +512,10 @@ func (p *PlaybackManager) Stop() { p.cmdQueue.Stop() } +func (p *PlaybackManager) Shutdown() { + p.cmdQueue.StopAndWait() +} + func (p *PlaybackManager) Pause() { p.cmdQueue.Pause() } @@ -662,6 +666,9 @@ func (p *PlaybackManager) runCmdQueue(ctx context.Context) { mpv.ForceRestartPlayback() } } + if c.OnDone != nil { + c.OnDone() + } } } } From d4c1b560d59f215e401bf3ee61886c8b95ac3c8e Mon Sep 17 00:00:00 2001 From: Drew Weymouth Date: Tue, 18 Mar 2025 18:11:58 -0700 Subject: [PATCH 22/45] do fast scan for remote players on startup --- backend/app.go | 4 ++-- backend/playbackmanager.go | 10 +++++++++- 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/backend/app.go b/backend/app.go index d2486bb..55ca333 100644 --- a/backend/app.go +++ b/backend/app.go @@ -153,7 +153,7 @@ func StartupApp(appName, displayAppName, appVersion, appVersionTag, latestReleas } // Periodically scan for remote players - go a.PlaybackManager.ScanRemotePlayers(a.bgrndCtx, 5 /*waitSec*/) + go a.PlaybackManager.ScanRemotePlayers(a.bgrndCtx, true /*fastScan*/) go func() { t := time.NewTicker(5 * time.Minute) for { @@ -162,7 +162,7 @@ func StartupApp(appName, displayAppName, appVersion, appVersionTag, latestReleas t.Stop() return case <-t.C: - a.PlaybackManager.ScanRemotePlayers(a.bgrndCtx, 10 /*waitSec*/) + a.PlaybackManager.ScanRemotePlayers(a.bgrndCtx, false) } } }() diff --git a/backend/playbackmanager.go b/backend/playbackmanager.go index bf24a9a..4d27e5f 100644 --- a/backend/playbackmanager.go +++ b/backend/playbackmanager.go @@ -96,7 +96,15 @@ func (p *PlaybackManager) addOnTrackChangeHook() { }) } -func (p *PlaybackManager) ScanRemotePlayers(ctx context.Context, waitSec int) { +func (p *PlaybackManager) ScanRemotePlayers(ctx context.Context, fastScan bool) { + if fastScan { + p.scanRemotePlayers(ctx, 1 /*waitSec*/) + // continue to slow scan to detect players that take longer to respond + } + p.scanRemotePlayers(ctx, 10 /*waitSec*/) +} + +func (p *PlaybackManager) scanRemotePlayers(ctx context.Context, waitSec int) { devices, _ := device.SearchMediaRenderers(ctx, waitSec, services.AVTransport, services.RenderingControl) var discovered []remotePlayer From e4c187b4df7abbddd9b8cba733e86a1e7d9c6770 Mon Sep 17 00:00:00 2001 From: Drew Weymouth Date: Wed, 19 Mar 2025 17:14:12 -0700 Subject: [PATCH 23/45] keep only 3 most recently used proxy URLs active --- backend/player/dlna/dlnaplayer.go | 74 ++++++++++++++++++++++++------- 1 file changed, 57 insertions(+), 17 deletions(-) diff --git a/backend/player/dlna/dlnaplayer.go b/backend/player/dlna/dlnaplayer.go index cc32c5d..3127f8d 100644 --- a/backend/player/dlna/dlnaplayer.go +++ b/backend/player/dlna/dlnaplayer.go @@ -33,6 +33,11 @@ const ( var unimplemented = errors.New("unimplemented") +type proxyMapEntry struct { + key string + url string +} + type DLNAPlayer struct { player.BasePlayerCallbackImpl @@ -54,8 +59,12 @@ type DLNAPlayer struct { localIP string proxyPort int + // keep in order of most recently accessed at the end + // that way the item in proxyURLs[0] can be kicked out + // when adding a new URL to the proxy, since + // only two will need to be active at any given time + proxyURLs [3]proxyMapEntry proxyURLLock sync.Mutex - proxyURLs map[string]string timerActive atomic.Bool timer *time.Timer @@ -82,7 +91,6 @@ func NewDLNAPlayer(device *device.MediaRenderer) (*DLNAPlayer, error) { return &DLNAPlayer{ avTransport: avt, renderControl: rc, - proxyURLs: make(map[string]string), resetChan: make(chan time.Duration), }, nil } @@ -256,15 +264,6 @@ func (d *DLNAPlayer) ensureSetupProxy() error { return nil } -func (d *DLNAPlayer) addURLToProxy(url string) string { - hash := md5.Sum([]byte(url)) - key := base64.StdEncoding.EncodeToString(hash[:]) - d.proxyURLLock.Lock() - d.proxyURLs[key] = url - d.proxyURLLock.Unlock() - return key -} - func (d *DLNAPlayer) setTrackChangeTimer(dur time.Duration) { if d.timerActive.Swap(true) { // was active @@ -339,13 +338,8 @@ func (d *DLNAPlayer) urlForItem(key string) string { } func (d *DLNAPlayer) handleRequest(w http.ResponseWriter, r *http.Request) { - var url string key := strings.TrimPrefix(r.URL.Path, "/") - d.proxyURLLock.Lock() - if u, ok := d.proxyURLs[key]; ok { - url = u - } - d.proxyURLLock.Unlock() + url, _ := d.lookupProxyURL(key) if url == "" { w.WriteHeader(http.StatusNotFound) @@ -389,6 +383,52 @@ func (d *DLNAPlayer) handleRequest(w http.ResponseWriter, r *http.Request) { } } +func (d *DLNAPlayer) addURLToProxy(url string) string { + hash := md5.Sum([]byte(url)) + key := base64.StdEncoding.EncodeToString(hash[:]) + d.proxyURLLock.Lock() + defer d.proxyURLLock.Unlock() + d._updateProxyURL(key, url) + return key +} + +// lookupProxyURL finds a URL by key and updates its position to most recently used +func (d *DLNAPlayer) lookupProxyURL(key string) (string, bool) { + d.proxyURLLock.Lock() + defer d.proxyURLLock.Unlock() + + for i := 0; i < len(d.proxyURLs); i++ { + if d.proxyURLs[i].key == key { + url := d.proxyURLs[i].url + // Move accessed entry to the most recent position + d._updateProxyURL(key, url) + return url, true + } + } + + return "", false +} + +func (d *DLNAPlayer) _updateProxyURL(key, url string) { + // Check if the key already exists, and if so, move it to the most recently used position + for i := 0; i < len(d.proxyURLs); i++ { + if d.proxyURLs[i].key == key { + if i < len(d.proxyURLs)-1 { + // Shift elements to the left from found position to the end + copy(d.proxyURLs[i:], d.proxyURLs[i+1:]) + } + // Place updated entry at the last position + d.proxyURLs[len(d.proxyURLs)-1] = proxyMapEntry{key: key, url: url} + return + } + } + + // Shift all elements left to make room for the new entry at the end + copy(d.proxyURLs[:], d.proxyURLs[1:]) + // Insert new element at the most recent position + d.proxyURLs[len(d.proxyURLs)-1] = proxyMapEntry{key: key, url: url} +} + type retryLogger struct{} func (retryLogger) Error(msg string, keysAndValues ...interface{}) { From 379309c27f933d9582ec991c7771a6b4cd7267d2 Mon Sep 17 00:00:00 2001 From: Drew Weymouth Date: Thu, 20 Mar 2025 19:52:26 -0700 Subject: [PATCH 24/45] handle case where SetNextAVTransportURI fails --- backend/player/dlna/dlnaplayer.go | 45 ++++++++++++++++++++++++++----- 1 file changed, 39 insertions(+), 6 deletions(-) diff --git a/backend/player/dlna/dlnaplayer.go b/backend/player/dlna/dlnaplayer.go index 3127f8d..5bbca30 100644 --- a/backend/player/dlna/dlnaplayer.go +++ b/backend/player/dlna/dlnaplayer.go @@ -66,6 +66,14 @@ type DLNAPlayer struct { proxyURLs [3]proxyMapEntry proxyURLLock sync.Mutex + // If SetNextAVTransport fails (e.g. because the device + // does not support the API/gapless), this flag is set + // true, and the next firing of the track change timer + // should clear it to false and use SetAVTransport + // to begin playing the item in nextTrackMeta. + failedToSetNext bool + unsetNextMediaItem *avtransport.MediaItem + timerActive atomic.Bool timer *time.Timer resetChan chan (time.Duration) @@ -117,11 +125,7 @@ func (d *DLNAPlayer) PlayFile(urlstr string, meta mediaprovider.MediaItemMetadat Title: meta.Name, } - err := d.avTransport.SetAVTransportMedia(context.Background(), &media) - if err != nil { - return err - } - if err := d.avTransport.Play(context.Background()); err != nil { + if err := d.playAVTransportMedia(&media); err != nil { return err } d.state = playing @@ -134,6 +138,17 @@ func (d *DLNAPlayer) PlayFile(urlstr string, meta mediaprovider.MediaItemMetadat return nil } +func (d *DLNAPlayer) playAVTransportMedia(media *avtransport.MediaItem) error { + err := d.avTransport.SetAVTransportMedia(context.Background(), media) + if err != nil { + return err + } + if err := d.avTransport.Play(context.Background()); err != nil { + return err + } + return nil +} + func (d *DLNAPlayer) SetNextFile(url string, meta mediaprovider.MediaItemMetadata) error { var media *avtransport.MediaItem d.metaLock.Lock() @@ -148,7 +163,14 @@ func (d *DLNAPlayer) SetNextFile(url string, meta mediaprovider.MediaItemMetadat Title: meta.Name, } } - return d.avTransport.SetNextAVTransportMedia(context.Background(), media) + err := d.avTransport.SetNextAVTransportMedia(context.Background(), media) + if err != nil { + d.metaLock.Lock() + d.failedToSetNext = true + d.unsetNextMediaItem = media + d.metaLock.Unlock() + } + return err } func (d *DLNAPlayer) Continue() error { @@ -325,6 +347,17 @@ func (d *DLNAPlayer) handleOnTrackChange() { d.stopwatch.Reset() d.InvokeOnStopped() } else { + d.metaLock.Lock() + if d.failedToSetNext { + d.failedToSetNext = false + media := d.unsetNextMediaItem + d.unsetNextMediaItem = nil + d.metaLock.Unlock() + d.playAVTransportMedia(media) + } else { + d.metaLock.Unlock() + } + d.lastStartTime = 0 d.stopwatch.Reset() d.stopwatch.Start() From 31c7692ec4d0607e315ef226f8fc611b63ae67b7 Mon Sep 17 00:00:00 2001 From: Drew Weymouth Date: Sun, 23 Mar 2025 17:20:26 -0700 Subject: [PATCH 25/45] seeking while paused stays paused --- backend/player/dlna/dlnaplayer.go | 66 +++++++++++++++++++++++-------- 1 file changed, 50 insertions(+), 16 deletions(-) diff --git a/backend/player/dlna/dlnaplayer.go b/backend/player/dlna/dlnaplayer.go index 5bbca30..778cf37 100644 --- a/backend/player/dlna/dlnaplayer.go +++ b/backend/player/dlna/dlnaplayer.go @@ -59,6 +59,9 @@ type DLNAPlayer struct { localIP string proxyPort int + pendingSeek bool + pendingSeekSecs float64 + // keep in order of most recently accessed at the end // that way the item in proxyURLs[0] can be kicked out // when adding a new URL to the proxy, since @@ -174,6 +177,18 @@ func (d *DLNAPlayer) SetNextFile(url string, meta mediaprovider.MediaItemMetadat } func (d *DLNAPlayer) Continue() error { + if d.state == playing { + return nil + } + + if d.pendingSeek { + d.pendingSeek = false + err := d.avTransport.Seek(context.Background(), int(d.pendingSeekSecs)) + if err != nil { + return err + } + } + if err := d.avTransport.Play(context.Background()); err != nil { return err } @@ -188,6 +203,10 @@ func (d *DLNAPlayer) Continue() error { } func (d *DLNAPlayer) Pause() error { + if d.state != playing { + return nil + } + if err := d.avTransport.Pause(context.Background()); err != nil { return err } @@ -199,32 +218,47 @@ func (d *DLNAPlayer) Pause() error { } func (d *DLNAPlayer) Stop() error { - if err := d.avTransport.Pause(context.Background()); err != nil { - return err + switch d.state { + case stopped: + return nil + case playing: + if err := d.avTransport.Pause(context.Background()); err != nil { + return err + } + fallthrough + case paused: + d.setTrackChangeTimer(0) + d.stopwatch.Reset() + d.lastStartTime = 0 + d.state = stopped + d.InvokeOnStopped() + return nil + default: + return errors.New("invalid player state") } - d.setTrackChangeTimer(0) - d.stopwatch.Reset() - d.lastStartTime = 0 - d.state = stopped - d.InvokeOnStopped() - return nil } func (d *DLNAPlayer) SeekSeconds(secs float64) error { - d.seeking = true - if err := d.avTransport.Seek(context.Background(), int(secs)); err != nil { + if d.state == paused { + d.pendingSeek = true + d.pendingSeekSecs = secs + } else { + d.seeking = true + if err := d.avTransport.Seek(context.Background(), int(secs)); err != nil { + d.seeking = false + return err + } d.seeking = false - return err } - d.seeking = false - d.metaLock.Lock() - nextTrackChange := time.Duration(d.curTrackMeta.Duration)*time.Second - time.Duration(secs)*time.Second - d.metaLock.Unlock() - d.setTrackChangeTimer(nextTrackChange) d.lastStartTime = int(secs) d.stopwatch.Reset() + if d.state == playing { + d.metaLock.Lock() + nextTrackChange := time.Duration(d.curTrackMeta.Duration)*time.Second - time.Duration(secs)*time.Second + d.metaLock.Unlock() + d.setTrackChangeTimer(nextTrackChange) d.stopwatch.Start() } From 90ddfb87b8b9e527fcbfbb8ecbc08aca1d57ee51 Mon Sep 17 00:00:00 2001 From: Drew Weymouth Date: Sun, 23 Mar 2025 17:42:00 -0700 Subject: [PATCH 26/45] don't unpause when switching players while paused --- backend/playbackengine.go | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/backend/playbackengine.go b/backend/playbackengine.go index 3cd5854..6dbd9e8 100644 --- a/backend/playbackengine.go +++ b/backend/playbackengine.go @@ -142,16 +142,20 @@ func (p *playbackEngine) SetPlayer(pl player.BasePlayer) { needToUnpause := false stat := p.PlayerStatus() + if p.pendingPlayerChange { + stat.State = player.Paused + } + switch stat.State { case player.Stopped: // nothing case player.Playing: p.Pause() + needToUnpause = true fallthrough case player.Paused: p.pendingPlayerChangeTimePos = stat.TimePos p.pendingPlayerChange = true - needToUnpause = true } oldVol := p.player.GetVolume() From 084aa6cdba8934d811f4a8242a9d14c2bfda0cd2 Mon Sep 17 00:00:00 2001 From: Drew Weymouth Date: Mon, 24 Mar 2025 18:00:21 -0700 Subject: [PATCH 27/45] show current player in casting menu, show error toasts on failures --- backend/playbackengine.go | 10 +++++++-- backend/playbackmanager.go | 34 ++++++++++++++++++++++--------- backend/player/dlna/dlnaplayer.go | 8 ++++++++ ui/controller/controller.go | 29 +++++++++++++++++++------- 4 files changed, 62 insertions(+), 19 deletions(-) diff --git a/backend/playbackengine.go b/backend/playbackengine.go index 6dbd9e8..2ed3eb8 100644 --- a/backend/playbackengine.go +++ b/backend/playbackengine.go @@ -3,6 +3,7 @@ package backend import ( "context" "errors" + "fmt" "log" "math/rand" "time" @@ -14,6 +15,8 @@ import ( "github.com/dweymouth/supersonic/sharedutil" ) +// TODO: make thread-safe + var ( ReplayGainNone = player.ReplayGainNone.String() ReplayGainAlbum = player.ReplayGainAlbum.String() @@ -138,7 +141,7 @@ func (p *playbackEngine) registerPlayerCallbacks(pl player.BasePlayer) { }) } -func (p *playbackEngine) SetPlayer(pl player.BasePlayer) { +func (p *playbackEngine) SetPlayer(pl player.BasePlayer) error { needToUnpause := false stat := p.PlayerStatus() @@ -150,7 +153,9 @@ func (p *playbackEngine) SetPlayer(pl player.BasePlayer) { case player.Stopped: // nothing case player.Playing: - p.Pause() + if err := p.Pause(); err != nil { + return fmt.Errorf("failed to pause: %v", err) + } needToUnpause = true fallthrough case player.Paused: @@ -179,6 +184,7 @@ func (p *playbackEngine) SetPlayer(pl player.BasePlayer) { cb(vol) } } + return nil } func (p *playbackEngine) PlayTrackAt(idx int) error { diff --git a/backend/playbackmanager.go b/backend/playbackmanager.go index 4d27e5f..057ea8a 100644 --- a/backend/playbackmanager.go +++ b/backend/playbackmanager.go @@ -26,17 +26,19 @@ type PlaybackManager struct { cmdQueue *playbackCommandQueue cfg *AppConfig - localPlayer player.BasePlayer - remotePlayersLock sync.Mutex - remotePlayers []remotePlayer + localPlayer player.BasePlayer + remotePlayersLock sync.Mutex + remotePlayers []RemotePlaybackDevice + currentRemotePlayer *RemotePlaybackDevice autoplay bool lastPlayTime float64 } -type remotePlayer struct { +type RemotePlaybackDevice struct { Name string + URL string Protocol string new func() (player.BasePlayer, error) } @@ -107,10 +109,11 @@ func (p *PlaybackManager) ScanRemotePlayers(ctx context.Context, fastScan bool) func (p *PlaybackManager) scanRemotePlayers(ctx context.Context, waitSec int) { devices, _ := device.SearchMediaRenderers(ctx, waitSec, services.AVTransport, services.RenderingControl) - var discovered []remotePlayer + var discovered []RemotePlaybackDevice for _, d := range devices { - p := remotePlayer{ + p := RemotePlaybackDevice{ Name: d.FriendlyName, + URL: d.URL, Protocol: "DLNA", new: func() (player.BasePlayer, error) { return dlna.NewDLNAPlayer(d) @@ -124,16 +127,23 @@ func (p *PlaybackManager) scanRemotePlayers(ctx context.Context, waitSec int) { p.remotePlayersLock.Unlock() } -func (p *PlaybackManager) RemotePlayers() []remotePlayer { +func (p *PlaybackManager) RemotePlayers() []RemotePlaybackDevice { p.remotePlayersLock.Lock() players := p.remotePlayers p.remotePlayersLock.Unlock() return players } -func (p *PlaybackManager) SetRemotePlayer(rp *remotePlayer) error { +func (p *PlaybackManager) CurrentRemotePlayer() *RemotePlaybackDevice { + return p.currentRemotePlayer +} + +func (p *PlaybackManager) SetRemotePlayer(rp *RemotePlaybackDevice) error { if rp == nil { - p.engine.SetPlayer(p.localPlayer) + if err := p.engine.SetPlayer(p.localPlayer); err != nil { + return err + } + p.currentRemotePlayer = nil return nil } @@ -141,7 +151,11 @@ func (p *PlaybackManager) SetRemotePlayer(rp *remotePlayer) error { if err != nil { return err } - p.engine.SetPlayer(player) + if err := p.engine.SetPlayer(player); err != nil { + return err + } + + p.currentRemotePlayer = rp return nil } diff --git a/backend/player/dlna/dlnaplayer.go b/backend/player/dlna/dlnaplayer.go index 778cf37..cb28591 100644 --- a/backend/player/dlna/dlnaplayer.go +++ b/backend/player/dlna/dlnaplayer.go @@ -99,6 +99,14 @@ func NewDLNAPlayer(device *device.MediaRenderer) (*DLNAPlayer, error) { return nil, err } rc.HTTPClient = cli + + // ping to test connectivity + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + if _, err := avt.GetTransportInfo(ctx); err != nil { + return nil, fmt.Errorf("failed to connect to %s", device.FriendlyName) + } + return &DLNAPlayer{ avTransport: avt, renderControl: rc, diff --git a/ui/controller/controller.go b/ui/controller/controller.go index c9fff4b..5bd0a44 100644 --- a/ui/controller/controller.go +++ b/ui/controller/controller.go @@ -152,16 +152,31 @@ func (m *Controller) HaveModal() bool { } func (m *Controller) ShowCastMenu() { + rp := m.App.PlaybackManager.CurrentRemotePlayer() devices := m.App.PlaybackManager.RemotePlayers() - menu := fyne.NewMenu("") - menu.Items = append(menu.Items, fyne.NewMenuItem(lang.L("Local player"), func() { - m.App.PlaybackManager.SetRemotePlayer(nil) - })) + local := fyne.NewMenuItem(lang.L("Local player"), func() { + go func() { + if err := m.App.PlaybackManager.SetRemotePlayer(nil); err != nil { + fyne.Do(func() { m.ToastProvider.ShowErrorToast("Failed to disconnect from remote player") }) + } + }() + }) + local.Icon = theme.ComputerIcon() + local.Checked = rp == nil + + menu := fyne.NewMenu("", local) for _, d := range devices { _d := d - menu.Items = append(menu.Items, fyne.NewMenuItem(d.Name, func() { - m.App.PlaybackManager.SetRemotePlayer(&_d) - })) + item := fyne.NewMenuItem(d.Name, func() { + go func() { + if err := m.App.PlaybackManager.SetRemotePlayer(&_d); err != nil { + fyne.Do(func() { m.ToastProvider.ShowErrorToast("Failed to connect to " + _d.Name) }) + } + }() + }) + item.Icon = myTheme.CastIcon + item.Checked = rp != nil && _d.URL == rp.URL + menu.Items = append(menu.Items, item) } pop := widget.NewPopUpMenu(menu, m.MainWindow.Canvas()) canvasSize := m.MainWindow.Canvas().Size() From f9c9f290bd6bd82ded88452aac3c981b99685e9e Mon Sep 17 00:00:00 2001 From: Drew Weymouth Date: Sat, 29 Mar 2025 15:57:34 -0700 Subject: [PATCH 28/45] seek to correct position when switching players --- backend/playbackengine.go | 21 ++++++++------- backend/player/dlna/dlnaplayer.go | 31 +++++++++++++++++----- backend/player/jukebox/jukeboxplayer.go | 2 +- backend/player/mpv/player.go | 35 ++++++++++++++++++------- backend/player/player.go | 4 +-- 5 files changed, 63 insertions(+), 30 deletions(-) diff --git a/backend/playbackengine.go b/backend/playbackengine.go index 2ed3eb8..13a08d1 100644 --- a/backend/playbackengine.go +++ b/backend/playbackengine.go @@ -171,10 +171,7 @@ func (p *playbackEngine) SetPlayer(pl player.BasePlayer) error { p.registerPlayerCallbacks(pl) if needToUnpause { - p.PlayTrackAt(p.nowPlayingIdx) - // TODO - find a better way to wait until the players are ready - time.Sleep(200 * time.Millisecond) - p.SeekSeconds(p.pendingPlayerChangeTimePos) + p.playTrackAt(p.nowPlayingIdx, p.pendingPlayerChangeTimePos) p.pendingPlayerChange = false } p.invokeNoArgCallbacks(p.onPlayerChange) @@ -188,6 +185,10 @@ func (p *playbackEngine) SetPlayer(pl player.BasePlayer) error { } func (p *playbackEngine) PlayTrackAt(idx int) error { + return p.playTrackAt(idx, 0) +} + +func (p *playbackEngine) playTrackAt(idx int, startTime float64) error { if idx < 0 || idx >= len(p.playQueue) { return errors.New("track index out of range") } @@ -195,7 +196,7 @@ func (p *playbackEngine) PlayTrackAt(idx int) error { p.checkScrobble() p.alreadyScrobbled = true p.pendingTrackChangeNum = idx - err := p.setTrack(idx, false) + err := p.setTrack(idx, false, startTime) return err } @@ -475,7 +476,7 @@ func (p *playbackEngine) RemoveTracksFromQueue(idxs []int) { p.Stop() } else { p.nowPlayingIdx -= 1 // will be incremented in newtrack callback from player - p.setTrack(newNowPlaying, false) + p.setTrack(newNowPlaying, false, 0) } // setNextTrack and onSongChange callbacks will be handled // when we receive new track event from player @@ -617,7 +618,7 @@ func (p *playbackEngine) setNextTrackAfterQueueUpdate() { } } -func (p *playbackEngine) setTrack(idx int, next bool) error { +func (p *playbackEngine) setTrack(idx int, next bool, startTime float64) error { if urlP, ok := p.player.(player.URLPlayer); ok { url := "" var meta mediaprovider.MediaItemMetadata @@ -637,7 +638,7 @@ func (p *playbackEngine) setTrack(idx int, next bool) error { if next { return urlP.SetNextFile(url, meta) } - return urlP.PlayFile(url, meta) + return urlP.PlayFile(url, meta, startTime) } else if trP, ok := p.player.(player.TrackPlayer); ok { var track *mediaprovider.Track if idx >= 0 { @@ -649,13 +650,13 @@ func (p *playbackEngine) setTrack(idx int, next bool) error { if next { return trP.SetNextTrack(track) } - return trP.PlayTrack(track) + return trP.PlayTrack(track, startTime) } panic("Unsupported player type") } func (p *playbackEngine) setNextTrack(idx int) error { - return p.setTrack(idx, true) + return p.setTrack(idx, true, 0) } // call BEFORE updating p.nowPlayingIdx diff --git a/backend/player/dlna/dlnaplayer.go b/backend/player/dlna/dlnaplayer.go index cb28591..dd87e34 100644 --- a/backend/player/dlna/dlnaplayer.go +++ b/backend/player/dlna/dlnaplayer.go @@ -123,7 +123,7 @@ func (d *DLNAPlayer) GetVolume() int { return vol } -func (d *DLNAPlayer) PlayFile(urlstr string, meta mediaprovider.MediaItemMetadata) error { +func (d *DLNAPlayer) PlayFile(urlstr string, meta mediaprovider.MediaItemMetadata, startTime float64) error { d.ensureSetupProxy() d.metaLock.Lock() @@ -139,13 +139,23 @@ func (d *DLNAPlayer) PlayFile(urlstr string, meta mediaprovider.MediaItemMetadat if err := d.playAVTransportMedia(&media); err != nil { return err } + if startTime > 0 { + // TODO: do something better than this!! + time.Sleep(2 * time.Second) + d.sendSeekCmd(startTime) + } d.state = playing - d.setTrackChangeTimer(time.Duration(meta.Duration) * time.Second) + remainingDur := meta.Duration - int(startTime) + d.setTrackChangeTimer(time.Duration(remainingDur) * time.Second) d.stopwatch.Reset() d.stopwatch.Start() - d.lastStartTime = 0 + d.lastStartTime = int(startTime) d.InvokeOnPlaying() d.InvokeOnTrackChange() + if startTime > 0 { + d.InvokeOnSeek() + } + return nil } @@ -251,12 +261,9 @@ func (d *DLNAPlayer) SeekSeconds(secs float64) error { d.pendingSeek = true d.pendingSeekSecs = secs } else { - d.seeking = true - if err := d.avTransport.Seek(context.Background(), int(secs)); err != nil { - d.seeking = false + if err := d.sendSeekCmd(secs); err != nil { return err } - d.seeking = false } d.lastStartTime = int(secs) @@ -274,6 +281,16 @@ func (d *DLNAPlayer) SeekSeconds(secs float64) error { return nil } +func (d *DLNAPlayer) sendSeekCmd(secs float64) error { + d.seeking = true + if err := d.avTransport.Seek(context.Background(), int(secs)); err != nil { + d.seeking = false + return err + } + d.seeking = false + return nil +} + func (d *DLNAPlayer) IsSeeking() bool { return d.seeking } diff --git a/backend/player/jukebox/jukeboxplayer.go b/backend/player/jukebox/jukeboxplayer.go index c3db780..176f43d 100644 --- a/backend/player/jukebox/jukeboxplayer.go +++ b/backend/player/jukebox/jukeboxplayer.go @@ -80,7 +80,7 @@ func (j *JukeboxPlayer) Stop() error { return nil } -func (j *JukeboxPlayer) PlayTrack(track *mediaprovider.Track) error { +func (j *JukeboxPlayer) PlayTrack(track *mediaprovider.Track, _ float64) error { if err := j.provider.JukeboxSet(track.ID); err != nil { return err } diff --git a/backend/player/mpv/player.go b/backend/player/mpv/player.go index 4b2ab55..9eb785b 100644 --- a/backend/player/mpv/player.go +++ b/backend/player/mpv/player.go @@ -6,6 +6,7 @@ import ( "fmt" "math" "strconv" + "sync" "github.com/dweymouth/supersonic/backend/mediaprovider" "github.com/dweymouth/supersonic/backend/player" @@ -68,6 +69,9 @@ type Player struct { equalizer Equalizer peaksEnabled bool + fileLoadedLock sync.Mutex + fileLoadedSig *sync.Cond + bgCancel context.CancelFunc } @@ -80,10 +84,12 @@ func New() *Player { // Same as New, but sets the application name that mpv // reports to the system audio API. func NewWithClientName(c string) *Player { - return &Player{ + p := &Player{ vol: -1, // use 100 in Init clientName: c, } + p.fileLoadedSig = sync.NewCond(&p.fileLoadedLock) + return p } // Initializes the Player and makes it ready for playback. @@ -134,18 +140,27 @@ func (p *Player) Init(maxCacheMB int) error { } // Plays the specified file, clearing the previous play queue, if any. -func (p *Player) PlayFile(url string, _ mediaprovider.MediaItemMetadata) error { +func (p *Player) PlayFile(url string, _ mediaprovider.MediaItemMetadata, startTime float64) error { if !p.initialized { return ErrUnitialized } err := p.mpv.Command([]string{"loadfile", url, "replace"}) - if err == nil { - p.lenPlaylist = 1 - if p.status.State == player.Paused { - return p.Continue() - } + if err != nil { + return err + } + p.lenPlaylist = 1 + if p.status.State == player.Paused { + err = p.Continue() + } else { p.setState(player.Playing) } + if startTime > 0 { + p.fileLoadedLock.Lock() + p.fileLoadedSig.Wait() + p.fileLoadedLock.Unlock() + p.SeekSeconds(startTime) + } + return err } @@ -481,10 +496,9 @@ func (p *Player) eventHandler(ctx context.Context) { } switch e.Event_Id { case mpv.EVENT_PLAYBACK_RESTART: - if p.seeking { - p.seeking = false - } + p.seeking = false case mpv.EVENT_SEEK: + p.seeking = false p.InvokeOnSeek() case mpv.EVENT_FILE_LOADED: p.curPlaylistPos, _ = p.getInt64Property("playlist-pos") @@ -494,6 +508,7 @@ func (p *Player) eventHandler(ctx context.Context) { p.InvokeOnSeek() } p.InvokeOnTrackChange() + p.fileLoadedSig.Signal() case mpv.EVENT_IDLE: p.status.Duration = 0 p.status.TimePos = 0 diff --git a/backend/player/player.go b/backend/player/player.go index 9c941a5..201e549 100644 --- a/backend/player/player.go +++ b/backend/player/player.go @@ -4,13 +4,13 @@ import "github.com/dweymouth/supersonic/backend/mediaprovider" type URLPlayer interface { BasePlayer - PlayFile(url string, metadata mediaprovider.MediaItemMetadata) error + PlayFile(url string, metadata mediaprovider.MediaItemMetadata, startTime float64) error SetNextFile(url string, metadata mediaprovider.MediaItemMetadata) error } type TrackPlayer interface { BasePlayer - PlayTrack(track *mediaprovider.Track) error + PlayTrack(track *mediaprovider.Track, startTime float64) error SetNextTrack(track *mediaprovider.Track) error } From a0c97f0be1457654f7617c4096e00b545bc67b21 Mon Sep 17 00:00:00 2001 From: Drew Weymouth Date: Sat, 29 Mar 2025 16:03:27 -0700 Subject: [PATCH 29/45] signal correct play pos to OS when switching back to MPV player --- backend/player/mpv/player.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/player/mpv/player.go b/backend/player/mpv/player.go index 9eb785b..bef95fe 100644 --- a/backend/player/mpv/player.go +++ b/backend/player/mpv/player.go @@ -496,7 +496,7 @@ func (p *Player) eventHandler(ctx context.Context) { } switch e.Event_Id { case mpv.EVENT_PLAYBACK_RESTART: - p.seeking = false + fallthrough case mpv.EVENT_SEEK: p.seeking = false p.InvokeOnSeek() From 447519c413144b8b432b592ee92cc428887a823c Mon Sep 17 00:00:00 2001 From: Drew Weymouth Date: Sat, 29 Mar 2025 16:15:42 -0700 Subject: [PATCH 30/45] disable cast button while player switch in progress --- backend/playbackengine.go | 4 ++++ ui/controller/controller.go | 4 +++- ui/widgets/auxcontrols.go | 11 +++++++++-- 3 files changed, 16 insertions(+), 3 deletions(-) diff --git a/backend/playbackengine.go b/backend/playbackengine.go index 13a08d1..352ac25 100644 --- a/backend/playbackengine.go +++ b/backend/playbackengine.go @@ -142,6 +142,10 @@ func (p *playbackEngine) registerPlayerCallbacks(pl player.BasePlayer) { } func (p *playbackEngine) SetPlayer(pl player.BasePlayer) error { + // even if we don't successfully change players, + // make sure UI updates if needed (eg enabling cast button) + defer p.invokeNoArgCallbacks(p.onPlayerChange) + needToUnpause := false stat := p.PlayerStatus() diff --git a/ui/controller/controller.go b/ui/controller/controller.go index 5bd0a44..a3d3186 100644 --- a/ui/controller/controller.go +++ b/ui/controller/controller.go @@ -151,10 +151,11 @@ func (m *Controller) HaveModal() bool { return m.haveModal } -func (m *Controller) ShowCastMenu() { +func (m *Controller) ShowCastMenu(onPendingPlayerChange func()) { rp := m.App.PlaybackManager.CurrentRemotePlayer() devices := m.App.PlaybackManager.RemotePlayers() local := fyne.NewMenuItem(lang.L("Local player"), func() { + onPendingPlayerChange() go func() { if err := m.App.PlaybackManager.SetRemotePlayer(nil); err != nil { fyne.Do(func() { m.ToastProvider.ShowErrorToast("Failed to disconnect from remote player") }) @@ -168,6 +169,7 @@ func (m *Controller) ShowCastMenu() { for _, d := range devices { _d := d item := fyne.NewMenuItem(d.Name, func() { + onPendingPlayerChange() go func() { if err := m.App.PlaybackManager.SetRemotePlayer(&_d); err != nil { fyne.Do(func() { m.ToastProvider.ShowErrorToast("Failed to connect to " + _d.Name) }) diff --git a/ui/widgets/auxcontrols.go b/ui/widgets/auxcontrols.go index bb50172..324bce1 100644 --- a/ui/widgets/auxcontrols.go +++ b/ui/widgets/auxcontrols.go @@ -98,7 +98,12 @@ func (a *AuxControls) SetLoopMode(mode backend.LoopMode) { } } +func (a *AuxControls) DisableCastButton() { + a.cast.Disable() +} + func (a *AuxControls) SetIsRemotePlayer(isRemote bool) { + a.cast.Enable() a.cast.Highlighted = isRemote a.cast.Refresh() } @@ -115,8 +120,10 @@ func (a *AuxControls) OnShowPlayQueue(f func()) { a.showQueue.OnTapped = f } -func (a *AuxControls) OnShowCastMenu(f func()) { - a.cast.OnTapped = f +func (a *AuxControls) OnShowCastMenu(f func(func())) { + a.cast.OnTapped = func() { + f(a.DisableCastButton) + } } type volumeSlider struct { From 2701bb3f7f646f9d56e4ca268a2f32b09be5667f Mon Sep 17 00:00:00 2001 From: Drew Weymouth Date: Sun, 30 Mar 2025 13:08:18 -0700 Subject: [PATCH 31/45] sync playback time when seeking and starting new tracks --- backend/player/dlna/dlnaplayer.go | 57 +++++++++++++++++++++++++++++-- go.mod | 10 +++--- go.sum | 20 +++++++---- res/translations/en.json | 1 + ui/controller/controller.go | 2 +- 5 files changed, 75 insertions(+), 15 deletions(-) diff --git a/backend/player/dlna/dlnaplayer.go b/backend/player/dlna/dlnaplayer.go index dd87e34..3f9c168 100644 --- a/backend/player/dlna/dlnaplayer.go +++ b/backend/player/dlna/dlnaplayer.go @@ -41,6 +41,8 @@ type proxyMapEntry struct { type DLNAPlayer struct { player.BasePlayerCallbackImpl + destroyed bool + avTransport *avtransport.Client renderControl *renderingcontrol.Client @@ -51,8 +53,13 @@ type DLNAPlayer struct { curTrackMeta mediaprovider.MediaItemMetadata nextTrackMeta mediaprovider.MediaItemMetadata + // if true, report playback time 00:00 + // pending time sync with player after beginning playback + pendingPlayStart bool + // start playback position in seconds of the last seek/time sync lastStartTime int - stopwatch util.Stopwatch + // how long the track has been playing since last time sync + stopwatch util.Stopwatch proxyServer *http.Server proxyActive atomic.Bool @@ -139,10 +146,22 @@ func (d *DLNAPlayer) PlayFile(urlstr string, meta mediaprovider.MediaItemMetadat if err := d.playAVTransportMedia(&media); err != nil { return err } + d.pendingPlayStart = true if startTime > 0 { // TODO: do something better than this!! time.Sleep(2 * time.Second) - d.sendSeekCmd(startTime) + if !d.destroyed { + d.sendSeekCmd(startTime) + } + d.pendingPlayStart = false + } else { + go func() { + time.Sleep(2 * time.Second) + if !d.destroyed { + d.syncPlaybackTime() + } + d.pendingPlayStart = false + }() } d.state = playing remainingDur := meta.Duration - int(startTime) @@ -278,6 +297,13 @@ func (d *DLNAPlayer) SeekSeconds(secs float64) error { } d.InvokeOnSeek() + + go func() { + time.Sleep(4 * time.Second) + if !d.destroyed { + d.syncPlaybackTime() + } + }() return nil } @@ -303,9 +329,13 @@ func (d *DLNAPlayer) GetStatus() player.Status { state = player.Paused } + var timePos float64 + if !d.pendingPlayStart { + timePos = d.curPlayPos().Seconds() + } return player.Status{ State: state, - TimePos: d.curPlayPos().Seconds(), + TimePos: timePos, Duration: float64(d.curTrackMeta.Duration), } } @@ -315,11 +345,25 @@ func (d *DLNAPlayer) curPlayPos() time.Duration { } func (d *DLNAPlayer) Destroy() { + d.destroyed = true if d.proxyServer != nil { go d.proxyServer.Shutdown(context.Background()) } } +func (d *DLNAPlayer) syncPlaybackTime() { + start := time.Now() + if pos, err := d.avTransport.GetPositionInfo(context.Background()); err == nil { + d.lastStartTime = int(pos.RelTime.Seconds() + (time.Since(start) / 2).Seconds()) + d.stopwatch.Reset() + if d.state == playing { + d.stopwatch.Start() + } + d.setTrackChangeTimer(time.Duration(d.curTrackMeta.Duration-d.lastStartTime) * time.Second) + d.InvokeOnSeek() + } +} + func (d *DLNAPlayer) ensureSetupProxy() error { if d.proxyActive.Swap(true) { return nil // already active @@ -422,6 +466,13 @@ func (d *DLNAPlayer) handleOnTrackChange() { d.stopwatch.Start() d.setTrackChangeTimer(nextTrackChange) d.InvokeOnTrackChange() + + go func() { + time.Sleep(5 * time.Second) + if !d.destroyed { + d.syncPlaybackTime() + } + }() } } diff --git a/go.mod b/go.mod index 1fbe980..91c3880 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module github.com/dweymouth/supersonic -go 1.21 +go 1.21.9 require ( fyne.io/fyne/v2 v2.6.0-beta1 @@ -14,13 +14,14 @@ require ( github.com/dweymouth/go-jellyfin v0.0.0-20240517151952-5ceca61cb645 github.com/godbus/dbus/v5 v5.1.0 github.com/google/uuid v1.3.0 + github.com/hashicorp/go-retryablehttp v0.7.7 github.com/pelletier/go-toml/v2 v2.0.8 github.com/quarckster/go-mpris-server v1.0.3 github.com/supersonic-app/go-mpv v0.1.0 github.com/supersonic-app/go-subsonic v0.0.0-20241224013245-9b2841f3711d - github.com/supersonic-app/go-upnpcast v0.0.0-20250312000014-e4f7242a07ce + github.com/supersonic-app/go-upnpcast v0.0.0-20250330154256-b957204209a5 github.com/zalando/go-keyring v0.2.6 - golang.org/x/net v0.25.0 + golang.org/x/net v0.33.0 golang.org/x/sys v0.30.0 golang.org/x/text v0.22.0 ) @@ -42,10 +43,9 @@ require ( github.com/go-text/typesetting v0.2.1 // indirect github.com/h2non/filetype v1.1.3 // indirect github.com/hashicorp/go-cleanhttp v0.5.2 // indirect - github.com/hashicorp/go-retryablehttp v0.7.7 // indirect github.com/jeandeaual/go-locale v0.0.0-20241217141322-fcc2cadd6f08 // indirect github.com/jsummers/gobmp v0.0.0-20230614200233-a9de23ed2e25 // indirect - github.com/koron/go-ssdp v0.0.4 // indirect + github.com/koron/go-ssdp v0.0.5 // indirect github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 // indirect github.com/nicksnyder/go-i18n/v2 v2.5.1 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect diff --git a/go.sum b/go.sum index a28ff24..e4cd2e6 100644 --- a/go.sum +++ b/go.sum @@ -27,6 +27,8 @@ github.com/dweymouth/fyne/v2 v2.3.0-rc1.0.20250308154116-c32a0de49bde h1:vSJQkJW github.com/dweymouth/fyne/v2 v2.3.0-rc1.0.20250308154116-c32a0de49bde/go.mod h1:ON11afuS9jVyN+nfEtITv3jX1H+JD+Z50gxCiEC1zLw= github.com/dweymouth/go-jellyfin v0.0.0-20240517151952-5ceca61cb645 h1:KzqSaQwG3HsTZQlEtkp0BeUy9vmYZ0rq0B15qIPSiBs= github.com/dweymouth/go-jellyfin v0.0.0-20240517151952-5ceca61cb645/go.mod h1:fcUagHBaQnt06GmBAllNE0J4O/7064zXRWdqnTTtVjI= +github.com/fatih/color v1.16.0 h1:zmkK9Ngbjj+K0yRhTVONQh1p/HknKYSlNT+vZCzyokM= +github.com/fatih/color v1.16.0/go.mod h1:fL2Sau1YI5c0pdGEVCbKQbLXB6edEj1ZgiY4NijnWvE= github.com/felixge/fgprof v0.9.3 h1:VvyZxILNuCiUCSXtPtYmmtGvb65nqXh2QFWc0Wpf2/g= github.com/felixge/fgprof v0.9.3/go.mod h1:RdbpDgzqYVh/T9fPELJyV7EYJuHB55UTEULNun8eiPw= github.com/fredbi/uri v1.1.0 h1:OqLpTXtyRg9ABReqvDGdJPqZUxs8cyBDOMXBbskCaB8= @@ -61,16 +63,22 @@ github.com/h2non/filetype v1.1.3 h1:FKkx9QbD7HR/zjK1Ia5XiBsq9zdLi5Kf3zGyFTAFkGg= github.com/h2non/filetype v1.1.3/go.mod h1:319b3zT68BvV+WRj7cwy856M2ehB3HqNOt6sy1HndBY= github.com/hashicorp/go-cleanhttp v0.5.2 h1:035FKYIWjmULyFRBKPs8TBQoi0x6d9G4xc9neXJWAZQ= github.com/hashicorp/go-cleanhttp v0.5.2/go.mod h1:kO/YDlP8L1346E6Sodw+PrpBSV4/SoxCXGY6BqNFT48= +github.com/hashicorp/go-hclog v1.6.3 h1:Qr2kF+eVWjTiYmU7Y31tYlP1h0q/X3Nl3tPGdaB11/k= +github.com/hashicorp/go-hclog v1.6.3/go.mod h1:W4Qnvbt70Wk/zYJryRzDRU/4r0kIg0PVHBcfoyhpF5M= github.com/hashicorp/go-retryablehttp v0.7.7 h1:C8hUCYzor8PIfXHa4UrZkU4VvK8o9ISHxT2Q8+VepXU= github.com/hashicorp/go-retryablehttp v0.7.7/go.mod h1:pkQpWZeYWskR+D1tR2O5OcBFOxfA7DoAO6xtkuQnHTk= github.com/jeandeaual/go-locale v0.0.0-20241217141322-fcc2cadd6f08 h1:wMeVzrPO3mfHIWLZtDcSaGAe2I4PW9B/P5nMkRSwCAc= github.com/jeandeaual/go-locale v0.0.0-20241217141322-fcc2cadd6f08/go.mod h1:ZDXo8KHryOWSIqnsb/CiDq7hQUYryCgdVnxbj8tDG7o= github.com/jsummers/gobmp v0.0.0-20230614200233-a9de23ed2e25 h1:YLvr1eE6cdCqjOe972w/cYF+FjW34v27+9Vo5106B4M= github.com/jsummers/gobmp v0.0.0-20230614200233-a9de23ed2e25/go.mod h1:kLgvv7o6UM+0QSf0QjAse3wReFDsb9qbZJdfexWlrQw= -github.com/koron/go-ssdp v0.0.4 h1:1IDwrghSKYM7yLf7XCzbByg2sJ/JcNOZRXS2jczTwz0= -github.com/koron/go-ssdp v0.0.4/go.mod h1:oDXq+E5IL5q0U8uSBcoAXzTzInwy5lEgC91HoKtbmZk= +github.com/koron/go-ssdp v0.0.5 h1:E1iSMxIs4WqxTbIBLtmNBeOOC+1sCIXQeqTWVnpmwhk= +github.com/koron/go-ssdp v0.0.5/go.mod h1:Qm59B7hpKpDqfyRNWRNr00jGwLdXjDyZh6y7rH6VS0w= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= +github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 h1:zYyBkD/k9seD2A7fsi6Oo2LfFZAehjjQMERAvZLEDnQ= github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646/go.mod h1:jpp1/29i3P1S/RLdc7JQKbRpFeM1dOBd8T9ki5s+AY8= github.com/nicksnyder/go-i18n/v2 v2.5.1 h1:IxtPxYsR9Gp60cGXjfuR/llTqV8aYMsC472zD0D1vHk= @@ -105,8 +113,8 @@ github.com/supersonic-app/go-mpv v0.1.0 h1:U+cCnLQxmpqx5mY6nMlC0J4uIdCCXUbAjpjS0 github.com/supersonic-app/go-mpv v0.1.0/go.mod h1:1bQz6kBQumJopXEbkiqoLxIXLy7F7yWFBvknvpAtIC0= github.com/supersonic-app/go-subsonic v0.0.0-20241224013245-9b2841f3711d h1:70+Nn7yh+cfeKqqXVTdpneFqXuvrBLyP7U6GVUsjTU4= github.com/supersonic-app/go-subsonic v0.0.0-20241224013245-9b2841f3711d/go.mod h1:D+OWPXeD9owcdcoXATv5YPBGWxxVvn5k98rt5B4wMc4= -github.com/supersonic-app/go-upnpcast v0.0.0-20250312000014-e4f7242a07ce h1:6R8/JRwRVrR9t2YPTgikS39lRf3gPp/MPcbx+lIf3rA= -github.com/supersonic-app/go-upnpcast v0.0.0-20250312000014-e4f7242a07ce/go.mod h1:Wscg4vEzF9x6i4ltL2Qkvx4VhzXfkBBl8uoG0YSAJMU= +github.com/supersonic-app/go-upnpcast v0.0.0-20250330154256-b957204209a5 h1:aoUJKPFD/ZrNZjK6fl2Xhazwsttx42esKxhSSzEE3Bo= +github.com/supersonic-app/go-upnpcast v0.0.0-20250330154256-b957204209a5/go.mod h1:ibt19zDV5/vvF14jHJpTv3AOorq1EbmrMAubxnuvR5Y= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= github.com/yuin/goldmark v1.7.8 h1:iERMLn0/QJeHFhxSt3p6PeN9mGnvIKSpG9YYorDMnic= github.com/yuin/goldmark v1.7.8/go.mod h1:uzxRWxtg69N339t3louHJ7+O03ezfj6PlliRlaOzY1E= @@ -123,8 +131,8 @@ golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLL golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= -golang.org/x/net v0.25.0 h1:d/OCCoBEUq33pjydKrGQhw7IlUPI2Oylr+8qLx49kac= -golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM= +golang.org/x/net v0.33.0 h1:74SYHlV8BIgHIFC/LrYkOGIwL19eTYXQ5wc6TBuO36I= +golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= diff --git a/res/translations/en.json b/res/translations/en.json index 5c10897..d2cfee6 100644 --- a/res/translations/en.json +++ b/res/translations/en.json @@ -207,6 +207,7 @@ "Testing connection": "Testing connection", "The request timed out": "The request timed out", "Theme": "Theme", + "This computer": "This computer", "Time": "Time", "Title": "Title", "Title (A-Z)": "Title (A-Z)", diff --git a/ui/controller/controller.go b/ui/controller/controller.go index a3d3186..67dde41 100644 --- a/ui/controller/controller.go +++ b/ui/controller/controller.go @@ -154,7 +154,7 @@ func (m *Controller) HaveModal() bool { func (m *Controller) ShowCastMenu(onPendingPlayerChange func()) { rp := m.App.PlaybackManager.CurrentRemotePlayer() devices := m.App.PlaybackManager.RemotePlayers() - local := fyne.NewMenuItem(lang.L("Local player"), func() { + local := fyne.NewMenuItem(lang.L("This computer"), func() { onPendingPlayerChange() go func() { if err := m.App.PlaybackManager.SetRemotePlayer(nil); err != nil { From 9001dc296f0667df6781942eafae829c9f80c2aa Mon Sep 17 00:00:00 2001 From: Drew Weymouth Date: Sun, 30 Mar 2025 13:51:50 -0700 Subject: [PATCH 32/45] keep hanging DLNA player from hanging Supersonic when quitting/switching players --- backend/playbackcommands.go | 6 ++ backend/playbackengine.go | 15 ++-- backend/playbackmanager.go | 1 + backend/player/dlna/dlnaplayer.go | 97 +++++++++++++++++++++---- backend/player/jukebox/jukeboxplayer.go | 2 +- backend/player/mpv/player.go | 2 +- backend/player/player.go | 6 +- 7 files changed, 102 insertions(+), 27 deletions(-) diff --git a/backend/playbackcommands.go b/backend/playbackcommands.go index 2d40a1b..2632fef 100644 --- a/backend/playbackcommands.go +++ b/backend/playbackcommands.go @@ -61,6 +61,12 @@ func (c *playbackCommandQueue) C() <-chan playbackCommand { return c.nextChan } +func (c *playbackCommandQueue) Clear() { + c.mutex.Lock() + c.queue = nil + c.mutex.Unlock() +} + func (c *playbackCommandQueue) Stop() { c.filterCommandsAndAdd([]playbackCommandType{cmdContinue, cmdPause, cmdStop}, playbackCommand{Type: cmdStop}) diff --git a/backend/playbackengine.go b/backend/playbackengine.go index 352ac25..52b6e78 100644 --- a/backend/playbackengine.go +++ b/backend/playbackengine.go @@ -3,7 +3,6 @@ package backend import ( "context" "errors" - "fmt" "log" "math/rand" "time" @@ -157,15 +156,15 @@ func (p *playbackEngine) SetPlayer(pl player.BasePlayer) error { case player.Stopped: // nothing case player.Playing: - if err := p.Pause(); err != nil { - return fmt.Errorf("failed to pause: %v", err) - } needToUnpause = true fallthrough case player.Paused: p.pendingPlayerChangeTimePos = stat.TimePos p.pendingPlayerChange = true } + if err := p.player.Stop(true); err != nil { + log.Printf("failed to stop player: %v", err) + } oldVol := p.player.GetVolume() if _, isMPV := p.player.(*mpv.Player); !isMPV { @@ -293,7 +292,7 @@ func (p *playbackEngine) IsSeeking() bool { } func (p *playbackEngine) Stop() error { - return p.player.Stop() + return p.player.Stop(false) } func (p *playbackEngine) Pause() error { @@ -332,7 +331,7 @@ func (p *playbackEngine) LoadTracks(tracks []*mediaprovider.Track, insertQueueMo func (p *playbackEngine) doLoaditems(items []mediaprovider.MediaItem, insertQueueMode InsertQueueMode, shuffle bool) error { if insertQueueMode == Replace { - p.player.Stop() + p.player.Stop(false) p.nowPlayingIdx = -1 p.playQueue = nil } @@ -358,7 +357,7 @@ func (p *playbackEngine) doLoaditems(items []mediaprovider.MediaItem, insertQueu func (p *playbackEngine) LoadRadioStation(radio *mediaprovider.RadioStation, insertMode InsertQueueMode) { if insertMode == Replace { - p.player.Stop() + p.player.Stop(false) p.nowPlayingIdx = -1 p.playQueue = nil } @@ -384,7 +383,7 @@ func (p *playbackEngine) LoadRadioStation(radio *mediaprovider.RadioStation, ins // Stop playback and clear the play queue. func (p *playbackEngine) StopAndClearPlayQueue() { changed := len(p.playQueue) > 0 - p.player.Stop() + p.player.Stop(false) p.playQueue = nil p.nowPlayingIdx = -1 if changed { diff --git a/backend/playbackmanager.go b/backend/playbackmanager.go index 057ea8a..a6eb122 100644 --- a/backend/playbackmanager.go +++ b/backend/playbackmanager.go @@ -139,6 +139,7 @@ func (p *PlaybackManager) CurrentRemotePlayer() *RemotePlaybackDevice { } func (p *PlaybackManager) SetRemotePlayer(rp *RemotePlaybackDevice) error { + p.cmdQueue.Clear() if rp == nil { if err := p.engine.SetPlayer(p.localPlayer); err != nil { return err diff --git a/backend/player/dlna/dlnaplayer.go b/backend/player/dlna/dlnaplayer.go index 3f9c168..939824f 100644 --- a/backend/player/dlna/dlnaplayer.go +++ b/backend/player/dlna/dlnaplayer.go @@ -41,7 +41,8 @@ type proxyMapEntry struct { type DLNAPlayer struct { player.BasePlayerCallbackImpl - destroyed bool + destroyed bool + cancelRequest context.CancelFunc avTransport *avtransport.Client renderControl *renderingcontrol.Client @@ -122,15 +123,31 @@ func NewDLNAPlayer(device *device.MediaRenderer) (*DLNAPlayer, error) { } func (d *DLNAPlayer) SetVolume(vol int) error { - return d.renderControl.SetVolume(context.Background(), vol) + if d.destroyed { + return nil + } + ctx, cancel := context.WithCancel(context.Background()) + d.cancelRequest = cancel + defer cancel() + return d.renderControl.SetVolume(ctx, vol) } func (d *DLNAPlayer) GetVolume() int { - vol, _ := d.renderControl.GetVolume(context.Background()) + if d.destroyed { + return 0 + } + ctx, cancel := context.WithCancel(context.Background()) + d.cancelRequest = cancel + defer cancel() + vol, _ := d.renderControl.GetVolume(ctx) return vol } func (d *DLNAPlayer) PlayFile(urlstr string, meta mediaprovider.MediaItemMetadata, startTime float64) error { + if d.destroyed { + return nil + } + d.ensureSetupProxy() d.metaLock.Lock() @@ -179,17 +196,25 @@ func (d *DLNAPlayer) PlayFile(urlstr string, meta mediaprovider.MediaItemMetadat } func (d *DLNAPlayer) playAVTransportMedia(media *avtransport.MediaItem) error { - err := d.avTransport.SetAVTransportMedia(context.Background(), media) + ctx, cancel := context.WithCancel(context.Background()) + d.cancelRequest = cancel + defer cancel() + + err := d.avTransport.SetAVTransportMedia(ctx, media) if err != nil { return err } - if err := d.avTransport.Play(context.Background()); err != nil { + if err := d.avTransport.Play(ctx); err != nil { return err } return nil } func (d *DLNAPlayer) SetNextFile(url string, meta mediaprovider.MediaItemMetadata) error { + if d.destroyed { + return nil + } + var media *avtransport.MediaItem d.metaLock.Lock() d.nextTrackMeta = meta @@ -203,7 +228,11 @@ func (d *DLNAPlayer) SetNextFile(url string, meta mediaprovider.MediaItemMetadat Title: meta.Name, } } - err := d.avTransport.SetNextAVTransportMedia(context.Background(), media) + + ctx, cancel := context.WithCancel(context.Background()) + d.cancelRequest = cancel + defer cancel() + err := d.avTransport.SetNextAVTransportMedia(ctx, media) if err != nil { d.metaLock.Lock() d.failedToSetNext = true @@ -214,19 +243,23 @@ func (d *DLNAPlayer) SetNextFile(url string, meta mediaprovider.MediaItemMetadat } func (d *DLNAPlayer) Continue() error { - if d.state == playing { + if d.destroyed || d.state == playing { return nil } + ctx, cancel := context.WithCancel(context.Background()) + d.cancelRequest = cancel + defer cancel() + if d.pendingSeek { d.pendingSeek = false - err := d.avTransport.Seek(context.Background(), int(d.pendingSeekSecs)) + err := d.avTransport.Seek(ctx, int(d.pendingSeekSecs)) if err != nil { return err } } - if err := d.avTransport.Play(context.Background()); err != nil { + if err := d.avTransport.Play(ctx); err != nil { return err } d.metaLock.Lock() @@ -240,11 +273,14 @@ func (d *DLNAPlayer) Continue() error { } func (d *DLNAPlayer) Pause() error { - if d.state != playing { + if d.destroyed || d.state != playing { return nil } - if err := d.avTransport.Pause(context.Background()); err != nil { + ctx, cancel := context.WithCancel(context.Background()) + d.cancelRequest = cancel + defer cancel() + if err := d.avTransport.Pause(ctx); err != nil { return err } d.setTrackChangeTimer(0) @@ -254,12 +290,29 @@ func (d *DLNAPlayer) Pause() error { return nil } -func (d *DLNAPlayer) Stop() error { +func (d *DLNAPlayer) Stop(force bool) error { + if d.destroyed { + return nil + } + if force && d.cancelRequest != nil { + d.cancelRequest() + } + switch d.state { case stopped: return nil case playing: - if err := d.avTransport.Pause(context.Background()); err != nil { + var ctx context.Context + var cancel context.CancelFunc + if force { + ctx, cancel = context.WithTimeout(context.Background(), 2*time.Second) + } else { + ctx, cancel = context.WithCancel(context.Background()) + } + d.cancelRequest = cancel + defer cancel() + + if err := d.avTransport.Pause(ctx); err != nil { return err } fallthrough @@ -276,6 +329,10 @@ func (d *DLNAPlayer) Stop() error { } func (d *DLNAPlayer) SeekSeconds(secs float64) error { + if d.destroyed { + return nil + } + if d.state == paused { d.pendingSeek = true d.pendingSeekSecs = secs @@ -309,7 +366,9 @@ func (d *DLNAPlayer) SeekSeconds(secs float64) error { func (d *DLNAPlayer) sendSeekCmd(secs float64) error { d.seeking = true - if err := d.avTransport.Seek(context.Background(), int(secs)); err != nil { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + if err := d.avTransport.Seek(ctx, int(secs)); err != nil { d.seeking = false return err } @@ -346,8 +405,16 @@ func (d *DLNAPlayer) curPlayPos() time.Duration { func (d *DLNAPlayer) Destroy() { d.destroyed = true + d.setTrackChangeTimer(0) + if d.cancelRequest != nil { + d.cancelRequest() + } + if d.proxyServer != nil { - go d.proxyServer.Shutdown(context.Background()) + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + go d.proxyServer.Shutdown(ctx) + d.proxyServer = nil } } diff --git a/backend/player/jukebox/jukeboxplayer.go b/backend/player/jukebox/jukeboxplayer.go index 176f43d..0dd63b8 100644 --- a/backend/player/jukebox/jukeboxplayer.go +++ b/backend/player/jukebox/jukeboxplayer.go @@ -68,7 +68,7 @@ func (j *JukeboxPlayer) Pause() error { return nil } -func (j *JukeboxPlayer) Stop() error { +func (j *JukeboxPlayer) Stop(_ bool) error { if j.state == stopped { return nil } diff --git a/backend/player/mpv/player.go b/backend/player/mpv/player.go index bef95fe..3c5965a 100644 --- a/backend/player/mpv/player.go +++ b/backend/player/mpv/player.go @@ -165,7 +165,7 @@ func (p *Player) PlayFile(url string, _ mediaprovider.MediaItemMetadata, startTi } // Stops playback and clears the play queue. -func (p *Player) Stop() error { +func (p *Player) Stop(_ bool) error { if !p.initialized { return ErrUnitialized } diff --git a/backend/player/player.go b/backend/player/player.go index 201e549..877fcea 100644 --- a/backend/player/player.go +++ b/backend/player/player.go @@ -1,6 +1,8 @@ package player -import "github.com/dweymouth/supersonic/backend/mediaprovider" +import ( + "github.com/dweymouth/supersonic/backend/mediaprovider" +) type URLPlayer interface { BasePlayer @@ -17,7 +19,7 @@ type TrackPlayer interface { type BasePlayer interface { Continue() error Pause() error - Stop() error + Stop(force bool) error SeekSeconds(secs float64) error IsSeeking() bool From fa2e998ef6b2d426a52a2c8e5b2af4bf142e5264 Mon Sep 17 00:00:00 2001 From: Drew Weymouth Date: Sun, 30 Mar 2025 13:56:01 -0700 Subject: [PATCH 33/45] fix a missed fyne.Do --- ui/browsing/artistpage.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ui/browsing/artistpage.go b/ui/browsing/artistpage.go index 6a45c98..5c827c6 100644 --- a/ui/browsing/artistpage.go +++ b/ui/browsing/artistpage.go @@ -471,7 +471,7 @@ func (a *ArtistPageHeader) Update(artist *mediaprovider.ArtistWithAlbums, im *ba a.artistImageID = artist.CoverArtID go func() { if cover, err := im.GetCoverThumbnail(artist.CoverArtID); err == nil { - a.artistImage.SetImage(cover, true) + fyne.Do(func() { a.artistImage.SetImage(cover, true) }) } else { log.Printf("error fetching cover: %v", err) } From cc702574451b63c642b404cfc2ee50f88776e17c Mon Sep 17 00:00:00 2001 From: Drew Weymouth Date: Sun, 30 Mar 2025 14:00:45 -0700 Subject: [PATCH 34/45] update build workflows to simplify, -tags migrated_fynedo for appimage --- .github/workflows/build-appimage-compat.yml | 2 +- .github/workflows/build-appimage.yml | 2 +- .github/workflows/build-macos-arm64.yml | 3 --- .github/workflows/build-macos-x64.yml | 5 +---- .github/workflows/build-ubuntu-22.04.yml | 3 --- .github/workflows/build-ubuntu-24.04.yml | 3 --- .github/workflows/build-windows.yml | 3 --- 7 files changed, 3 insertions(+), 18 deletions(-) diff --git a/.github/workflows/build-appimage-compat.yml b/.github/workflows/build-appimage-compat.yml index 3c8577c..982962f 100644 --- a/.github/workflows/build-appimage-compat.yml +++ b/.github/workflows/build-appimage-compat.yml @@ -24,7 +24,7 @@ jobs: run: sudo apt update && sudo apt install libmpv-dev gcc libegl1-mesa-dev xorg-dev desktop-file-utils - name: Build - run: go build + run: make build - name: make appimage run: | diff --git a/.github/workflows/build-appimage.yml b/.github/workflows/build-appimage.yml index 1bc96c2..6715d6a 100644 --- a/.github/workflows/build-appimage.yml +++ b/.github/workflows/build-appimage.yml @@ -24,7 +24,7 @@ jobs: run: sudo apt update && sudo apt install libmpv-dev gcc libegl1-mesa-dev xorg-dev desktop-file-utils libmfx1 libdvdnav4 - name: Build - run: go build + run: make build - name: make appimage run: | diff --git a/.github/workflows/build-macos-arm64.yml b/.github/workflows/build-macos-arm64.yml index 8a017a7..e1669ad 100644 --- a/.github/workflows/build-macos-arm64.yml +++ b/.github/workflows/build-macos-arm64.yml @@ -31,9 +31,6 @@ jobs: - name: Setup path run: export C_INCLUDE_PATH=/usr/local/include:/opt/homebrew/include:$C_INCLUDE_PATH && export LIBRARY_PATH=/usr/local/lib:/opt/homebrew/lib:$LIBRARY_PATH - - name: Build - run: CGO_CFLAGS="-I/usr/local/include -I/opt/homebrew/include" CGO_LDFLAGS="-L/usr/local/lib -L/opt/homebrew/lib" go build - - name: Install Fyne tool run: go install fyne.io/fyne/v2/cmd/fyne@latest diff --git a/.github/workflows/build-macos-x64.yml b/.github/workflows/build-macos-x64.yml index 5d97b6d..51f958f 100644 --- a/.github/workflows/build-macos-x64.yml +++ b/.github/workflows/build-macos-x64.yml @@ -31,13 +31,10 @@ jobs: - name: Setup path run: export C_INCLUDE_PATH=/usr/local/include:/opt/homebrew/include:$C_INCLUDE_PATH && export LIBRARY_PATH=/usr/local/lib:/opt/homebrew/lib:$LIBRARY_PATH - - name: Build - run: CGO_CFLAGS="-I/usr/local/include -I/opt/homebrew/include" CGO_LDFLAGS="-L/usr/local/lib -L/opt/homebrew/lib" go build - - name: Install Fyne tool run: go install fyne.io/fyne/v2/cmd/fyne@latest - - name: Package app bundles + - name: Build and package app bundles run: > make package_macos && cp -r Supersonic.app Supersonic.app.2 && diff --git a/.github/workflows/build-ubuntu-22.04.yml b/.github/workflows/build-ubuntu-22.04.yml index 2e4d48b..33b8a6a 100644 --- a/.github/workflows/build-ubuntu-22.04.yml +++ b/.github/workflows/build-ubuntu-22.04.yml @@ -23,9 +23,6 @@ jobs: - name: Install dependencies run: sudo apt update && sudo apt install libmpv-dev gcc libegl1-mesa-dev xorg-dev - - name: Build - run: go build - - name: Install Fyne tool run: go install fyne.io/fyne/v2/cmd/fyne@latest diff --git a/.github/workflows/build-ubuntu-24.04.yml b/.github/workflows/build-ubuntu-24.04.yml index b2402e3..53c0e83 100644 --- a/.github/workflows/build-ubuntu-24.04.yml +++ b/.github/workflows/build-ubuntu-24.04.yml @@ -23,9 +23,6 @@ jobs: - name: Install dependencies run: sudo apt update && sudo apt install libmpv-dev gcc libegl1-mesa-dev xorg-dev - - name: Build - run: go build - - name: Install Fyne tool run: go install fyne.io/fyne/v2/cmd/fyne@latest diff --git a/.github/workflows/build-windows.yml b/.github/workflows/build-windows.yml index 1d8e7dd..7775e3d 100644 --- a/.github/workflows/build-windows.yml +++ b/.github/workflows/build-windows.yml @@ -36,9 +36,6 @@ jobs: pacman -Syu && pacman --noconfirm -S mingw-w64-x86_64-toolchain mingw-w64-x86_64-mpv - - name: Build - run: go build - - name: Install Fyne tool run: go install fyne.io/fyne/v2/cmd/fyne@latest From 6e7480f48a4be0a20273295ba9f15e2d4ca792c9 Mon Sep 17 00:00:00 2001 From: Drew Weymouth Date: Sun, 30 Mar 2025 14:39:27 -0700 Subject: [PATCH 35/45] pass MIME type and seekable flag to DLNA players --- backend/mediaprovider/model.go | 2 ++ backend/playbackengine.go | 9 +++++++++ backend/player/dlna/dlnaplayer.go | 12 ++++++++---- 3 files changed, 19 insertions(+), 4 deletions(-) diff --git a/backend/mediaprovider/model.go b/backend/mediaprovider/model.go index a39477d..bd7b4d7 100644 --- a/backend/mediaprovider/model.go +++ b/backend/mediaprovider/model.go @@ -183,6 +183,7 @@ const ( type MediaItemMetadata struct { Type MediaItemType + MIMEType string ID string Name string Artists []string @@ -204,6 +205,7 @@ func (t *Track) Metadata() MediaItemMetadata { } return MediaItemMetadata{ Type: MediaItemTypeTrack, + MIMEType: t.ContentType, ID: t.ID, Name: t.Title, Artists: t.ArtistNames, diff --git a/backend/playbackengine.go b/backend/playbackengine.go index 52b6e78..8ed8654 100644 --- a/backend/playbackengine.go +++ b/backend/playbackengine.go @@ -140,6 +140,14 @@ func (p *playbackEngine) registerPlayerCallbacks(pl player.BasePlayer) { }) } +func (p *playbackEngine) unregisterPlayerCallbacks(pl player.BasePlayer) { + pl.OnPaused(nil) + pl.OnPlaying(nil) + pl.OnStopped(nil) + pl.OnSeek(nil) + pl.OnTrackChange(nil) +} + func (p *playbackEngine) SetPlayer(pl player.BasePlayer) error { // even if we don't successfully change players, // make sure UI updates if needed (eg enabling cast button) @@ -162,6 +170,7 @@ func (p *playbackEngine) SetPlayer(pl player.BasePlayer) error { p.pendingPlayerChangeTimePos = stat.TimePos p.pendingPlayerChange = true } + p.unregisterPlayerCallbacks(p.player) if err := p.player.Stop(true); err != nil { log.Printf("failed to stop player: %v", err) } diff --git a/backend/player/dlna/dlnaplayer.go b/backend/player/dlna/dlnaplayer.go index 939824f..0dcb38f 100644 --- a/backend/player/dlna/dlnaplayer.go +++ b/backend/player/dlna/dlnaplayer.go @@ -156,8 +156,10 @@ func (d *DLNAPlayer) PlayFile(urlstr string, meta mediaprovider.MediaItemMetadat key := d.addURLToProxy(urlstr) media := avtransport.MediaItem{ - URL: d.urlForItem(key), - Title: meta.Name, + URL: d.urlForItem(key), + Title: meta.Name, + ContentType: meta.MIMEType, + Seekable: true, } if err := d.playAVTransportMedia(&media); err != nil { @@ -224,8 +226,10 @@ func (d *DLNAPlayer) SetNextFile(url string, meta mediaprovider.MediaItemMetadat key := d.addURLToProxy(url) media = &avtransport.MediaItem{ - URL: d.urlForItem(key), - Title: meta.Name, + URL: d.urlForItem(key), + ContentType: meta.MIMEType, + Title: meta.Name, + Seekable: true, } } From 28baf912f2dc48c415dba38501a78cbb3852bf76 Mon Sep 17 00:00:00 2001 From: Drew Weymouth Date: Sat, 5 Apr 2025 10:57:19 -0700 Subject: [PATCH 36/45] fix behavior of switching players while paused --- backend/app.go | 2 +- backend/mpmedia_mac.go | 6 ++--- backend/mpris.go | 12 ++++----- backend/playbackengine.go | 49 ++++++++++++++++++++++------------- backend/playbackmanager.go | 10 +++---- backend/savedplayqueue.go | 2 +- ui/browsing/nowplayingpage.go | 4 +-- ui/controller/controller.go | 9 ++++++- 8 files changed, 57 insertions(+), 37 deletions(-) diff --git a/backend/app.go b/backend/app.go index 55ca333..f76a58b 100644 --- a/backend/app.go +++ b/backend/app.go @@ -404,7 +404,7 @@ func (a *App) SetupWindowsSMTC(hwnd uintptr) { }) a.PlaybackManager.OnSeek(func() { dur := a.PlaybackManager.NowPlaying().Metadata().Duration - smtc.UpdatePosition(int(a.PlaybackManager.PlayerStatus().TimePos*1000), dur*1000) + smtc.UpdatePosition(int(a.PlaybackManager.PlaybackStatus().TimePos*1000), dur*1000) }) a.PlaybackManager.OnPlaying(func() { smtc.SetEnabled(true) diff --git a/backend/mpmedia_mac.go b/backend/mpmedia_mac.go index dff1fcc..e7616ee 100644 --- a/backend/mpmedia_mac.go +++ b/backend/mpmedia_mac.go @@ -84,17 +84,17 @@ func InitMPMediaHandler(playbackManager *PlaybackManager, artURLLookup func(trac }) mp.playbackManager.OnSeek(func() { - C.update_os_now_playing_info_position(C.double(mp.playbackManager.PlayerStatus().TimePos)) + C.update_os_now_playing_info_position(C.double(mp.playbackManager.PlaybackStatus().TimePos)) }) mp.playbackManager.OnPlaying(func() { C.set_os_playback_state_playing() - C.update_os_now_playing_info_position(C.double(mp.playbackManager.PlayerStatus().TimePos)) + C.update_os_now_playing_info_position(C.double(mp.playbackManager.PlaybackStatus().TimePos)) }) mp.playbackManager.OnPaused(func() { C.set_os_playback_state_paused() - C.update_os_now_playing_info_position(C.double(mp.playbackManager.PlayerStatus().TimePos)) + C.update_os_now_playing_info_position(C.double(mp.playbackManager.PlaybackStatus().TimePos)) }) return nil diff --git a/backend/mpris.go b/backend/mpris.go index 9d90da4..f2921a4 100644 --- a/backend/mpris.go +++ b/backend/mpris.go @@ -54,7 +54,7 @@ func NewMPRISHandler(playerName string, pm *PlaybackManager) *MPRISHandler { pm.OnSeek(func() { if m.connErr == nil { - pos := secondsToMicroseconds(pm.PlayerStatus().TimePos) + pos := secondsToMicroseconds(pm.PlaybackStatus().TimePos) m.evt.Player.OnSeek(pos) } }) @@ -160,7 +160,7 @@ func (m *MPRISHandler) Previous() error { } func (m *MPRISHandler) Pause() error { - if m.pm.PlayerStatus().State == player.Playing { + if m.pm.PlaybackStatus().State == player.Playing { m.pm.PlayPause() } return nil @@ -177,7 +177,7 @@ func (m *MPRISHandler) Stop() error { } func (m *MPRISHandler) Play() error { - switch m.pm.PlayerStatus().State { + switch m.pm.PlaybackStatus().State { case player.Paused: m.pm.PlayPause() case player.Stopped: @@ -204,7 +204,7 @@ func (m *MPRISHandler) OpenUri(uri string) error { } func (m *MPRISHandler) PlaybackStatus() (types.PlaybackStatus, error) { - switch m.pm.PlayerStatus().State { + switch m.pm.PlaybackStatus().State { case player.Playing: return types.PlaybackStatusPlaying, nil case player.Paused: @@ -254,7 +254,7 @@ func (m *MPRISHandler) Metadata() (types.Metadata, error) { if m.curTrackPath != "" { trackObjPath = m.curTrackPath } - status := m.pm.PlayerStatus() + status := m.pm.PlaybackStatus() var meta mediaprovider.MediaItemMetadata // metadata that can come only from tracks @@ -307,7 +307,7 @@ func (m *MPRISHandler) SetVolume(v float64) error { } func (m *MPRISHandler) Position() (int64, error) { - return int64(secondsToMicroseconds(m.pm.PlayerStatus().TimePos)), nil + return int64(secondsToMicroseconds(m.pm.PlaybackStatus().TimePos)), nil } func (m *MPRISHandler) MinimumRate() (float64, error) { diff --git a/backend/playbackengine.go b/backend/playbackengine.go index 8ed8654..09e7192 100644 --- a/backend/playbackengine.go +++ b/backend/playbackengine.go @@ -40,6 +40,14 @@ const ( LoopOne ) +type PlaybackState = player.State + +type PlaybackStatus struct { + State PlaybackState + TimePos float64 + Duration float64 +} + type playbackEngine struct { ctx context.Context cancelPollPos context.CancelFunc @@ -65,8 +73,8 @@ type playbackEngine struct { // and reset this to -1 pendingTrackChangeNum int - pendingPlayerChange bool - pendingPlayerChangeTimePos float64 + pendingPlayerChange bool + pendingPlayerChangeStatus player.Status // to pass to onSongChange listeners; clear once listeners have been called lastScrobbled *mediaprovider.Track @@ -155,7 +163,7 @@ func (p *playbackEngine) SetPlayer(pl player.BasePlayer) error { needToUnpause := false - stat := p.PlayerStatus() + stat := p.CurrentPlayer().GetStatus() if p.pendingPlayerChange { stat.State = player.Paused } @@ -165,9 +173,10 @@ func (p *playbackEngine) SetPlayer(pl player.BasePlayer) error { // nothing case player.Playing: needToUnpause = true + p.stopPollTimePos() fallthrough case player.Paused: - p.pendingPlayerChangeTimePos = stat.TimePos + p.pendingPlayerChangeStatus = stat p.pendingPlayerChange = true } p.unregisterPlayerCallbacks(p.player) @@ -183,7 +192,7 @@ func (p *playbackEngine) SetPlayer(pl player.BasePlayer) error { p.registerPlayerCallbacks(pl) if needToUnpause { - p.playTrackAt(p.nowPlayingIdx, p.pendingPlayerChangeTimePos) + p.playTrackAt(p.nowPlayingIdx, p.pendingPlayerChangeStatus.TimePos) p.pendingPlayerChange = false } p.invokeNoArgCallbacks(p.onPlayerChange) @@ -239,8 +248,16 @@ func (p *playbackEngine) GetLoopMode() LoopMode { return p.loopMode } -func (p *playbackEngine) PlayerStatus() player.Status { - return p.player.GetStatus() +func (p *playbackEngine) PlaybackStatus() PlaybackStatus { + stat := p.pendingPlayerChangeStatus + if !p.pendingPlayerChange { + stat = p.CurrentPlayer().GetStatus() + } + return PlaybackStatus{ + State: stat.State, + TimePos: stat.TimePos, + Duration: stat.Duration, + } } func (p *playbackEngine) SetVolume(vol int) error { @@ -259,14 +276,14 @@ func (p *playbackEngine) CurrentPlayer() player.BasePlayer { } func (p *playbackEngine) SeekNext() error { - if p.PlayerStatus().State == player.Stopped { + if p.PlaybackStatus().State == player.Stopped { return nil } return p.PlayTrackAt(p.nowPlayingIdx + 1) } func (p *playbackEngine) SeekBackOrPrevious() error { - if p.nowPlayingIdx == 0 || p.PlayerStatus().TimePos > 3 { + if p.nowPlayingIdx == 0 || p.PlaybackStatus().TimePos > 3 { return p.player.SeekSeconds(0) } return p.PlayTrackAt(p.nowPlayingIdx - 1) @@ -274,7 +291,7 @@ func (p *playbackEngine) SeekBackOrPrevious() error { func (p *playbackEngine) SeekFwdBackN(n int) error { idx := p.nowPlayingIdx - if n < 0 && p.PlayerStatus().TimePos > 3 { + if n < 0 && p.PlaybackStatus().TimePos > 3 { n += 1 // first seek back is just seek to beginning of current } if n == 0 || (idx == 0 && n < 0) { @@ -310,15 +327,11 @@ func (p *playbackEngine) Pause() error { func (p *playbackEngine) Continue() error { if p.pendingPlayerChange { - err := p.PlayTrackAt(p.nowPlayingIdx) - if p.pendingPlayerChangeTimePos != 0 { - p.SeekSeconds(p.pendingPlayerChangeTimePos) - } p.pendingPlayerChange = false - return err + return p.playTrackAt(p.nowPlayingIdx, p.pendingPlayerChangeStatus.TimePos) } - if p.PlayerStatus().State == player.Stopped { + if p.PlaybackStatus().State == player.Stopped { return p.PlayTrackAt(0) } return p.player.Continue() @@ -548,7 +561,7 @@ func (p *playbackEngine) handleOnTrackChange() { p.checkScrobble() } - if p.PlayerStatus().State == player.Playing { + if p.PlaybackStatus().State == player.Playing { p.playTimeStopwatch.Start() } if p.pendingTrackChangeNum < 0 && (p.wasStopped || p.loopMode != LoopOne) { @@ -785,7 +798,7 @@ func (p *playbackEngine) doUpdateTimePos(seeked bool) { if p.callbacksDisabled { return } - s := p.PlayerStatus() + s := p.PlaybackStatus() if s.TimePos > p.latestTrackPosition { p.latestTrackPosition = s.TimePos } diff --git a/backend/playbackmanager.go b/backend/playbackmanager.go index a6eb122..153760f 100644 --- a/backend/playbackmanager.go +++ b/backend/playbackmanager.go @@ -85,7 +85,7 @@ func (p *PlaybackManager) addOnTrackChangeHook() { 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 { + if p.NowPlayingIndex() != len(p.engine.playQueue) && p.PlaybackStatus().State == player.Playing { p.lastPlayTime = 0 go func() { time.Sleep(300 * time.Millisecond) @@ -476,8 +476,8 @@ func (p *PlaybackManager) IsAutoplay() bool { return p.autoplay } -func (p *PlaybackManager) PlayerStatus() player.Status { - return p.engine.PlayerStatus() +func (p *PlaybackManager) PlaybackStatus() PlaybackStatus { + return p.engine.PlaybackStatus() } func (p *PlaybackManager) SetVolume(vol int) { @@ -510,7 +510,7 @@ func (p *PlaybackManager) SeekSeconds(sec float64) { // Seek by given relative position in the current track by seconds. func (p *PlaybackManager) SeekBySeconds(sec float64) { - status := p.engine.PlayerStatus() + status := p.engine.PlaybackStatus() target := status.TimePos + sec if target < 0 { target = 0 @@ -548,7 +548,7 @@ func (p *PlaybackManager) Continue() { } func (p *PlaybackManager) PlayPause() { - switch p.engine.PlayerStatus().State { + switch p.engine.PlaybackStatus().State { case player.Playing: p.Pause() case player.Paused: diff --git a/backend/savedplayqueue.go b/backend/savedplayqueue.go index fd6e025..5efc0d8 100644 --- a/backend/savedplayqueue.go +++ b/backend/savedplayqueue.go @@ -26,7 +26,7 @@ type serializedSavedPlayQueue struct { // If the provided CanSavePlayQueue server is non-nil, it will also save to the server. func SavePlayQueue(serverID string, pm *PlaybackManager, filepath string, server mediaprovider.CanSavePlayQueue) error { queue := pm.GetPlayQueue() - stats := pm.PlayerStatus() + stats := pm.PlaybackStatus() trackIdx := pm.NowPlayingIndex() trackIDs := make([]string, 0, len(queue)) diff --git a/ui/browsing/nowplayingpage.go b/ui/browsing/nowplayingpage.go index ae539e5..bbb0a7f 100644 --- a/ui/browsing/nowplayingpage.go +++ b/ui/browsing/nowplayingpage.go @@ -443,7 +443,7 @@ func (a *NowPlayingPage) Reload() { } switch a.tabs.SelectedIndex() { case 1: /*lyrics*/ - a.lastPlayPos = a.pm.PlayerStatus().TimePos + a.lastPlayPos = a.pm.PlaybackStatus().TimePos a.updateLyrics() case 2: /*related*/ a.updateRelatedList() @@ -527,7 +527,7 @@ func (a *NowPlayingPage) saveSelectedTab(tabNum int) { func (a *NowPlayingPage) formatStatusLine() { curPlayer := a.pm.CurrentPlayer() - playerStats := a.pm.PlayerStatus() + playerStats := a.pm.PlaybackStatus() lastStatus := a.statusLabel.Text stopped := lang.L("Stopped") state := stopped diff --git a/ui/controller/controller.go b/ui/controller/controller.go index 67dde41..91bd54a 100644 --- a/ui/controller/controller.go +++ b/ui/controller/controller.go @@ -155,6 +155,9 @@ func (m *Controller) ShowCastMenu(onPendingPlayerChange func()) { rp := m.App.PlaybackManager.CurrentRemotePlayer() devices := m.App.PlaybackManager.RemotePlayers() local := fyne.NewMenuItem(lang.L("This computer"), func() { + if rp == nil { + return // no-op. already not casting + } onPendingPlayerChange() go func() { if err := m.App.PlaybackManager.SetRemotePlayer(nil); err != nil { @@ -168,7 +171,11 @@ func (m *Controller) ShowCastMenu(onPendingPlayerChange func()) { menu := fyne.NewMenu("", local) for _, d := range devices { _d := d + isCurrent := rp != nil && _d.URL == rp.URL item := fyne.NewMenuItem(d.Name, func() { + if isCurrent { + return // no-op. + } onPendingPlayerChange() go func() { if err := m.App.PlaybackManager.SetRemotePlayer(&_d); err != nil { @@ -177,7 +184,7 @@ func (m *Controller) ShowCastMenu(onPendingPlayerChange func()) { }() }) item.Icon = myTheme.CastIcon - item.Checked = rp != nil && _d.URL == rp.URL + item.Checked = isCurrent menu.Items = append(menu.Items, item) } pop := widget.NewPopUpMenu(menu, m.MainWindow.Canvas()) From 1ce10033f41cc97a8728f6fdf35d84a49c6ed95a Mon Sep 17 00:00:00 2001 From: Drew Weymouth Date: Sat, 5 Apr 2025 11:50:20 -0700 Subject: [PATCH 37/45] Update README.md - DLNA cast, rewrite installation section --- README.md | 39 ++++++++++++++++++++++++++++++++++----- 1 file changed, 34 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 2a36033..29e1060 100644 --- a/README.md +++ b/README.md @@ -55,22 +55,51 @@ Supersonic supports any music server with a Subsonic (or OpenSubsonic) API, or J * [x] Shuffle and repeat playback modes (partial; shuffle album, playlist, artist radio, random songs; repeat one/all) * [x] Lyrics support * [x] Internet radio station support (Subsonic) +* [x] Cast to uPnP/DLNA devices (likely planned) * [ ] Server jukebox control (planned) * [ ] Browse by folders (planned) -* [ ] Cast to uPnP/DLNA devices (likely planned) * [ ] Offline mode (eventually planned) * [ ] iOS/Android support (maybe eventually planned) ## Installation -On Linux, Supersonic is [available as a Flatpak](https://flathub.org/apps/details/io.github.dweymouth.supersonic)! (Thank you @anarcat!) If you prefer to directly install the release build, or build from source, read below. +Platform-specific installation instructions are listed below. In addition to the most recent stable release, you can also download the latest build from the `main` branch via the [Actions](https://github.com/dweymouth/supersonic/actions) tab to get unreleased features and bug fixes (you must be signed in to Github to do this). If you prefer to build from source, or are not running one of these OSes, then see the build instructions for your platform in the next section. -If you are running **Windows**, **Mac OS**, or a **Debian**-based Linux distro, download the [latest release](https://github.com/dweymouth/supersonic/releases) for your operating system. You can also download the latest build from the `main` branch via the [Actions](https://github.com/dweymouth/supersonic/actions) tab to get unreleased features and bug fixes (you must be signed in to Github to do this). If you prefer to build from source, or are not running one of these OSes, then see the build instructions for your platform below. +### Linux + +**Packages:** On Linux, Supersonic is [available as a Flatpak](https://flathub.org/apps/details/io.github.dweymouth.supersonic). (Thank you @anarcat!) Third-party packages are also available for Arch and Nix OS. + +**AppImage:** On the [latest release](https://github.com/dweymouth/supersonic/releases) page, you can download an AppImage package which supports any OS that can run AppImages. The MPV library is bundled in the AppImage. + +**(Debian) .tar.xz:** The.tar.xz builds from the Releases page support Debian-based distros. You **must** have libmpv installed on your system, and choose the correct release build (libmpv2 or libmpv1) based on which is available in your distro's package manager. On apt-based systems, run `sudo apt install libmpv1` (or libmpv2) if it is not already installed. To install the Linux release build, after ensuring the required libmpv is installed, extract the .tar.xz bundle and run `make user-install` or `sudo make install`. + +### Windows + +Download the [latest release](https://github.com/dweymouth/supersonic/releases). You can choose between the installer, or a standalone zip file which can be extracted and run without requiring system installation. + +### Mac OS + +Supersonic is available on Homebrew via a custom brew tap, or via downloading the .app bundle from the Releases page. + +To install Supersonic with Homebrew run: + +```sh +brew tap supersonic-app/homebrew +brew install --no-quarantine supersonic +``` + +The `--no-quarantine` flag is important because Supersonic is distributed without having been [notarized](https://developer.apple.com/documentation/security/notarizing-macos-software-before-distribution), and therefore will not run without this. + +You should also include it when upgrading in future: + +``` +brew upgrade --no-quarantine supersonic +``` + +To install the downloaded .app bundle from the [Releases page](https://github.com/dweymouth/supersonic/releases), unzip and then drag Supersonic.app to the Applications folder. **Apple Silicon (M1/M2) Macs:** You will have to remove the "quarantine bit" that Mac will automatically set, being an application downloaded from the internet. After copying the .app bundle to your Applications folder, in the terminal run `sudo xattr -r -d com.apple.quarantine /Applications/Supersonic.app` -**If you are on Linux** and using the build from the Releases page, you must have libmpv installed on your system, and choose the correct release build (libmpv2 or libmpv1) based on which is available in your distro's package manager. On apt-based systems, run `sudo apt install libmpv1` (or libmpv2) if it is not already installed. The Windows and Mac release builds bundle the mpv dependencies. To install the Linux release build, after ensuring the required libmpv is installed, extract the .tar.xz bundle and run `make user-install` or `sudo make install`. - ## Build instructions (Linux) ### Ubuntu dependencies From b24a0abcc6cf09b1e58e296fe09f52b92af34bf3 Mon Sep 17 00:00:00 2001 From: Drew Weymouth Date: Sat, 5 Apr 2025 11:51:08 -0700 Subject: [PATCH 38/45] Update README.md --- README.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 29e1060..25019d9 100644 --- a/README.md +++ b/README.md @@ -55,7 +55,7 @@ Supersonic supports any music server with a Subsonic (or OpenSubsonic) API, or J * [x] Shuffle and repeat playback modes (partial; shuffle album, playlist, artist radio, random songs; repeat one/all) * [x] Lyrics support * [x] Internet radio station support (Subsonic) -* [x] Cast to uPnP/DLNA devices (likely planned) +* [x] Cast to uPnP/DLNA devices * [ ] Server jukebox control (planned) * [ ] Browse by folders (planned) * [ ] Offline mode (eventually planned) @@ -81,7 +81,7 @@ Download the [latest release](https://github.com/dweymouth/supersonic/releases). Supersonic is available on Homebrew via a custom brew tap, or via downloading the .app bundle from the Releases page. -To install Supersonic with Homebrew run: +To install Supersonic with **Homebrew** run: ```sh brew tap supersonic-app/homebrew @@ -96,7 +96,7 @@ You should also include it when upgrading in future: brew upgrade --no-quarantine supersonic ``` -To install the downloaded .app bundle from the [Releases page](https://github.com/dweymouth/supersonic/releases), unzip and then drag Supersonic.app to the Applications folder. +To install the **downloaded .app bundle** from the [Releases page](https://github.com/dweymouth/supersonic/releases), unzip and then drag Supersonic.app to the Applications folder. **Apple Silicon (M1/M2) Macs:** You will have to remove the "quarantine bit" that Mac will automatically set, being an application downloaded from the internet. After copying the .app bundle to your Applications folder, in the terminal run `sudo xattr -r -d com.apple.quarantine /Applications/Supersonic.app` From b615845e1f76484658b51a065fccc3a22c829798 Mon Sep 17 00:00:00 2001 From: Drew Weymouth Date: Sun, 6 Apr 2025 07:56:22 -0700 Subject: [PATCH 39/45] Update README.md - homebrew tap name --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 25019d9..4427104 100644 --- a/README.md +++ b/README.md @@ -84,7 +84,7 @@ Supersonic is available on Homebrew via a custom brew tap, or via downloading th To install Supersonic with **Homebrew** run: ```sh -brew tap supersonic-app/homebrew +brew tap supersonic-app/supersonic brew install --no-quarantine supersonic ``` From 705318f9501b2f392dac936ebd20d7ec8842d146 Mon Sep 17 00:00:00 2001 From: Drew Weymouth Date: Sun, 6 Apr 2025 10:42:39 -0700 Subject: [PATCH 40/45] update Fyne --- go.mod | 9 ++++++--- go.sum | 18 ++++++++++++------ 2 files changed, 18 insertions(+), 9 deletions(-) diff --git a/go.mod b/go.mod index 91c3880..dcbf747 100644 --- a/go.mod +++ b/go.mod @@ -21,7 +21,7 @@ require ( github.com/supersonic-app/go-subsonic v0.0.0-20241224013245-9b2841f3711d github.com/supersonic-app/go-upnpcast v0.0.0-20250330154256-b957204209a5 github.com/zalando/go-keyring v0.2.6 - golang.org/x/net v0.33.0 + golang.org/x/net v0.35.0 golang.org/x/sys v0.30.0 golang.org/x/text v0.22.0 ) @@ -36,12 +36,15 @@ require ( github.com/fsnotify/fsnotify v1.7.0 // indirect github.com/fyne-io/gl-js v0.1.0 // indirect github.com/fyne-io/glfw-js v0.2.0 // indirect - github.com/fyne-io/image v0.1.0 // indirect + github.com/fyne-io/image v0.1.1 // indirect + github.com/fyne-io/oksvg v0.1.0 // indirect github.com/go-gl/gl v0.0.0-20231021071112-07e5d0ea2e71 // indirect github.com/go-gl/glfw/v3.3/glfw v0.0.0-20240506104042-037f3cc74f2a // indirect github.com/go-text/render v0.2.0 // indirect github.com/go-text/typesetting v0.2.1 // indirect github.com/h2non/filetype v1.1.3 // indirect + github.com/hack-pad/go-indexeddb v0.3.2 // indirect + github.com/hack-pad/safejs v0.1.0 // indirect github.com/hashicorp/go-cleanhttp v0.5.2 // indirect github.com/jeandeaual/go-locale v0.0.0-20241217141322-fcc2cadd6f08 // indirect github.com/jsummers/gobmp v0.0.0-20230614200233-a9de23ed2e25 // indirect @@ -58,4 +61,4 @@ require ( gopkg.in/yaml.v3 v3.0.1 // indirect ) -replace fyne.io/fyne/v2 v2.6.0-beta1 => github.com/dweymouth/fyne/v2 v2.3.0-rc1.0.20250308154116-c32a0de49bde +replace fyne.io/fyne/v2 v2.6.0-beta1 => github.com/dweymouth/fyne/v2 v2.3.0-rc1.0.20250406173843-f5cb002423a5 diff --git a/go.sum b/go.sum index e4cd2e6..c26a6e2 100644 --- a/go.sum +++ b/go.sum @@ -23,8 +23,8 @@ github.com/dweymouth/fyne-lyrics v0.0.0-20240528234907-15eee7ce5e64 h1:RUIrnGY03 github.com/dweymouth/fyne-lyrics v0.0.0-20240528234907-15eee7ce5e64/go.mod h1:3YrjFDHMlhCsSZ/OvmJCxWm9QHSgOVWZBxnraZz9Z7c= github.com/dweymouth/fyne-tooltip v0.3.0 h1:NKCyTkh9NtvnTsiHtTOtaJzRDOFYP8AckQ2tyhOh6JY= github.com/dweymouth/fyne-tooltip v0.3.0/go.mod h1:m04ShLW/Tp6LXrNieTumApvNgo7YSB+wi+jZTN+kDBU= -github.com/dweymouth/fyne/v2 v2.3.0-rc1.0.20250308154116-c32a0de49bde h1:vSJQkJWRwwTnex2cSsGQNA9EZKzCUKzFc1Gs8A/JUXs= -github.com/dweymouth/fyne/v2 v2.3.0-rc1.0.20250308154116-c32a0de49bde/go.mod h1:ON11afuS9jVyN+nfEtITv3jX1H+JD+Z50gxCiEC1zLw= +github.com/dweymouth/fyne/v2 v2.3.0-rc1.0.20250406173843-f5cb002423a5 h1:/gxlRSmndtF8QVxV3oFjlozm2clkMlLBlfiKFtehH+M= +github.com/dweymouth/fyne/v2 v2.3.0-rc1.0.20250406173843-f5cb002423a5/go.mod h1:YZt7SksjvrSNJCwbWFV32WON3mE1Sr7L41D29qMZ/lU= github.com/dweymouth/go-jellyfin v0.0.0-20240517151952-5ceca61cb645 h1:KzqSaQwG3HsTZQlEtkp0BeUy9vmYZ0rq0B15qIPSiBs= github.com/dweymouth/go-jellyfin v0.0.0-20240517151952-5ceca61cb645/go.mod h1:fcUagHBaQnt06GmBAllNE0J4O/7064zXRWdqnTTtVjI= github.com/fatih/color v1.16.0 h1:zmkK9Ngbjj+K0yRhTVONQh1p/HknKYSlNT+vZCzyokM= @@ -39,8 +39,10 @@ github.com/fyne-io/gl-js v0.1.0 h1:8luJzNs0ntEAJo+8x8kfUOXujUlP8gB3QMOxO2mUdpM= github.com/fyne-io/gl-js v0.1.0/go.mod h1:ZcepK8vmOYLu96JoxbCKJy2ybr+g1pTnaBDdl7c3ajI= github.com/fyne-io/glfw-js v0.2.0 h1:8GUZtN2aCoTPNqgRDxK5+kn9OURINhBEBc7M4O1KrmM= github.com/fyne-io/glfw-js v0.2.0/go.mod h1:Ri6te7rdZtBgBpxLW19uBpp3Dl6K9K/bRaYdJ22G8Jk= -github.com/fyne-io/image v0.1.0 h1:Vm2TQJ2PWGHCf3jYi1/XroaNNMu+GfI/O2QpSbZd4XQ= -github.com/fyne-io/image v0.1.0/go.mod h1:xrfYBh6yspc+KjkgdZU/ifUC9sPA5Iv7WYUBzQKK7JM= +github.com/fyne-io/image v0.1.1 h1:WH0z4H7qfvNUw5l4p3bC1q70sa5+YWVt6HCj7y4VNyA= +github.com/fyne-io/image v0.1.1/go.mod h1:xrfYBh6yspc+KjkgdZU/ifUC9sPA5Iv7WYUBzQKK7JM= +github.com/fyne-io/oksvg v0.1.0 h1:7EUKk3HV3Y2E+qypp3nWqMXD7mum0hCw2KEGhI1fnBw= +github.com/fyne-io/oksvg v0.1.0/go.mod h1:dJ9oEkPiWhnTFNCmRgEze+YNprJF7YRbpjgpWS4kzoI= github.com/go-gl/gl v0.0.0-20231021071112-07e5d0ea2e71 h1:5BVwOaUSBTlVZowGO6VZGw2H/zl9nrd3eCZfYV+NfQA= github.com/go-gl/gl v0.0.0-20231021071112-07e5d0ea2e71/go.mod h1:9YTyiznxEY1fVinfM7RvRcjRHbw2xLBJ3AAGIT0I4Nw= github.com/go-gl/glfw/v3.3/glfw v0.0.0-20240506104042-037f3cc74f2a h1:vxnBhFDDT+xzxf1jTJKMKZw3H0swfWk9RpWbBbDK5+0= @@ -61,6 +63,10 @@ github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I= github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/h2non/filetype v1.1.3 h1:FKkx9QbD7HR/zjK1Ia5XiBsq9zdLi5Kf3zGyFTAFkGg= github.com/h2non/filetype v1.1.3/go.mod h1:319b3zT68BvV+WRj7cwy856M2ehB3HqNOt6sy1HndBY= +github.com/hack-pad/go-indexeddb v0.3.2 h1:DTqeJJYc1usa45Q5r52t01KhvlSN02+Oq+tQbSBI91A= +github.com/hack-pad/go-indexeddb v0.3.2/go.mod h1:QvfTevpDVlkfomY498LhstjwbPW6QC4VC/lxYb0Kom0= +github.com/hack-pad/safejs v0.1.0 h1:qPS6vjreAqh2amUqj4WNG1zIw7qlRQJ9K10eDKMCnE8= +github.com/hack-pad/safejs v0.1.0/go.mod h1:HdS+bKF1NrE72VoXZeWzxFOVQVUSqZJAG0xNCnb+Tio= github.com/hashicorp/go-cleanhttp v0.5.2 h1:035FKYIWjmULyFRBKPs8TBQoi0x6d9G4xc9neXJWAZQ= github.com/hashicorp/go-cleanhttp v0.5.2/go.mod h1:kO/YDlP8L1346E6Sodw+PrpBSV4/SoxCXGY6BqNFT48= github.com/hashicorp/go-hclog v1.6.3 h1:Qr2kF+eVWjTiYmU7Y31tYlP1h0q/X3Nl3tPGdaB11/k= @@ -131,8 +137,8 @@ golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLL golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= -golang.org/x/net v0.33.0 h1:74SYHlV8BIgHIFC/LrYkOGIwL19eTYXQ5wc6TBuO36I= -golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4= +golang.org/x/net v0.35.0 h1:T5GQRQb2y08kTAByq9L4/bz8cipCdA8FbRTXewonqY8= +golang.org/x/net v0.35.0/go.mod h1:EglIi67kWsHKlRzzVMUD93VMSWGFOMSZgxFjparz1Qk= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= From 7438ecbbd6af728b064b937718bff0cbfd5e1f39 Mon Sep 17 00:00:00 2001 From: Drew Weymouth Date: Sun, 6 Apr 2025 10:47:40 -0700 Subject: [PATCH 41/45] add new "Cast to device" translation string --- res/translations/en.json | 1 + 1 file changed, 1 insertion(+) diff --git a/res/translations/en.json b/res/translations/en.json index d2cfee6..262c0b5 100644 --- a/res/translations/en.json +++ b/res/translations/en.json @@ -41,6 +41,7 @@ "BPM": "BPM", "Broadcast": "Broadcast", "Cancel": "Cancel", + "Cast to device": "Cast to device", "Check for Updates": "Check for Updates", "Close": "Close", "Close to system tray": "Close to system tray", From 2b2e2251e717eafc94dcda82071b2cd063c1e9ec Mon Sep 17 00:00:00 2001 From: Drew Weymouth Date: Sun, 6 Apr 2025 11:04:12 -0700 Subject: [PATCH 42/45] prepare for 0.15.0 release --- CHANGELOG.md | 13 +++++++ FyneApp.toml | 2 +- ...io.github.dweymouth.supersonic.appdata.xml | 35 ++++++------------- res/metadata.go | 31 +++++----------- win_inno_installscript.iss | 2 +- 5 files changed, 34 insertions(+), 49 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d25b8a0..5b41492 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,18 @@ # Change Log +## [0.15.0] + +### Added +- [#582](https://github.com/dweymouth/supersonic/pull/582) Add ability to cast playback to DLNA devices +- [#574](https://github.com/dweymouth/supersonic/pull/574) Add ability to search tracks within playlist +- [#584](https://github.com/dweymouth/supersonic/issues/584) Add context menu actions to items in "Search Everywhere" dialog +- [#571](https://github.com/dweymouth/supersonic/issues/571) Make server requests timeout configurable and increase default timeout +- New overlay animation when showing full size covers + +### Fixed +- [#578](https://github.com/dweymouth/supersonic/issues/578),[#530](https://github.com/dweymouth/supersonic/issues/530) Wrong track scrobbled when skipping next +- [#580](https://github.com/dweymouth/supersonic/issues/580) Regression: searching the "All Tracks" page skips some results + ## [0.14.0] ### Added diff --git a/FyneApp.toml b/FyneApp.toml index 0add8c8..2fb7876 100644 --- a/FyneApp.toml +++ b/FyneApp.toml @@ -1,7 +1,7 @@ [Details] Icon = "res/appicon-512.png" Name = "Supersonic" - Version = "0.14.0" + Version = "0.15.0" [LinuxAndBSD] Categories = ["Audio", "AudioVideo"] diff --git a/res/io.github.dweymouth.supersonic.appdata.xml b/res/io.github.dweymouth.supersonic.appdata.xml index c419982..d796b5d 100644 --- a/res/io.github.dweymouth.supersonic.appdata.xml +++ b/res/io.github.dweymouth.supersonic.appdata.xml @@ -33,6 +33,8 @@
  • Sort tracklist views by column and configure visible tracklist columns
  • Set/unset favorite and browse by favorite albums, artists, and songs
  • Shuffle and repeat playback modes (partial; shuffle album, playlist, artist radio, random songs)
  • +
  • Internet radio station support (Subsonic)
  • +
  • Cast to uPnP/DLNA devices
  • @@ -61,44 +63,29 @@ - +

    - Version 0.14.0 of Supersonic + Version 0.15.0 of Supersonic

    Added

      -
    • New translations: Dutch
    • -
    • Media Key / SMTC support for Windows
    • -
    • Add an Autoplay mode to continually add related songs when nearing the end of queue
    • -
    • Shuffle button added to favorite songs view
    • -
    • Added toast notifications for certain UI actions
    • -
    • Add album shuffle modes to Albums and Genre pages
    • -
    • Caching added for LrcLib lyrics, custom LrcLib URL supported in config file
    • -
    • Use notify-send for notifications on Linux if available
    • -
    • Add setting to disable automatic scaling adjustment for detected DPI
    • -
    • New Advanced settings tab exposing more config file settings to the UI
    • -
    • Favorite/menu icon buttons added to grid cards when hovered
    • -
    • Show full dates from OpenSubsonic servers when available
    • -
    • Log to a file instead of stdout on Windows
    • +
    • Add ability to cast playback to DLNA devices
    • +
    • Add ability to search tracks within playlist
    • +
    • Add context menu actions to items in "Search Everywhere" dialog
    • +
    • Make server requests timeout configurable and increase default timeout
    • +
    • New overlay animation when showing full size covers

    Fixed

      -
    • Persist repeat setting across restarts
    • -
    • Removing track from playlist when sorted removed wrong track
    • -
    • Fullscreen artist images not loading in full resolution
    • -
    • Fix volume slider scrolling too quickly with some mice
    • -
    • Loading dots not stopping if canceling a search before completion
    • -
    • Add list/grid debouncing to improve scrolling smoothness when scrolling very quickly
    • -
    • Image caching broken on some servers due to track IDs not being legal filenames
    • -
    • Migrate to Fyne 2.6 for improved stability and performance
    • -
    • Sometimes crashing on exit
    • +
    • Wrong track scrobbled when skipping next
    • +
    • Regression: searching the "All Tracks" page skips some results
    diff --git a/res/metadata.go b/res/metadata.go index 889d1be..75b3298 100644 --- a/res/metadata.go +++ b/res/metadata.go @@ -3,7 +3,7 @@ package res const ( AppName = "supersonic" DisplayName = "Supersonic" - AppVersion = "0.14.0" + AppVersion = "0.15.0" AppVersionTag = "v" + AppVersion ConfigFile = "config.toml" GithubURL = "https://github.com/dweymouth/supersonic" @@ -15,29 +15,14 @@ const ( var ( WhatsAdded = ` ## Added -* New translations: Dutch -* Media Key / SMTC support for Windows -* Add an Autoplay mode to continually add related songs when nearing the end of queue -* Shuffle button added to favorite songs view -* Added toast notifications for certain UI actions -* Add album shuffle modes to Albums and Genre pages -* Caching added for LrcLib lyrics, custom LrcLib URL supported in config file -* Use notify-send for notifications on Linux if available -* Add setting to disable automatic scaling adjustment for detected DPI -* New Advanced settings tab exposing more config file settings to the UI -* Favorite/menu icon buttons added to grid cards when hovered -* Show full dates from OpenSubsonic servers when available -* Log to a file instead of stdout on Windows` +* Add ability to cast playback to DLNA devices +* Add ability to search tracks within playlist +* Add context menu actions to items in "Search Everywhere" dialog +* Make server requests timeout configurable and increase default timeout +* New overlay animation when showing full size covers` WhatsFixed = ` ## Fixed -* Persist repeat setting across restarts -* Removing track from playlist when sorted removed wrong track -* Fullscreen artist images not loading in full resolution -* Fix volume slider scrolling too quickly with some mice -* Loading dots not stopping if canceling a search before completion -* Add list/grid debouncing to improve scrolling smoothness when scrolling very quickly -* Image caching broken on some servers due to track IDs not being legal filenames -* Migrate to Fyne 2.6 for improved stability and performance -* Sometimes crashing on exit` +* Wrong track scrobbled when skipping next +* Regression: searching the "All Tracks" page skips some results` ) diff --git a/win_inno_installscript.iss b/win_inno_installscript.iss index 17d590d..56744d3 100644 --- a/win_inno_installscript.iss +++ b/win_inno_installscript.iss @@ -2,7 +2,7 @@ ; SEE THE DOCUMENTATION FOR DETAILS ON CREATING INNO SETUP SCRIPT FILES! #define MyAppName "Supersonic" -#define MyAppVersion "0.14.0" +#define MyAppVersion "0.15.0" #define MyAppPublisher "Drew Weymouth" #define MyAppURL "https://github.com/dweymouth/supersonic" #define MyAppExeName "Supersonic.exe" From b16dc5034e7b98c853e3fa6e69c19ba8516855c6 Mon Sep 17 00:00:00 2001 From: Drew Weymouth Date: Sun, 6 Apr 2025 13:54:24 -0700 Subject: [PATCH 43/45] fix crash when showing album filter dialog --- ui/widgets/albumfilterbutton.go | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/ui/widgets/albumfilterbutton.go b/ui/widgets/albumfilterbutton.go index 3562868..43cb30c 100644 --- a/ui/widgets/albumfilterbutton.go +++ b/ui/widgets/albumfilterbutton.go @@ -199,7 +199,10 @@ func NewAlbumFilterPopup(filter *AlbumFilterButton) *AlbumFilterPopup { ) go func() { - a.genreFilter.SetGenreList(<-a.filterBtn.genreListChan) + genres := <-a.filterBtn.genreListChan + fyne.Do(func() { + a.genreFilter.SetGenreList(genres) + }) }() return a From 876ac22ee1c57c6df7354bdecec7bde9e4144fa2 Mon Sep 17 00:00:00 2001 From: Magnus Larsen Date: Sun, 6 Apr 2025 17:29:37 -0700 Subject: [PATCH 44/45] WindowsSMTC: Use playback status' track duration --- backend/app.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/backend/app.go b/backend/app.go index f76a58b..9e0183c 100644 --- a/backend/app.go +++ b/backend/app.go @@ -403,8 +403,8 @@ func (a *App) SetupWindowsSMTC(hwnd uintptr) { }() }) a.PlaybackManager.OnSeek(func() { - dur := a.PlaybackManager.NowPlaying().Metadata().Duration - smtc.UpdatePosition(int(a.PlaybackManager.PlaybackStatus().TimePos*1000), dur*1000) + playbackStatus := a.PlaybackManager.PlaybackStatus() + smtc.UpdatePosition(int(playbackStatus.TimePos*1000), int(playbackStatus.Duration*1000)) }) a.PlaybackManager.OnPlaying(func() { smtc.SetEnabled(true) From a479c554af8bee60f3d8c0ac440772aa14fbf5e5 Mon Sep 17 00:00:00 2001 From: Drew Weymouth Date: Mon, 7 Apr 2025 07:55:30 -0700 Subject: [PATCH 45/45] prepare for 0.15.1 release --- CHANGELOG.md | 6 ++++++ FyneApp.toml | 2 +- ...io.github.dweymouth.supersonic.appdata.xml | 19 ++++--------------- res/metadata.go | 14 ++++---------- win_inno_installscript.iss | 2 +- 5 files changed, 16 insertions(+), 27 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5b41492..12de2b5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Change Log +## [0.15.1] + +### Fixed +- [#591](https://github.com/dweymouth/supersonic/pull/591) Fix crashing on playback on Windows +- Fix crashing when clicking on album filters button + ## [0.15.0] ### Added diff --git a/FyneApp.toml b/FyneApp.toml index 2fb7876..cc5c35c 100644 --- a/FyneApp.toml +++ b/FyneApp.toml @@ -1,7 +1,7 @@ [Details] Icon = "res/appicon-512.png" Name = "Supersonic" - Version = "0.15.0" + Version = "0.15.1" [LinuxAndBSD] Categories = ["Audio", "AudioVideo"] diff --git a/res/io.github.dweymouth.supersonic.appdata.xml b/res/io.github.dweymouth.supersonic.appdata.xml index d796b5d..06b3510 100644 --- a/res/io.github.dweymouth.supersonic.appdata.xml +++ b/res/io.github.dweymouth.supersonic.appdata.xml @@ -63,29 +63,18 @@ - +

    - Version 0.15.0 of Supersonic + Version 0.15.1 of Supersonic

    -

    - Added -

    -
      -
    • Add ability to cast playback to DLNA devices
    • -
    • Add ability to search tracks within playlist
    • -
    • Add context menu actions to items in "Search Everywhere" dialog
    • -
    • Make server requests timeout configurable and increase default timeout
    • -
    • New overlay animation when showing full size covers
    • -
    -

    Fixed

      -
    • Wrong track scrobbled when skipping next
    • -
    • Regression: searching the "All Tracks" page skips some results
    • +
    • Fix crashing on playback on Windows
    • +
    • Fix crashing when clicking on album filters button
    diff --git a/res/metadata.go b/res/metadata.go index 75b3298..2a4172e 100644 --- a/res/metadata.go +++ b/res/metadata.go @@ -3,7 +3,7 @@ package res const ( AppName = "supersonic" DisplayName = "Supersonic" - AppVersion = "0.15.0" + AppVersion = "0.15.1" AppVersionTag = "v" + AppVersion ConfigFile = "config.toml" GithubURL = "https://github.com/dweymouth/supersonic" @@ -13,16 +13,10 @@ const ( ) var ( - WhatsAdded = ` -## Added -* Add ability to cast playback to DLNA devices -* Add ability to search tracks within playlist -* Add context menu actions to items in "Search Everywhere" dialog -* Make server requests timeout configurable and increase default timeout -* New overlay animation when showing full size covers` + WhatsAdded = `` WhatsFixed = ` ## Fixed -* Wrong track scrobbled when skipping next -* Regression: searching the "All Tracks" page skips some results` +* Fix crashing on playback on Windows +* Fix crashing when clicking on album filters button` ) diff --git a/win_inno_installscript.iss b/win_inno_installscript.iss index 56744d3..705294f 100644 --- a/win_inno_installscript.iss +++ b/win_inno_installscript.iss @@ -2,7 +2,7 @@ ; SEE THE DOCUMENTATION FOR DETAILS ON CREATING INNO SETUP SCRIPT FILES! #define MyAppName "Supersonic" -#define MyAppVersion "0.15.0" +#define MyAppVersion "0.15.1" #define MyAppPublisher "Drew Weymouth" #define MyAppURL "https://github.com/dweymouth/supersonic" #define MyAppExeName "Supersonic.exe"