From aee62216917e658cfb14ea4292fe2237823a3c76 Mon Sep 17 00:00:00 2001 From: Drew Weymouth Date: Sun, 9 Apr 2023 13:14:39 -0700 Subject: [PATCH 1/7] Fix #55: show disc number and disc count for multi-disc albums --- CHANGELOG.md | 1 + ui/browsing/albumpage.go | 7 ++++++- ui/widgets/tracklist.go | 19 +++++++++++++++++-- 3 files changed, 24 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index bc792d4..868480d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ - **todo-commithash** Don't show update available prompt if the found version is the same as the running app version - [#120](https://github.com/dweymouth/supersonic/issues/120),[#87](https://github.com/dweymouth/supersonic/issues/87) Update Mac build process to support OS versions back to High Sierra (thanks @whorfin!) - [#125](https://github.com/dweymouth/supersonic/issues/125) Navigating back twice to an albums page with search result clears search state +- [#55](https://github.com/dweymouth/supersonic/issues/55) Show disc number and disc count for multi-disc albums ## [0.1.0-beta] - 2023-04-01 diff --git a/ui/browsing/albumpage.go b/ui/browsing/albumpage.go index a4eaf03..b3b6368 100644 --- a/ui/browsing/albumpage.go +++ b/ui/browsing/albumpage.go @@ -121,6 +121,7 @@ func (a *AlbumPage) load() { return } a.header.Update(album, a.im) + a.tracklist.ShowDiscNumber = album.Song[0].DiscNumber != album.Song[len(album.Song)-1].DiscNumber a.tracklist.Tracks = album.Song a.tracklist.SetNowPlaying(a.nowPlayingID) } @@ -236,7 +237,11 @@ func (a *AlbumPageHeader) showPopUpCover() { } func formatMiscLabelStr(a *subsonic.AlbumID3) string { - return fmt.Sprintf("%d · %d tracks · %s", a.Year, a.SongCount, util.SecondsToTimeString(float64(a.Duration))) + var discs string + if discCount := a.Song[len(a.Song)-1].DiscNumber; discCount > 1 { + discs = fmt.Sprintf("%d discs · ", discCount) + } + return fmt.Sprintf("%d · %d tracks · %s%s", a.Year, a.SongCount, discs, util.SecondsToTimeString(float64(a.Duration))) } func (s *albumPageState) Restore() Page { diff --git a/ui/widgets/tracklist.go b/ui/widgets/tracklist.go index eaed121..f47d693 100644 --- a/ui/widgets/tracklist.go +++ b/ui/widgets/tracklist.go @@ -1,6 +1,7 @@ package widgets import ( + "fmt" "log" "strconv" "supersonic/res" @@ -40,6 +41,10 @@ type Tracklist struct { // or to use the number from the track's metadata AutoNumber bool + // ShowDiscNumber sets whether to display the disc number as part of the '#' column, + // (with format %d.%02d). Only applies if AutoNumber==false. + ShowDiscNumber bool + // AuxiliaryMenuItems sets additional menu items appended to the context menu // must be set before the context menu is shown for the first time AuxiliaryMenuItems []*fyne.MenuItem @@ -77,7 +82,7 @@ func NewTracklist(tracks []*subsonic.Child) *Tracklist { t.ExtendBaseWidget(t) t.selectionMgr = util.NewListSelectionManager(func() int { return len(t.Tracks) }) // #, Title, Artist, Album, Time, Year, Favorite, Plays, Bitrate, Size, Path - t.colLayout = layouts.NewColumnsLayout([]float32{35, -1, -1, -1, 60, 60, 47, 65, 75, 70, -1}) + t.colLayout = layouts.NewColumnsLayout([]float32{40, -1, -1, -1, 60, 60, 47, 65, 75, 70, -1}) t.buildHeader() t.hdr.OnColumnVisibilityChanged = t.setColumnVisible t.hdr.OnColumnVisibilityMenuShown = func(pop *widget.PopUp) { @@ -438,11 +443,21 @@ func (t *TrackRow) Update(tr *subsonic.Child, isPlaying bool, rowNum int) { // Update track num if needed // (which can change based on bound *subsonic.Child or tracklist.AutoNumber) if t.trackNum != rowNum { + discNum := -1 + var str string if rowNum < 0 { rowNum = tr.Track + if t.tracklist.ShowDiscNumber { + discNum = tr.DiscNumber + } } t.trackNum = rowNum - t.num.Segments[0].(*widget.TextSegment).Text = strconv.Itoa(rowNum) + if discNum >= 0 { + str = fmt.Sprintf("%d.%02d", discNum, rowNum) + } else { + str = strconv.Itoa(rowNum) + } + t.num.Segments[0].(*widget.TextSegment).Text = str } // Update play count if needed From f95632a43cd3dddfd5803ee057ed4db0ded7fb1e Mon Sep 17 00:00:00 2001 From: Drew Weymouth Date: Sun, 9 Apr 2023 13:35:28 -0700 Subject: [PATCH 2/7] changelog: move multi-disc support from fixed to added --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 868480d..162636b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,12 +6,12 @@ - [#107](https://github.com/dweymouth/supersonic/issues/107) Add playback settings dropdown to choose output audio device - [#119](https://github.com/dweymouth/supersonic/issues/119) Add file path column to tracklist view - [#117](https://github.com/dweymouth/supersonic/issues/117) Add (optional) system tray menu and close to tray support +- [#55](https://github.com/dweymouth/supersonic/issues/55) Show disc number and disc count for multi-disc albums ### Fixes - **todo-commithash** Don't show update available prompt if the found version is the same as the running app version - [#120](https://github.com/dweymouth/supersonic/issues/120),[#87](https://github.com/dweymouth/supersonic/issues/87) Update Mac build process to support OS versions back to High Sierra (thanks @whorfin!) - [#125](https://github.com/dweymouth/supersonic/issues/125) Navigating back twice to an albums page with search result clears search state -- [#55](https://github.com/dweymouth/supersonic/issues/55) Show disc number and disc count for multi-disc albums ## [0.1.0-beta] - 2023-04-01 From 19a45b7361ed764f8953d42f3a74ec422f1fec32 Mon Sep 17 00:00:00 2001 From: Drew Weymouth Date: Mon, 10 Apr 2023 17:38:33 -0700 Subject: [PATCH 3/7] Fix #115: add search to artists, genres, and playlists pages --- CHANGELOG.md | 1 + sharedutil/sharedutil.go | 10 ++++++ ui/browsing/artistsgenrespage.go | 56 +++++++++++++++++++++++------ ui/browsing/playlistspage.go | 60 ++++++++++++++++++++++++++------ 4 files changed, 107 insertions(+), 20 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 162636b..a731971 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ - [#119](https://github.com/dweymouth/supersonic/issues/119) Add file path column to tracklist view - [#117](https://github.com/dweymouth/supersonic/issues/117) Add (optional) system tray menu and close to tray support - [#55](https://github.com/dweymouth/supersonic/issues/55) Show disc number and disc count for multi-disc albums +- [#115](https://github.com/dweymouth/supersonic/issues/115) Add search bar to artist, genres, and playlists pages ### Fixes - **todo-commithash** Don't show update available prompt if the found version is the same as the running app version diff --git a/sharedutil/sharedutil.go b/sharedutil/sharedutil.go index 3077bdb..1dbbee4 100644 --- a/sharedutil/sharedutil.go +++ b/sharedutil/sharedutil.go @@ -25,6 +25,16 @@ func IntSliceContains(slice []int, i int) bool { return false } +func FilterSlice[T any](ss []T, test func(T) bool) []T { + result := make([]T, 0) + for _, s := range ss { + if test(s) { + result = append(result, s) + } + } + return result +} + func FindTrackByID(id string, tracks []*subsonic.Child) *subsonic.Child { for _, tr := range tracks { if id == tr.ID { diff --git a/ui/browsing/artistsgenrespage.go b/ui/browsing/artistsgenrespage.go index f325f04..40add25 100644 --- a/ui/browsing/artistsgenrespage.go +++ b/ui/browsing/artistsgenrespage.go @@ -2,7 +2,9 @@ package browsing import ( "log" + "strings" "supersonic/backend" + "supersonic/sharedutil" "supersonic/ui/controller" "supersonic/ui/layouts" "supersonic/ui/widgets" @@ -10,6 +12,7 @@ import ( "fyne.io/fyne/v2" "fyne.io/fyne/v2/container" + "fyne.io/fyne/v2/layout" "fyne.io/fyne/v2/theme" "fyne.io/fyne/v2/widget" "github.com/dweymouth/go-subsonic/subsonic" @@ -23,12 +26,19 @@ type ArtistsGenresPage struct { isGenresPage bool contr *controller.Controller sm *backend.ServerManager - titleDisp *widget.RichText - container *fyne.Container + model []widgets.ArtistGenreListItemModel list *widgets.ArtistGenreList + + titleDisp *widget.RichText + container *fyne.Container + searcher *widgets.Searcher } func NewArtistsGenresPage(isGenresPage bool, contr *controller.Controller, sm *backend.ServerManager) *ArtistsGenresPage { + return newArtistsGenresPage(isGenresPage, contr, sm, "") +} + +func newArtistsGenresPage(isGenresPage bool, contr *controller.Controller, sm *backend.ServerManager, searchText string) *ArtistsGenresPage { title := "Artists" if isGenresPage { title = "Genres" @@ -51,27 +61,49 @@ func NewArtistsGenresPage(isGenresPage bool, contr *controller.Controller, sm *b a.contr.NavigateTo(controller.ArtistRoute(id)) } } + a.searcher = widgets.NewSearcher() + a.searcher.OnSearched = a.onSearched + a.searcher.Entry.Text = searchText a.buildContainer() - go a.load() + go a.load(searchText != "") return a } // should be called asynchronously -func (a *ArtistsGenresPage) load() { +func (a *ArtistsGenresPage) load(searchOnLoad bool) { if a.isGenresPage { genres, err := a.sm.Server.GetGenres() if err != nil { log.Printf("error loading genres: %v", err.Error()) } - a.list.Items = a.buildGenresListModel(genres) + a.model = a.buildGenresListModel(genres) } else { artists, err := a.sm.Server.GetArtists(nil) if err != nil { log.Printf("error loading artists: %v", err.Error()) } - a.list.Items = a.buildArtistListModel(artists) + a.model = a.buildArtistListModel(artists) } - a.Refresh() + if searchOnLoad { + a.onSearched(a.searcher.Entry.Text) + } else { + a.list.Items = a.model + a.list.Refresh() + } +} + +func (a *ArtistsGenresPage) onSearched(query string) { + // since the artists and genres lists are returned in full non-paginated, we will do our own + // simple search based on the artist/genre name, rather than calling a server API + if query == "" { + a.list.Items = a.model + } else { + result := sharedutil.FilterSlice(a.model, func(x widgets.ArtistGenreListItemModel) bool { + return strings.Contains(strings.ToLower(x.Name), strings.ToLower(query)) + }) + a.list.Items = result + } + a.list.Refresh() } func (a *ArtistsGenresPage) Route() controller.Route { @@ -82,7 +114,7 @@ func (a *ArtistsGenresPage) Route() controller.Route { } func (a *ArtistsGenresPage) Reload() { - go a.load() + go a.load(false) } func (a *ArtistsGenresPage) Save() SavedPage { @@ -90,6 +122,7 @@ func (a *ArtistsGenresPage) Save() SavedPage { isGenresPage: a.isGenresPage, contr: a.contr, sm: a.sm, + searchText: a.searcher.Entry.Text, } } @@ -97,10 +130,11 @@ type savedArtistsGenresPage struct { isGenresPage bool contr *controller.Controller sm *backend.ServerManager + searchText string } func (s *savedArtistsGenresPage) Restore() Page { - return NewArtistsGenresPage(s.isGenresPage, s.contr, s.sm) + return newArtistsGenresPage(s.isGenresPage, s.contr, s.sm, s.searchText) } func (a *ArtistsGenresPage) buildArtistListModel(artists *subsonic.ArtistsID3) []widgets.ArtistGenreListItemModel { @@ -133,9 +167,11 @@ func (a *ArtistsGenresPage) buildGenresListModel(genres []*subsonic.Genre) []wid } func (a *ArtistsGenresPage) buildContainer() { + searchVbox := container.NewVBox(layout.NewSpacer(), a.searcher.Entry, layout.NewSpacer()) a.container = container.New(&layouts.MaxPadLayout{PadLeft: 15, PadRight: 15, PadTop: 5, PadBottom: 15}, container.NewBorder( - container.New(&layouts.MaxPadLayout{PadLeft: -5}, a.titleDisp), + container.New(&layouts.MaxPadLayout{PadLeft: -5}, + container.NewHBox(a.titleDisp, layout.NewSpacer(), searchVbox)), nil, nil, nil, a.list)) } diff --git a/ui/browsing/playlistspage.go b/ui/browsing/playlistspage.go index 7c30f10..9128ca1 100644 --- a/ui/browsing/playlistspage.go +++ b/ui/browsing/playlistspage.go @@ -3,13 +3,16 @@ package browsing import ( "log" "strconv" + "strings" "supersonic/backend" + "supersonic/sharedutil" "supersonic/ui/controller" "supersonic/ui/layouts" "supersonic/ui/widgets" "fyne.io/fyne/v2" "fyne.io/fyne/v2/container" + "fyne.io/fyne/v2/layout" "fyne.io/fyne/v2/theme" "fyne.io/fyne/v2/widget" "github.com/dweymouth/go-subsonic/subsonic" @@ -20,12 +23,19 @@ type PlaylistsPage struct { contr *controller.Controller sm *backend.ServerManager + playlists []*subsonic.Playlist + + searcher *widgets.Searcher titleDisp *widget.RichText container *fyne.Container list *PlaylistList } func NewPlaylistsPage(contr *controller.Controller, sm *backend.ServerManager) *PlaylistsPage { + return newPlaylistsPage(contr, sm, "") +} + +func newPlaylistsPage(contr *controller.Controller, sm *backend.ServerManager, searchText string) *PlaylistsPage { a := &PlaylistsPage{ sm: sm, contr: contr, @@ -37,17 +47,42 @@ func NewPlaylistsPage(contr *controller.Controller, sm *backend.ServerManager) * a.list.OnNavTo = func(id string) { a.contr.NavigateTo(controller.PlaylistRoute(id)) } + a.searcher = widgets.NewSearcher() + a.searcher.OnSearched = a.onSearched + a.searcher.Entry.Text = searchText a.buildContainer() - go a.loadAsync() + go a.load(searchText != "") return a } -func (a *PlaylistsPage) loadAsync() { +func (a *PlaylistsPage) load(searchOnLoad bool) { playlists, err := a.sm.Server.GetPlaylists(nil) if err != nil { log.Printf("error loading playlists: %v", err.Error()) } - a.list.Playlists = playlists + a.playlists = playlists + if searchOnLoad { + a.onSearched(a.searcher.Entry.Text) + } else { + a.list.Playlists = playlists + a.list.Refresh() + } +} + +func (a *PlaylistsPage) onSearched(query string) { + // since the playlist list is returned in full non-paginated, we will do our own + // simple search based on the name, description, and owner, rather than calling a server API + if query == "" { + a.list.Playlists = a.playlists + } else { + result := sharedutil.FilterSlice(a.playlists, func(p *subsonic.Playlist) bool { + qLower := strings.ToLower(query) + return strings.Contains(strings.ToLower(p.Name), qLower) || + strings.Contains(strings.ToLower(p.Comment), qLower) || + strings.Contains(strings.ToLower(p.Owner), qLower) + }) + a.list.Playlists = result + } a.list.Refresh() } @@ -56,28 +91,33 @@ func (a *PlaylistsPage) Route() controller.Route { } func (a *PlaylistsPage) Reload() { - go a.loadAsync() + go a.load(false) } func (a *PlaylistsPage) Save() SavedPage { return &savedPlaylistsPage{ - contr: a.contr, - sm: a.sm, + contr: a.contr, + sm: a.sm, + searchText: a.searcher.Entry.Text, } } type savedPlaylistsPage struct { - contr *controller.Controller - sm *backend.ServerManager + contr *controller.Controller + sm *backend.ServerManager + searchText string } func (s *savedPlaylistsPage) Restore() Page { - return NewPlaylistsPage(s.contr, s.sm) + return newPlaylistsPage(s.contr, s.sm, s.searchText) } func (a *PlaylistsPage) buildContainer() { + searchVbox := container.NewVBox(layout.NewSpacer(), a.searcher.Entry, layout.NewSpacer()) a.container = container.New(&layouts.MaxPadLayout{PadLeft: 15, PadRight: 15, PadTop: 5, PadBottom: 15}, - container.NewBorder(a.titleDisp, nil, nil, nil, a.list)) + container.NewBorder( + container.NewHBox(a.titleDisp, layout.NewSpacer(), searchVbox), + nil, nil, nil, a.list)) } func (a *PlaylistsPage) CreateRenderer() fyne.WidgetRenderer { From 84b5963407e2d0f888145a0985a4f0111dae7223 Mon Sep 17 00:00:00 2001 From: Drew Weymouth Date: Mon, 10 Apr 2023 19:14:36 -0700 Subject: [PATCH 4/7] add comments to some custom layouts --- ui/layouts/columnslayout.go | 8 ++++++++ ui/layouts/leftmiddlerightlayout.go | 3 +++ 2 files changed, 11 insertions(+) diff --git a/ui/layouts/columnslayout.go b/ui/layouts/columnslayout.go index 614915e..5276483 100644 --- a/ui/layouts/columnslayout.go +++ b/ui/layouts/columnslayout.go @@ -2,6 +2,14 @@ package layouts import "fyne.io/fyne/v2" +// ColumnsLayout lays out a number of items into columns. +// There are two types of columns: fixed-width and variable width. +// A fixed width column is any with a non-negative width and will +// be laid out with that width. A variable width column is created +// by using any negative number as its width. Variable width columns +// are all laid out with the same width, splitting the "leftover" space +// equally between themselves after accounting for the fixed-width columns. +// Hidden items are not shown and take up 0 space. type ColumnsLayout struct { ColumnWidths []float32 } diff --git a/ui/layouts/leftmiddlerightlayout.go b/ui/layouts/leftmiddlerightlayout.go index f18da27..2aeccda 100644 --- a/ui/layouts/leftmiddlerightlayout.go +++ b/ui/layouts/leftmiddlerightlayout.go @@ -6,6 +6,9 @@ import ( "fyne.io/fyne/v2/theme" ) +// Lays out up to 3 objects such that the middle object, Objects[1], +// is centered in the available space and takes up a fixed width. +// The left, and right (if non-nil), split the leftover space equally. type LeftMiddleRightLayout struct { middleWidth float32 hbox fyne.Layout From 3c7fd445c7769fc729d8a2fc860bd6eb1ab31ed2 Mon Sep 17 00:00:00 2001 From: Drew Weymouth Date: Tue, 11 Apr 2023 09:33:30 -0700 Subject: [PATCH 5/7] README updates --- README.md | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 1658596..a75cccd 100644 --- a/README.md +++ b/README.md @@ -5,6 +5,8 @@ A lightweight cross-platform desktop client for Subsonic music servers (Navidrom ## Screenshots +Screenshots of Supersonic running against the Navidrome [demo server](https://www.navidrome.org/demo/) + @@ -19,6 +21,7 @@ A lightweight cross-platform desktop client for Subsonic music servers (Navidrom * [x] Browse by albums, artists, genres, playlists * [x] Album and playlist views with tracklist and cover image * [x] Artist view with biography, image, similar artists, and discography +* [x] Play "artist radio" (mix of songs from given artist and similar artists, depends on your server's support) * [x] Create, play, and update playlists * [x] Configure visible tracklist columns * [x] Set/unset favorite and browse by favorite albums, artists, and songs @@ -31,12 +34,12 @@ A lightweight cross-platform desktop client for Subsonic music servers (Navidrom * [ ] Cast to uPnP/DLNA devices (likely planned) * [ ] Built-in multi-band equalizer (eventully planned) * [ ] Offline mode (eventually planned) -* [ ] iOS/Android support (eventually planned) * [ ] Lyrics support (eventually planned) +* [ ] iOS/Android support (maybe eventually planned) ## Installation -Download the latest [release](https://github.com/dweymouth/supersonic/releases) for your operating system. If you prefer to build from source, **or there is no release build currently offered for your OS/architecture (Apple silicon Macs)**, then see the build instructions for your platform below. +If you are running Windows, Mac OS (Intel), or a Debian-based Linux distro, download the latest [release](https://github.com/dweymouth/supersonic/releases) for your operating system. If you prefer to build from source, **or there is no release build currently offered for your OS/architecture (Apple silicon Macs)**, then see the build instructions for your platform below. **If you are on Linux** you must have libmpv installed on your system. On apt-based systems, run `sudo apt install libmpv-dev` if it is not already installed. The Windows and Mac release builds bundle the mpv dependencies. From 37e78f0c037e3b15835417c4e5b1441bb3151a10 Mon Sep 17 00:00:00 2001 From: Drew Weymouth Date: Tue, 11 Apr 2023 17:04:27 -0700 Subject: [PATCH 6/7] allow Ctrl+F to focus the search bar on artists, genres, playlists pages --- ui/browsing/artistsgenrespage.go | 6 ++++++ ui/browsing/playlistspage.go | 6 ++++++ 2 files changed, 12 insertions(+) diff --git a/ui/browsing/artistsgenrespage.go b/ui/browsing/artistsgenrespage.go index 40add25..4aa8b09 100644 --- a/ui/browsing/artistsgenrespage.go +++ b/ui/browsing/artistsgenrespage.go @@ -106,6 +106,12 @@ func (a *ArtistsGenresPage) onSearched(query string) { a.list.Refresh() } +var _ Searchable = (*ArtistsGenresPage)(nil) + +func (a *ArtistsGenresPage) SearchWidget() fyne.Focusable { + return a.searcher.Entry +} + func (a *ArtistsGenresPage) Route() controller.Route { if a.isGenresPage { return controller.GenresRoute() diff --git a/ui/browsing/playlistspage.go b/ui/browsing/playlistspage.go index 9128ca1..0a7b189 100644 --- a/ui/browsing/playlistspage.go +++ b/ui/browsing/playlistspage.go @@ -86,6 +86,12 @@ func (a *PlaylistsPage) onSearched(query string) { a.list.Refresh() } +var _ Searchable = (*PlaylistsPage)(nil) + +func (a *PlaylistsPage) SearchWidget() fyne.Focusable { + return a.searcher.Entry +} + func (a *PlaylistsPage) Route() controller.Route { return controller.PlaylistsRoute() } From d6c249aa8c7213ece1f710da3f2c8d8b94f81ab8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Antoine=20Beaupr=C3=A9?= Date: Thu, 13 Apr 2023 21:48:06 -0400 Subject: [PATCH 7/7] add debugging statements when playing This was requested in issue #112. --- player/player.go | 3 +++ 1 file changed, 3 insertions(+) diff --git a/player/player.go b/player/player.go index 36ecfde..56f7128 100644 --- a/player/player.go +++ b/player/player.go @@ -4,6 +4,7 @@ import ( "context" "errors" "fmt" + "log" "strconv" "github.com/dweymouth/go-mpv" @@ -156,6 +157,7 @@ func (p *Player) Init(maxCacheMB int) error { // Note that the Player API does not provide methods to read // the play queue. Clients are expected to maintain their own play queue model. func (p *Player) AppendFile(url string) error { + log.Printf("Adding playback URL: %s", url) if !p.initialized { return ErrUnitialized } @@ -164,6 +166,7 @@ func (p *Player) AppendFile(url string) error { // Plays the specified file, clearing the previous play queue, if any. func (p *Player) PlayFile(url string) error { + log.Printf("Adding playback URL: %s", url) if !p.initialized { return ErrUnitialized }