From f67a0c5fc1d72f61fd0d8f862d82b84e9ef9cfbc Mon Sep 17 00:00:00 2001 From: Drew Weymouth Date: Fri, 15 Mar 2024 12:09:05 -0700 Subject: [PATCH 01/25] in progress MediaItem refactor - does not build --- backend/mediaprovider/model.go | 26 +++++++++ backend/mpmedia_mac.go | 14 ++--- backend/mpris.go | 8 ++- backend/playbackengine.go | 102 +++++++++++++++++++++++++-------- backend/playbackmanager.go | 29 +++++++++- backend/savedplayqueue.go | 9 ++- sharedutil/sharedutil.go | 17 ++++++ ui/browsing/browsingpane.go | 4 +- ui/browsing/favoritespage.go | 2 +- ui/browsing/nowplayingpage.go | 4 +- 10 files changed, 171 insertions(+), 44 deletions(-) diff --git a/backend/mediaprovider/model.go b/backend/mediaprovider/model.go index c865bce..fcb1096 100644 --- a/backend/mediaprovider/model.go +++ b/backend/mediaprovider/model.go @@ -139,6 +139,32 @@ type SavedPlayQueue struct { TimePos int // seconds } +type RadioStation struct { + Name string + ID string + HomePageURL string + StreamURL string +} + +type MediaItem interface { + MediaItemName() string + MediaItemID() string + Copy() MediaItem +} + +func (t Track) MediaItemName() string { return t.Name } +func (t Track) MediaItemID() string { return t.ID } +func (t *Track) Copy() MediaItem { + new := *t + return &new +} + +func (r RadioStation) MediaItemName() string { return r.Name } +func (r RadioStation) MediaItemID() string { return r.ID } +func (r *RadioStation) Copy() MediaItem { + return r // no need to copy since RadioStations are immutable +} + type ContentType int const ( diff --git a/backend/mpmedia_mac.go b/backend/mpmedia_mac.go index 9e59056..b3343ce 100644 --- a/backend/mpmedia_mac.go +++ b/backend/mpmedia_mac.go @@ -69,7 +69,7 @@ func InitMPMediaHandler(playbackManager *PlaybackManager, artURLLookup func(trac mpMediaEventRecipient = mp C.register_os_remote_commands() - mp.playbackManager.OnSongChange(func(track, _ *mediaprovider.Track) { + mp.playbackManager.OnSongChange(func(track *NowPlayingMetadata, _ *mediaprovider.Track) { // Asynchronously because artwork fetching can take time go mp.updateMetadata(track) }) @@ -95,17 +95,17 @@ func InitMPMediaHandler(playbackManager *PlaybackManager, artURLLookup func(trac return nil } -func (mp *MPMediaHandler) updateMetadata(track *mediaprovider.Track) { +func (mp *MPMediaHandler) updateMetadata(meta *NowPlayingMetadata) { var title, artist, artURL string var duration int - if track != nil && track.ID != "" { + if meta != nil && meta.ID != "" { + title = meta.Title var err error - if artURL, err = mp.artURLLookup(track.CoverArtID); err != nil { + if artURL, err = mp.artURLLookup(meta.CoverArtID); err != nil { log.Printf("error fetching art url: %s", err.Error()) } - title = track.Name - artist = strings.Join(track.ArtistNames, ", ") - duration = track.Duration + artist = strings.Join(meta.Artists, ", ") + duration = meta.Duration } cTitle := C.CString(title) diff --git a/backend/mpris.go b/backend/mpris.go index 58a9260..045a2cd 100644 --- a/backend/mpris.go +++ b/backend/mpris.go @@ -58,7 +58,7 @@ func NewMPRISHandler(playerName string, pm *PlaybackManager) *MPRISHandler { m.evt.Player.OnSeek(pos) } }) - pm.OnSongChange(func(tr, _ *mediaprovider.Track) { + pm.OnSongChange(func(tr *NowPlayingMetadata, _ *mediaprovider.Track) { if tr == nil { m.curTrackPath = "" } else { @@ -252,8 +252,10 @@ func (m *MPRISHandler) Metadata() (types.Metadata, error) { } status := m.pm.PlayerStatus() var tr mediaprovider.Track - if np := m.pm.NowPlaying(); np != nil && status.State != player.Stopped { - tr = *np + if np := m.pm.NowPlayingMediaItem(); np != nil && status.State != player.Stopped { + if track, ok := np.(*mediaprovider.Track); ok { + tr = *track + } } var artURL string if tr.ID != "" && m.ArtURLLookup != nil { diff --git a/backend/playbackengine.go b/backend/playbackengine.go index 3a089d4..0d8f05d 100644 --- a/backend/playbackengine.go +++ b/backend/playbackengine.go @@ -48,7 +48,7 @@ type playbackEngine struct { latestTrackPosition float64 // cleared by checkScrobble callbacksDisabled bool - playQueue []*mediaprovider.Track + playQueue []mediaprovider.MediaItem nowPlayingIdx int wasStopped bool // true iff player was stopped before handleOnTrackChange invocation loopMode LoopMode @@ -60,7 +60,7 @@ type playbackEngine struct { replayGainCfg ReplayGainConfig // registered callbacks - onSongChange []func(nowPlaying, justScrobbledIfAny *mediaprovider.Track) + onSongChange []func(nowPlaying *NowPlayingMetadata, justScrobbledIfAny *mediaprovider.Track) onPlayTimeUpdate []func(float64, float64, bool) onLoopModeChange []func(LoopMode) onVolumeChange []func(int) @@ -123,7 +123,33 @@ func (p *playbackEngine) PlayTrackAt(idx int) error { } // Gets the curently playing song, if any. -func (p *playbackEngine) NowPlaying() *mediaprovider.Track { +func (p *playbackEngine) NowPlaying() *NowPlayingMetadata { + if p.nowPlayingIdx < 0 || len(p.playQueue) == 0 || p.player.GetStatus().State == player.Stopped { + return nil + } + item := p.playQueue[p.nowPlayingIdx] + if tr, ok := item.(*mediaprovider.Track); ok { + return &NowPlayingMetadata{ + Type: MediaTypeTrack, + ID: tr.ID, + Title: tr.Name, + Artists: tr.ArtistNames, + ArtistIDs: tr.ArtistIDs, + Album: tr.Album, + AlbumID: tr.AlbumID, + Duration: float64(tr.Duration), + } + } + return &NowPlayingMetadata{ + Type: MediaTypeRadioStation, + ID: item.MediaItemID(), + Title: item.MediaItemName(), + Duration: 0.0, + } +} + +// Gets the curently playing song, if any. +func (p *playbackEngine) NowPlayingMediaItem() mediaprovider.MediaItem { if p.nowPlayingIdx < 0 || len(p.playQueue) == 0 || p.player.GetStatus().State == player.Stopped { return nil } @@ -216,7 +242,7 @@ func (p *playbackEngine) LoadTracks(tracks []*mediaprovider.Track, insertQueueMo } needToSetNext := len(tracks) > 0 && (insertQueueMode == InsertNext || (insertQueueMode == Append && p.nowPlayingIdx == len(p.playQueue)-1)) - newTracks := p.deepCopyTrackSlice(tracks) + newTracks := p.copyTrackSliceToMediaItemSlice(tracks) if shuffle { rand.Shuffle(len(newTracks), func(i, j int) { newTracks[i], newTracks[j] = newTracks[j], newTracks[i] }) } @@ -247,23 +273,27 @@ func (p *playbackEngine) StopAndClearPlayQueue() { } } -func (p *playbackEngine) GetPlayQueue() []*mediaprovider.Track { +func (p *playbackEngine) GetPlayQueue() []mediaprovider.MediaItem { return p.deepCopyTrackSlice(p.playQueue) } // Any time the user changes the favorite status of a track elsewhere in the app, // this should be called to ensure the in-memory track model is updated. func (p *playbackEngine) OnTrackFavoriteStatusChanged(id string, fav bool) { - if tr := sharedutil.FindTrackByID(id, p.playQueue); tr != nil { - tr.Favorite = fav + if item := sharedutil.FindMediaItemByID(id, p.playQueue); item != nil { + if tr, ok := item.(*mediaprovider.Track); ok { + tr.Favorite = fav + } } } // Any time the user changes the rating of a track elsewhere in the app, // this should be called to ensure the in-memory track model is updated. func (p *playbackEngine) OnTrackRatingChanged(id string, rating int) { - if tr := sharedutil.FindTrackByID(id, p.playQueue); tr != nil { - tr.Rating = rating + if item := sharedutil.FindMediaItemByID(id, p.playQueue); item != nil { + if tr, ok := item.(*mediaprovider.Track); ok { + tr.Rating = rating + } } } @@ -271,12 +301,12 @@ func (p *playbackEngine) OnTrackRatingChanged(id string, rating int) { // Does not stop playback if the currently playing track is in the new queue, // but updates the now playing index to point to the first instance of the track in the new queue. func (p *playbackEngine) UpdatePlayQueue(tracks []*mediaprovider.Track) error { - newQueue := p.deepCopyTrackSlice(tracks) + newQueue := p.copyTrackSliceToMediaItemSlice(tracks) newNowPlayingIdx := -1 if p.nowPlayingIdx >= 0 { - nowPlayingID := p.playQueue[p.nowPlayingIdx].ID + nowPlayingID := p.playQueue[p.nowPlayingIdx].MediaItemID() for i, tr := range newQueue { - if tr.ID == nowPlayingID { + if tr.MediaItemID() == nowPlayingID { newNowPlayingIdx = i break } @@ -298,14 +328,14 @@ func (p *playbackEngine) UpdatePlayQueue(tracks []*mediaprovider.Track) error { } func (p *playbackEngine) RemoveTracksFromQueue(trackIDs []string) { - newQueue := make([]*mediaprovider.Track, 0, len(p.playQueue)-len(trackIDs)) + newQueue := make([]mediaprovider.MediaItem, 0, len(p.playQueue)-len(trackIDs)) idSet := sharedutil.ToSet(trackIDs) isPlayingTrackRemoved := false isNextPlayingTrackremoved := false nowPlaying := p.NowPlayingIndex() newNowPlaying := nowPlaying for i, tr := range p.playQueue { - if _, ok := idSet[tr.ID]; ok { + if _, ok := idSet[tr.MediaItemID()]; ok { if i < nowPlaying { // if removing a track earlier than the currently playing one (if any), // decrement new now playing index by one to account for new position in queue @@ -396,7 +426,10 @@ func (p *playbackEngine) handleOnTrackChange() { } } p.wasStopped = false - p.curTrackTime = float64(p.playQueue[p.nowPlayingIdx].Duration) + p.curTrackTime = 0 + if tr, ok := p.playQueue[p.nowPlayingIdx].(*mediaprovider.Track); ok { + p.curTrackTime = float64(tr.Duration) + } p.sendNowPlayingScrobble() // Must come before invokeOnChangeCallbacks b/c track may immediately be scrobbled p.invokeOnSongChangeCallbacks() p.doUpdateTimePos(false) @@ -460,7 +493,12 @@ func (p *playbackEngine) setTrack(idx int, next bool) error { url := "" if idx >= 0 { var err error - url, err = p.sm.Server.GetStreamURL(p.playQueue[idx].ID, p.transcodeCfg.ForceRawFile) + item := p.playQueue[idx] + if tr, ok := item.(*mediaprovider.Track); ok { + url, err = p.sm.Server.GetStreamURL(tr.ID, p.transcodeCfg.ForceRawFile) + } else { + url = item.(*mediaprovider.RadioStation).StreamURL + } if err != nil { return err } @@ -472,7 +510,10 @@ func (p *playbackEngine) setTrack(idx int, next bool) error { } else if trP, ok := p.player.(player.TrackPlayer); ok { var track *mediaprovider.Track if idx >= 0 { - track = p.playQueue[idx] + track, ok = p.playQueue[idx].(*mediaprovider.Track) + if !ok { + return errors.New("cannot play non-Track media item with TrackPlayer") + } } if next { return trP.SetNextTrack(track) @@ -491,6 +532,11 @@ func (p *playbackEngine) checkScrobble() { if !p.scrobbleCfg.Enabled || len(p.playQueue) == 0 || p.nowPlayingIdx < 0 { return } + track, ok := p.playQueue[p.nowPlayingIdx].(*mediaprovider.Track) + if !ok { + return // radio stations are not scrobbled + } + playDur := p.playTimeStopwatch.Elapsed() if playDur.Seconds() < 0.1 || p.curTrackTime < 0.1 { return @@ -499,7 +545,6 @@ func (p *playbackEngine) checkScrobble() { timeThresholdMet := p.scrobbleCfg.ThresholdTimeSeconds >= 0 && playDur.Seconds() >= float64(p.scrobbleCfg.ThresholdTimeSeconds) - track := p.playQueue[p.nowPlayingIdx] var submission bool server := p.sm.Server if server.ClientDecidesScrobble() && (timeThresholdMet || pcnt >= float64(p.scrobbleCfg.ThresholdPercent)) { @@ -516,7 +561,11 @@ func (p *playbackEngine) sendNowPlayingScrobble() { if !p.scrobbleCfg.Enabled || len(p.playQueue) == 0 || p.nowPlayingIdx < 0 { return } - track := p.playQueue[p.nowPlayingIdx] + track, ok := p.playQueue[p.nowPlayingIdx].(*mediaprovider.Track) + if !ok { + return // radio stations are not scrobbled + } + server := p.sm.Server if !server.ClientDecidesScrobble() { // server will count track as scrobbled as soon as it starts playing @@ -528,11 +577,18 @@ func (p *playbackEngine) sendNowPlayingScrobble() { // creates a deep copy of the track info so that we can maintain our own state // (play count increases, favorite, and rating) without messing up other views' track models -func (p *playbackEngine) deepCopyTrackSlice(tracks []*mediaprovider.Track) []*mediaprovider.Track { - newTracks := make([]*mediaprovider.Track, len(tracks)) +func (p *playbackEngine) deepCopyTrackSlice(tracks []mediaprovider.MediaItem) []mediaprovider.MediaItem { + newTracks := make([]mediaprovider.MediaItem, len(tracks)) for i, tr := range tracks { - copy := *tr - newTracks[i] = © + newTracks[i] = tr.Copy() + } + return newTracks +} + +func (p *playbackEngine) copyTrackSliceToMediaItemSlice(tracks []*mediaprovider.Track) []mediaprovider.MediaItem { + newTracks := make([]mediaprovider.MediaItem, len(tracks)) + for i, tr := range tracks { + newTracks[i] = tr.Copy() } return newTracks } diff --git a/backend/playbackmanager.go b/backend/playbackmanager.go index 2fdab18..9ba9eac 100644 --- a/backend/playbackmanager.go +++ b/backend/playbackmanager.go @@ -15,6 +15,25 @@ type PlaybackManager struct { engine *playbackEngine } +type MediaType int + +const ( + MediaTypeTrack MediaType = iota + MediaTypeRadioStation +) + +type NowPlayingMetadata struct { + Type MediaType + ID string + Title string + Artists []string + ArtistIDs []string + Album string + AlbumID string + CoverArtID string + Duration int +} + func NewPlaybackManager( ctx context.Context, s *ServerManager, @@ -46,16 +65,20 @@ func (p *PlaybackManager) DisableCallbacks() { } // Gets the curently playing song, if any. -func (p *PlaybackManager) NowPlaying() *mediaprovider.Track { +func (p *PlaybackManager) NowPlaying() *NowPlayingMetadata { return p.engine.NowPlaying() } +func (p *PlaybackManager) NowPlayingMediaItem() mediaprovider.MediaItem { + return p.engine.NowPlayingMediaItem() +} + func (p *PlaybackManager) NowPlayingIndex() int { return p.engine.NowPlayingIndex() } // Sets a callback that is notified whenever a new song begins playing. -func (p *PlaybackManager) OnSongChange(cb func(nowPlaying *mediaprovider.Track, justScrobbledIfAny *mediaprovider.Track)) { +func (p *PlaybackManager) OnSongChange(cb func(nowPlaying *NowPlayingMetadata, justScrobbledIfAny *mediaprovider.Track)) { p.engine.onSongChange = append(p.engine.onSongChange, cb) } @@ -194,7 +217,7 @@ func (p *PlaybackManager) fetchAndPlayTracks(fetchFn func() ([]*mediaprovider.Tr } } -func (p *PlaybackManager) GetPlayQueue() []*mediaprovider.Track { +func (p *PlaybackManager) GetPlayQueue() []mediaprovider.MediaItem { return p.engine.GetPlayQueue() } diff --git a/backend/savedplayqueue.go b/backend/savedplayqueue.go index a0baf41..c66e1a7 100644 --- a/backend/savedplayqueue.go +++ b/backend/savedplayqueue.go @@ -29,9 +29,12 @@ func SavePlayQueue(serverID string, pm *PlaybackManager, filepath string, server stats := pm.PlayerStatus() trackIdx := pm.NowPlayingIndex() - trackIDs := make([]string, len(queue)) - for i, tr := range queue { - trackIDs[i] = tr.ID + trackIDs := make([]string, 0, len(queue)) + for _, item := range queue { + if _, ok := item.(*mediaprovider.Track); ok { + // dont' save radio stations in play queue + trackIDs = append(trackIDs, item.MediaItemID()) + } } saved := serializedSavedPlayQueue{ diff --git a/sharedutil/sharedutil.go b/sharedutil/sharedutil.go index 0421128..ef72041 100644 --- a/sharedutil/sharedutil.go +++ b/sharedutil/sharedutil.go @@ -4,6 +4,7 @@ import ( "math" "slices" + "github.com/dweymouth/supersonic/backend" "github.com/dweymouth/supersonic/backend/mediaprovider" ) @@ -74,6 +75,15 @@ func FindTrackByID(id string, tracks []*mediaprovider.Track) *mediaprovider.Trac return nil } +func FindMediaItemByID(id string, items []mediaprovider.MediaItem) mediaprovider.MediaItem { + for _, tr := range items { + if id == tr.MediaItemID() { + return tr + } + } + return nil +} + func TrackIDOrEmptyStr(track *mediaprovider.Track) string { if track == nil { return "" @@ -81,6 +91,13 @@ func TrackIDOrEmptyStr(track *mediaprovider.Track) string { return track.ID } +func TrackIDOremptyStringFromMeta(meta *backend.NowPlayingMetadata) string { + if meta == nil || meta.Type != backend.MediaTypeTrack { + return "" + } + return meta.ID +} + func AlbumIDOrEmptyStr(track *mediaprovider.Track) string { if track == nil { return "" diff --git a/ui/browsing/browsingpane.go b/ui/browsing/browsingpane.go index ca1a802..38b0550 100644 --- a/ui/browsing/browsingpane.go +++ b/ui/browsing/browsingpane.go @@ -42,7 +42,7 @@ type Scrollable interface { } type CanShowNowPlaying interface { - OnSongChange(song, lastScrobbledIfAny *mediaprovider.Track) + OnSongChange(meta *backend.NowPlayingMetadata, lastScrobbledIfAny *mediaprovider.Track) } type CanShowPlayTime interface { @@ -213,7 +213,7 @@ func (b *BrowsingPane) doSetPage(p Page) bool { return true } -func (b *BrowsingPane) onSongChange(song, lastScrobbledIfAny *mediaprovider.Track) { +func (b *BrowsingPane) onSongChange(song *backend.NowPlayingMetadata, lastScrobbledIfAny *mediaprovider.Track) { if b.curPage == nil { return } diff --git a/ui/browsing/favoritespage.go b/ui/browsing/favoritespage.go index 48c60d8..a841e4e 100644 --- a/ui/browsing/favoritespage.go +++ b/ui/browsing/favoritespage.go @@ -269,7 +269,7 @@ func (a *FavoritesPage) OnSearched(query string) { var _ CanShowNowPlaying = (*FavoritesPage)(nil) -func (a *FavoritesPage) OnSongChange(song, lastScrobbledIfAny *mediaprovider.Track) { +func (a *FavoritesPage) OnSongChange(meta *backend.NowPlayingMetadata, lastScrobbledIfAny *mediaprovider.Track) { a.nowPlayingID = sharedutil.TrackIDOrEmptyStr(song) if tracklist := a.tracklistOrNil(); tracklist != nil { tracklist.SetNowPlaying(a.nowPlayingID) diff --git a/ui/browsing/nowplayingpage.go b/ui/browsing/nowplayingpage.go index d8791d1..9e8b113 100644 --- a/ui/browsing/nowplayingpage.go +++ b/ui/browsing/nowplayingpage.go @@ -245,12 +245,12 @@ func (a *NowPlayingPage) Route() controller.Route { var _ CanShowNowPlaying = (*NowPlayingPage)(nil) -func (a *NowPlayingPage) OnSongChange(song, _ *mediaprovider.Track) { +func (a *NowPlayingPage) OnSongChange(song *backend.NowPlayingMetadata, lastScrobbledIfAny *mediaprovider.Track) { a.nowPlaying = song if a.imageLoadCancel != nil { a.imageLoadCancel() } - a.nowPlayingID = sharedutil.TrackIDOrEmptyStr(song) + a.nowPlayingID = sharedutil.TrackIDOremptyStringFromMeta(song) a.queueList.SetNowPlaying(a.nowPlayingID) a.relatedList.SetNowPlaying(a.nowPlayingID) From 0a902aef9f0165cde30a5ff8bd2c90f8dc700734 Mon Sep 17 00:00:00 2001 From: Drew Weymouth Date: Fri, 31 May 2024 16:37:14 -0700 Subject: [PATCH 02/25] refactoring continued - compiles now --- .../jellyfin/jellyfinmediaprovider.go | 2 +- backend/mediaprovider/model.go | 49 ++++++++++++++++--- .../subsonic/subsonicmediaprovider.go | 4 +- backend/mpmedia_mac.go | 12 +++-- backend/mpris.go | 8 +-- backend/playbackengine.go | 38 +++----------- backend/playbackmanager.go | 29 ++--------- backend/savedplayqueue.go | 2 +- sharedutil/sharedutil.go | 16 ++---- ui/bottompanel.go | 27 ++++++---- ui/browsing/albumpage.go | 4 +- ui/browsing/artistpage.go | 6 +-- ui/browsing/browsingpane.go | 4 +- ui/browsing/favoritespage.go | 6 +-- ui/browsing/nowplayingpage.go | 24 ++++----- ui/browsing/playlistpage.go | 4 +- ui/browsing/trackspage.go | 4 +- ui/controller/controller.go | 6 +-- ui/mainwindow.go | 14 ++++-- ui/widgets/largenowplayingcard.go | 19 ++++--- ui/widgets/nowplayingcard.go | 18 ++++--- ui/widgets/playqueuelist.go | 2 +- ui/widgets/tracklist.go | 2 +- ui/widgets/tracklistrow.go | 2 +- 24 files changed, 154 insertions(+), 148 deletions(-) diff --git a/backend/mediaprovider/jellyfin/jellyfinmediaprovider.go b/backend/mediaprovider/jellyfin/jellyfinmediaprovider.go index 9386052..a303e69 100644 --- a/backend/mediaprovider/jellyfin/jellyfinmediaprovider.go +++ b/backend/mediaprovider/jellyfin/jellyfinmediaprovider.go @@ -395,7 +395,7 @@ func toTrack(ch *jellyfin.Song) *mediaprovider.Track { ID: ch.Id, CoverArtID: coverArtID, ParentID: ch.AlbumID, - Name: ch.Name, + Title: ch.Name, Duration: int(ch.RunTimeTicks / runTimeTicksPerSecond), TrackNumber: ch.IndexNumber, DiscNumber: ch.DiscNumber, diff --git a/backend/mediaprovider/model.go b/backend/mediaprovider/model.go index fcb1096..9e7c896 100644 --- a/backend/mediaprovider/model.go +++ b/backend/mediaprovider/model.go @@ -86,7 +86,7 @@ type Track struct { ID string CoverArtID string ParentID string - Name string + Title string Duration int TrackNumber int DiscNumber int @@ -146,21 +146,56 @@ type RadioStation struct { StreamURL string } +type MediaItemType int + +const ( + MediaItemTypeTrack MediaItemType = iota + MediaItemTypeRadioStation +) + +type MediaItemMetadata struct { + Type MediaItemType + ID string + Name string + Artists []string + ArtistIDs []string + Album string + AlbumID string + CoverArtID string + Duration int +} + type MediaItem interface { - MediaItemName() string - MediaItemID() string + Metadata() MediaItemMetadata Copy() MediaItem } -func (t Track) MediaItemName() string { return t.Name } -func (t Track) MediaItemID() string { return t.ID } +func (t Track) Metadata() MediaItemMetadata { + return MediaItemMetadata{ + Type: MediaItemTypeTrack, + ID: t.ID, + Name: t.Title, + Artists: t.ArtistNames, + ArtistIDs: t.ArtistIDs, + Album: t.Album, + AlbumID: t.AlbumID, + CoverArtID: t.CoverArtID, + Duration: t.Duration, + } +} func (t *Track) Copy() MediaItem { new := *t return &new } -func (r RadioStation) MediaItemName() string { return r.Name } -func (r RadioStation) MediaItemID() string { return r.ID } +func (r RadioStation) Metadata() MediaItemMetadata { + return MediaItemMetadata{ + Type: MediaItemTypeRadioStation, + ID: r.ID, + Name: r.Name, + } +} + func (r *RadioStation) Copy() MediaItem { return r // no need to copy since RadioStations are immutable } diff --git a/backend/mediaprovider/subsonic/subsonicmediaprovider.go b/backend/mediaprovider/subsonic/subsonicmediaprovider.go index 98b95db..bddffea 100644 --- a/backend/mediaprovider/subsonic/subsonicmediaprovider.go +++ b/backend/mediaprovider/subsonic/subsonicmediaprovider.go @@ -367,7 +367,7 @@ func (s *subsonicMediaProvider) GetLyrics(track *mediaprovider.Track) (*mediapro return mpLyrics, nil } // fallback to legacy getLyrics endpoint - lyrics, err := s.client.GetLyrics(track.Name, track.ArtistNames[0]) + lyrics, err := s.client.GetLyrics(track.Title, track.ArtistNames[0]) if err != nil || lyrics == nil || lyrics.Text == "" { return nil, err } @@ -433,7 +433,7 @@ func toTrack(ch *subsonic.Child) *mediaprovider.Track { ID: ch.ID, CoverArtID: ch.CoverArt, ParentID: ch.Parent, - Name: ch.Title, + Title: ch.Title, Duration: ch.Duration, TrackNumber: ch.Track, DiscNumber: ch.DiscNumber, diff --git a/backend/mpmedia_mac.go b/backend/mpmedia_mac.go index b3343ce..3a30c1c 100644 --- a/backend/mpmedia_mac.go +++ b/backend/mpmedia_mac.go @@ -69,9 +69,13 @@ func InitMPMediaHandler(playbackManager *PlaybackManager, artURLLookup func(trac mpMediaEventRecipient = mp C.register_os_remote_commands() - mp.playbackManager.OnSongChange(func(track *NowPlayingMetadata, _ *mediaprovider.Track) { + mp.playbackManager.OnSongChange(func(track mediaprovider.MediaItem, _ *mediaprovider.Track) { // Asynchronously because artwork fetching can take time - go mp.updateMetadata(track) + var meta mediaprovider.MediaItemMetadata + if track == nil { + meta = track.Metadata() + } + go mp.updateMetadata(&meta) }) mp.playbackManager.OnStopped(func() { @@ -95,11 +99,11 @@ func InitMPMediaHandler(playbackManager *PlaybackManager, artURLLookup func(trac return nil } -func (mp *MPMediaHandler) updateMetadata(meta *NowPlayingMetadata) { +func (mp *MPMediaHandler) updateMetadata(meta *mediaprovider.MediaItemMetadata) { var title, artist, artURL string var duration int if meta != nil && meta.ID != "" { - title = meta.Title + title = meta.Name var err error if artURL, err = mp.artURLLookup(meta.CoverArtID); err != nil { log.Printf("error fetching art url: %s", err.Error()) diff --git a/backend/mpris.go b/backend/mpris.go index 045a2cd..2801e56 100644 --- a/backend/mpris.go +++ b/backend/mpris.go @@ -58,11 +58,11 @@ func NewMPRISHandler(playerName string, pm *PlaybackManager) *MPRISHandler { m.evt.Player.OnSeek(pos) } }) - pm.OnSongChange(func(tr *NowPlayingMetadata, _ *mediaprovider.Track) { + pm.OnSongChange(func(tr mediaprovider.MediaItem, _ *mediaprovider.Track) { if tr == nil { m.curTrackPath = "" } else { - m.curTrackPath = dbusTrackIDPrefix + encodeTrackId(tr.ID) + m.curTrackPath = dbusTrackIDPrefix + encodeTrackId(tr.Metadata().ID) } if m.connErr == nil { m.evt.Player.OnTitle() @@ -252,7 +252,7 @@ func (m *MPRISHandler) Metadata() (types.Metadata, error) { } status := m.pm.PlayerStatus() var tr mediaprovider.Track - if np := m.pm.NowPlayingMediaItem(); np != nil && status.State != player.Stopped { + if np := m.pm.NowPlaying(); np != nil && status.State != player.Stopped { if track, ok := np.(*mediaprovider.Track); ok { tr = *track } @@ -266,7 +266,7 @@ func (m *MPRISHandler) Metadata() (types.Metadata, error) { return types.Metadata{ TrackId: dbus.ObjectPath(trackObjPath), Length: secondsToMicroseconds(status.Duration), - Title: tr.Name, + Title: tr.Title, Album: tr.Album, Artist: tr.ArtistNames, DiscNumber: tr.DiscNumber, diff --git a/backend/playbackengine.go b/backend/playbackengine.go index 0d8f05d..46db90c 100644 --- a/backend/playbackengine.go +++ b/backend/playbackengine.go @@ -60,7 +60,7 @@ type playbackEngine struct { replayGainCfg ReplayGainConfig // registered callbacks - onSongChange []func(nowPlaying *NowPlayingMetadata, justScrobbledIfAny *mediaprovider.Track) + onSongChange []func(nowPlaying mediaprovider.MediaItem, justScrobbledIfAny *mediaprovider.Track) onPlayTimeUpdate []func(float64, float64, bool) onLoopModeChange []func(LoopMode) onVolumeChange []func(int) @@ -122,34 +122,8 @@ func (p *playbackEngine) PlayTrackAt(idx int) error { return p.setTrack(idx, false) } -// Gets the curently playing song, if any. -func (p *playbackEngine) NowPlaying() *NowPlayingMetadata { - if p.nowPlayingIdx < 0 || len(p.playQueue) == 0 || p.player.GetStatus().State == player.Stopped { - return nil - } - item := p.playQueue[p.nowPlayingIdx] - if tr, ok := item.(*mediaprovider.Track); ok { - return &NowPlayingMetadata{ - Type: MediaTypeTrack, - ID: tr.ID, - Title: tr.Name, - Artists: tr.ArtistNames, - ArtistIDs: tr.ArtistIDs, - Album: tr.Album, - AlbumID: tr.AlbumID, - Duration: float64(tr.Duration), - } - } - return &NowPlayingMetadata{ - Type: MediaTypeRadioStation, - ID: item.MediaItemID(), - Title: item.MediaItemName(), - Duration: 0.0, - } -} - -// Gets the curently playing song, if any. -func (p *playbackEngine) NowPlayingMediaItem() mediaprovider.MediaItem { +// Gets the curently playing media item, if any. +func (p *playbackEngine) NowPlaying() mediaprovider.MediaItem { if p.nowPlayingIdx < 0 || len(p.playQueue) == 0 || p.player.GetStatus().State == player.Stopped { return nil } @@ -304,9 +278,9 @@ func (p *playbackEngine) UpdatePlayQueue(tracks []*mediaprovider.Track) error { newQueue := p.copyTrackSliceToMediaItemSlice(tracks) newNowPlayingIdx := -1 if p.nowPlayingIdx >= 0 { - nowPlayingID := p.playQueue[p.nowPlayingIdx].MediaItemID() + nowPlayingID := p.playQueue[p.nowPlayingIdx].Metadata().ID for i, tr := range newQueue { - if tr.MediaItemID() == nowPlayingID { + if tr.Metadata().ID == nowPlayingID { newNowPlayingIdx = i break } @@ -335,7 +309,7 @@ func (p *playbackEngine) RemoveTracksFromQueue(trackIDs []string) { nowPlaying := p.NowPlayingIndex() newNowPlaying := nowPlaying for i, tr := range p.playQueue { - if _, ok := idSet[tr.MediaItemID()]; ok { + if _, ok := idSet[tr.Metadata().ID]; ok { if i < nowPlaying { // if removing a track earlier than the currently playing one (if any), // decrement new now playing index by one to account for new position in queue diff --git a/backend/playbackmanager.go b/backend/playbackmanager.go index 9ba9eac..4471c8e 100644 --- a/backend/playbackmanager.go +++ b/backend/playbackmanager.go @@ -15,25 +15,6 @@ type PlaybackManager struct { engine *playbackEngine } -type MediaType int - -const ( - MediaTypeTrack MediaType = iota - MediaTypeRadioStation -) - -type NowPlayingMetadata struct { - Type MediaType - ID string - Title string - Artists []string - ArtistIDs []string - Album string - AlbumID string - CoverArtID string - Duration int -} - func NewPlaybackManager( ctx context.Context, s *ServerManager, @@ -64,21 +45,17 @@ func (p *PlaybackManager) DisableCallbacks() { p.engine.callbacksDisabled = true } -// Gets the curently playing song, if any. -func (p *PlaybackManager) NowPlaying() *NowPlayingMetadata { +// Gets the now playing media item, if any. +func (p *PlaybackManager) NowPlaying() mediaprovider.MediaItem { return p.engine.NowPlaying() } -func (p *PlaybackManager) NowPlayingMediaItem() mediaprovider.MediaItem { - return p.engine.NowPlayingMediaItem() -} - func (p *PlaybackManager) NowPlayingIndex() int { return p.engine.NowPlayingIndex() } // Sets a callback that is notified whenever a new song begins playing. -func (p *PlaybackManager) OnSongChange(cb func(nowPlaying *NowPlayingMetadata, justScrobbledIfAny *mediaprovider.Track)) { +func (p *PlaybackManager) OnSongChange(cb func(nowPlaying mediaprovider.MediaItem, justScrobbledIfAny *mediaprovider.Track)) { p.engine.onSongChange = append(p.engine.onSongChange, cb) } diff --git a/backend/savedplayqueue.go b/backend/savedplayqueue.go index c66e1a7..ad664b9 100644 --- a/backend/savedplayqueue.go +++ b/backend/savedplayqueue.go @@ -33,7 +33,7 @@ func SavePlayQueue(serverID string, pm *PlaybackManager, filepath string, server for _, item := range queue { if _, ok := item.(*mediaprovider.Track); ok { // dont' save radio stations in play queue - trackIDs = append(trackIDs, item.MediaItemID()) + trackIDs = append(trackIDs, item.Metadata().ID) } } diff --git a/sharedutil/sharedutil.go b/sharedutil/sharedutil.go index ef72041..c407783 100644 --- a/sharedutil/sharedutil.go +++ b/sharedutil/sharedutil.go @@ -4,7 +4,6 @@ import ( "math" "slices" - "github.com/dweymouth/supersonic/backend" "github.com/dweymouth/supersonic/backend/mediaprovider" ) @@ -77,25 +76,18 @@ func FindTrackByID(id string, tracks []*mediaprovider.Track) *mediaprovider.Trac func FindMediaItemByID(id string, items []mediaprovider.MediaItem) mediaprovider.MediaItem { for _, tr := range items { - if id == tr.MediaItemID() { + if id == tr.Metadata().ID { return tr } } return nil } -func TrackIDOrEmptyStr(track *mediaprovider.Track) string { - if track == nil { +func MediaItemIDOrEmptyStr(item mediaprovider.MediaItem) string { + if item == nil { return "" } - return track.ID -} - -func TrackIDOremptyStringFromMeta(meta *backend.NowPlayingMetadata) string { - if meta == nil || meta.Type != backend.MediaTypeTrack { - return "" - } - return meta.ID + return item.Metadata().ID } func AlbumIDOrEmptyStr(track *mediaprovider.Track) string { diff --git a/ui/bottompanel.go b/ui/bottompanel.go index 7af0d53..a99de31 100644 --- a/ui/bottompanel.go +++ b/ui/bottompanel.go @@ -56,22 +56,30 @@ func NewBottomPanel(pm *backend.PlaybackManager, contr *controller.Controller) * contr.NavigateTo(controller.NowPlayingRoute("")) } bp.NowPlaying.OnSetFavorite = func(fav bool) { - contr.SetTrackFavorites([]string{pm.NowPlaying().ID}, fav) + if tr, ok := pm.NowPlaying().(*mediaprovider.Track); ok { + contr.SetTrackFavorites([]string{tr.ID}, fav) + } } bp.NowPlaying.OnSetRating = func(rating int) { - contr.SetTrackRatings([]string{pm.NowPlaying().ID}, rating) + if tr, ok := pm.NowPlaying().(*mediaprovider.Track); ok { + contr.SetTrackRatings([]string{tr.ID}, rating) + } } bp.NowPlaying.OnAddToPlaylist = func() { - contr.DoAddTracksToPlaylistWorkflow([]string{pm.NowPlaying().ID}) + if tr, ok := pm.NowPlaying().(*mediaprovider.Track); ok { + contr.DoAddTracksToPlaylistWorkflow([]string{tr.ID}) + } } bp.NowPlaying.OnAlbumNameTapped = func() { - contr.NavigateTo(controller.AlbumRoute(pm.NowPlaying().AlbumID)) + if tr, ok := pm.NowPlaying().(*mediaprovider.Track); ok { + contr.NavigateTo(controller.AlbumRoute(tr.AlbumID)) + } } bp.NowPlaying.OnArtistNameTapped = func(artistID string) { contr.NavigateTo(controller.ArtistRoute(artistID)) } bp.NowPlaying.OnTrackNameTapped = func() { - contr.NavigateTo(controller.NowPlayingRoute(pm.NowPlaying().ID)) + contr.NavigateTo(controller.NowPlayingRoute(pm.NowPlaying().Metadata().ID)) } bp.Controls = widgets.NewPlayerControls() bp.Controls.OnPlayPause(func() { @@ -102,19 +110,20 @@ func NewBottomPanel(pm *backend.PlaybackManager, contr *controller.Controller) * return bp } -func (bp *BottomPanel) onSongChange(song, _ *mediaprovider.Track) { +func (bp *BottomPanel) onSongChange(song mediaprovider.MediaItem, _ *mediaprovider.Track) { if song == nil { bp.NowPlaying.Update(nil, nil) } else { - bp.coverArtID = song.CoverArtID + meta := song.Metadata() + bp.coverArtID = meta.CoverArtID var im image.Image if bp.ImageManager != nil { // set image to expire not long after the length of the song // if song is played through without much pausing, image will still // be in cache for the next song if it's from the same album, or // if the user navigates to the album page for the track - imgTTLSec := song.Duration + 30 - im, _ = bp.ImageManager.GetCoverThumbnailWithTTL(song.CoverArtID, time.Duration(imgTTLSec)*time.Second) + imgTTLSec := meta.Duration + 30 + im, _ = bp.ImageManager.GetCoverThumbnailWithTTL(meta.CoverArtID, time.Duration(imgTTLSec)*time.Second) } bp.NowPlaying.Update(song, im) } diff --git a/ui/browsing/albumpage.go b/ui/browsing/albumpage.go index cc6197d..ae4d634 100644 --- a/ui/browsing/albumpage.go +++ b/ui/browsing/albumpage.go @@ -131,9 +131,9 @@ func (a *AlbumPage) Route() controller.Route { } func (a *AlbumPage) OnSongChange(track, lastScrobbledIfAny *mediaprovider.Track) { - a.nowPlayingID = sharedutil.TrackIDOrEmptyStr(track) + a.nowPlayingID = sharedutil.MediaItemIDOrEmptyStr(track) a.tracklist.SetNowPlaying(a.nowPlayingID) - a.tracklist.IncrementPlayCount(sharedutil.TrackIDOrEmptyStr(lastScrobbledIfAny)) + a.tracklist.IncrementPlayCount(sharedutil.MediaItemIDOrEmptyStr(lastScrobbledIfAny)) } func (a *AlbumPage) Reload() { diff --git a/ui/browsing/artistpage.go b/ui/browsing/artistpage.go index 35439fd..634e304 100644 --- a/ui/browsing/artistpage.go +++ b/ui/browsing/artistpage.go @@ -139,12 +139,12 @@ func (a *ArtistPage) Save() SavedPage { var _ CanShowNowPlaying = (*ArtistPage)(nil) -func (a *ArtistPage) OnSongChange(track, lastScrobbledIfAny *mediaprovider.Track) { - a.nowPlayingID = sharedutil.TrackIDOrEmptyStr(track) +func (a *ArtistPage) OnSongChange(track mediaprovider.MediaItem, lastScrobbledIfAny *mediaprovider.Track) { + a.nowPlayingID = sharedutil.MediaItemIDOrEmptyStr(track) if a.tracklistCtr != nil { tl := a.tracklistCtr.Objects[0].(*widgets.Tracklist) tl.SetNowPlaying(a.nowPlayingID) - tl.IncrementPlayCount(sharedutil.TrackIDOrEmptyStr(lastScrobbledIfAny)) + tl.IncrementPlayCount(sharedutil.MediaItemIDOrEmptyStr(lastScrobbledIfAny)) } } diff --git a/ui/browsing/browsingpane.go b/ui/browsing/browsingpane.go index 38b0550..4fce17d 100644 --- a/ui/browsing/browsingpane.go +++ b/ui/browsing/browsingpane.go @@ -42,7 +42,7 @@ type Scrollable interface { } type CanShowNowPlaying interface { - OnSongChange(meta *backend.NowPlayingMetadata, lastScrobbledIfAny *mediaprovider.Track) + OnSongChange(playing mediaprovider.MediaItem, lastScrobbledIfAny *mediaprovider.Track) } type CanShowPlayTime interface { @@ -213,7 +213,7 @@ func (b *BrowsingPane) doSetPage(p Page) bool { return true } -func (b *BrowsingPane) onSongChange(song *backend.NowPlayingMetadata, lastScrobbledIfAny *mediaprovider.Track) { +func (b *BrowsingPane) onSongChange(song mediaprovider.MediaItem, lastScrobbledIfAny *mediaprovider.Track) { if b.curPage == nil { return } diff --git a/ui/browsing/favoritespage.go b/ui/browsing/favoritespage.go index a841e4e..a550a3e 100644 --- a/ui/browsing/favoritespage.go +++ b/ui/browsing/favoritespage.go @@ -269,11 +269,11 @@ func (a *FavoritesPage) OnSearched(query string) { var _ CanShowNowPlaying = (*FavoritesPage)(nil) -func (a *FavoritesPage) OnSongChange(meta *backend.NowPlayingMetadata, lastScrobbledIfAny *mediaprovider.Track) { - a.nowPlayingID = sharedutil.TrackIDOrEmptyStr(song) +func (a *FavoritesPage) OnSongChange(item mediaprovider.MediaItem, lastScrobbledIfAny *mediaprovider.Track) { + a.nowPlayingID = sharedutil.MediaItemIDOrEmptyStr(item) if tracklist := a.tracklistOrNil(); tracklist != nil { tracklist.SetNowPlaying(a.nowPlayingID) - tracklist.IncrementPlayCount(sharedutil.TrackIDOrEmptyStr(lastScrobbledIfAny)) + tracklist.IncrementPlayCount(sharedutil.MediaItemIDOrEmptyStr(lastScrobbledIfAny)) } } diff --git a/ui/browsing/nowplayingpage.go b/ui/browsing/nowplayingpage.go index 9e8b113..c182bf8 100644 --- a/ui/browsing/nowplayingpage.go +++ b/ui/browsing/nowplayingpage.go @@ -37,7 +37,7 @@ type NowPlayingPage struct { nowPlayingPageState // volatile state - nowPlaying *mediaprovider.Track + nowPlaying mediaprovider.MediaItem nowPlayingID string curLyricsID string // id of track currently shown in lyrics curRelatedID string // id of track currrently used to populate related list @@ -108,7 +108,7 @@ func NewNowPlayingPage( a.card = widgets.NewLargeNowPlayingCard() a.card.OnAlbumNameTapped = func() { - contr.NavigateTo(controller.AlbumRoute(sharedutil.AlbumIDOrEmptyStr(a.nowPlaying))) + contr.NavigateTo(controller.AlbumRoute(a.nowPlaying.Metadata().AlbumID)) } a.card.OnArtistNameTapped = func(artistID string) { contr.NavigateTo(controller.ArtistRoute(artistID)) @@ -245,12 +245,12 @@ func (a *NowPlayingPage) Route() controller.Route { var _ CanShowNowPlaying = (*NowPlayingPage)(nil) -func (a *NowPlayingPage) OnSongChange(song *backend.NowPlayingMetadata, lastScrobbledIfAny *mediaprovider.Track) { +func (a *NowPlayingPage) OnSongChange(song mediaprovider.MediaItem, lastScrobbledIfAny *mediaprovider.Track) { a.nowPlaying = song if a.imageLoadCancel != nil { a.imageLoadCancel() } - a.nowPlayingID = sharedutil.TrackIDOremptyStringFromMeta(song) + a.nowPlayingID = sharedutil.MediaItemIDOrEmptyStr(song) a.queueList.SetNowPlaying(a.nowPlayingID) a.relatedList.SetNowPlaying(a.nowPlayingID) @@ -258,7 +258,7 @@ func (a *NowPlayingPage) OnSongChange(song *backend.NowPlayingMetadata, lastScro if song == nil { a.card.SetCoverImage(nil) } else { - a.imageLoadCancel = a.im.GetFullSizeCoverArtAsync(song.CoverArtID, a.onImageLoaded) + a.imageLoadCancel = a.im.GetFullSizeCoverArtAsync(song.Metadata().CoverArtID, a.onImageLoaded) } if a.tabs != nil && a.tabs.SelectedIndex() == 1 /*lyrics*/ { @@ -308,7 +308,7 @@ func (a *NowPlayingPage) updateLyrics() { return } } - if a.nowPlaying == nil { + if a.nowPlaying == nil || a.nowPlaying.Metadata().Type == mediaprovider.MediaItemTypeRadioStation { a.lyricsViewer.SetLyrics(nil) a.curLyricsID = "" return @@ -321,19 +321,20 @@ func (a *NowPlayingPage) updateLyrics() { // to keep it from showing "Lyrics not available" a.lyricsViewer.SetLyrics(&mediaprovider.Lyrics{Synced: true, Lines: []mediaprovider.LyricLine{{Text: ""}}}) - go a.fetchLyrics(ctx, a.nowPlaying) + tr, _ := a.nowPlaying.(*mediaprovider.Track) + go a.fetchLyrics(ctx, tr) } func (a *NowPlayingPage) fetchLyrics(ctx context.Context, song *mediaprovider.Track) { var lyrics *mediaprovider.Lyrics var err error if lp, ok := a.sm.Server.(mediaprovider.LyricsProvider); ok { - if lyrics, err = lp.GetLyrics(a.nowPlaying); err != nil { + if lyrics, err = lp.GetLyrics(song); err != nil { log.Printf("Error fetching lyrics: %v", err) } } if lyrics == nil { - lyrics, err = backend.FetchLrcLibLyrics(song.Name, song.ArtistNames[0], song.Album, song.Duration) + lyrics, err = backend.FetchLrcLibLyrics(song.Title, song.ArtistNames[0], song.Album, song.Duration) if err != nil { log.Println(err.Error()) } @@ -400,7 +401,8 @@ func (a *NowPlayingPage) Reload() { a.relatedList.DisableRating = !a.canRate a.relatedList.DisableSharing = !a.canShare - a.queue = a.pm.GetPlayQueue() + // TODO + //a.queue = a.pm.GetPlayQueue() a.queueList.SetTracks(a.queue) a.totalTime = 0.0 for _, tr := range a.queue { @@ -492,7 +494,7 @@ func (a *NowPlayingPage) formatStatusLine() { dur := 0.0 if np := a.pm.NowPlaying(); np != nil { - dur = float64(np.Duration) + dur = float64(np.Metadata().Duration) } statusSuffix := "" trackNum := 0 diff --git a/ui/browsing/playlistpage.go b/ui/browsing/playlistpage.go index 104e23d..4044fff 100644 --- a/ui/browsing/playlistpage.go +++ b/ui/browsing/playlistpage.go @@ -127,9 +127,9 @@ func (a *PlaylistPage) Route() controller.Route { } func (a *PlaylistPage) OnSongChange(track, lastScrobbledIfAny *mediaprovider.Track) { - a.nowPlayingID = sharedutil.TrackIDOrEmptyStr(track) + a.nowPlayingID = sharedutil.MediaItemIDOrEmptyStr(track) a.tracklist.SetNowPlaying(a.nowPlayingID) - a.tracklist.IncrementPlayCount(sharedutil.TrackIDOrEmptyStr(lastScrobbledIfAny)) + a.tracklist.IncrementPlayCount(sharedutil.MediaItemIDOrEmptyStr(lastScrobbledIfAny)) } func (a *PlaylistPage) Reload() { diff --git a/ui/browsing/trackspage.go b/ui/browsing/trackspage.go index fea8fdc..d2bc587 100644 --- a/ui/browsing/trackspage.go +++ b/ui/browsing/trackspage.go @@ -96,12 +96,12 @@ func (t *TracksPage) Reload() { } func (t *TracksPage) OnSongChange(track, lastScrobbledIfAny *mediaprovider.Track) { - t.nowPlayingID = sharedutil.TrackIDOrEmptyStr(track) + t.nowPlayingID = sharedutil.MediaItemIDOrEmptyStr(track) t.tracklist.SetNowPlaying(t.nowPlayingID) if t.searchTracklist != nil { t.searchTracklist.SetNowPlaying(t.nowPlayingID) } - playedID := sharedutil.TrackIDOrEmptyStr(lastScrobbledIfAny) + playedID := sharedutil.MediaItemIDOrEmptyStr(lastScrobbledIfAny) t.tracklist.IncrementPlayCount(playedID) if t.searchTracklist != nil { t.searchTracklist.IncrementPlayCount(playedID) diff --git a/ui/controller/controller.go b/ui/controller/controller.go index 95d149c..d62869e 100644 --- a/ui/controller/controller.go +++ b/ui/controller/controller.go @@ -827,8 +827,8 @@ func (c *Controller) downloadTrack(track *mediaprovider.Track, filePath string) return } - log.Printf("Saved song %s to: %s\n", track.Name, filePath) - c.sendNotification(fmt.Sprintf("Download completed: %s", track.Name), fmt.Sprintf("Saved at: %s", filePath)) + log.Printf("Saved song %s to: %s\n", track.Title, filePath) + c.sendNotification(fmt.Sprintf("Download completed: %s", track.Title), fmt.Sprintf("Saved at: %s", filePath)) } func (c *Controller) downloadTracks(tracks []*mediaprovider.Track, filePath, downloadName string) { @@ -863,7 +863,7 @@ func (c *Controller) downloadTracks(tracks []*mediaprovider.Track, filePath, dow continue } - log.Printf("Saved song %s to: %s\n", track.Name, filePath) + log.Printf("Saved song %s to: %s\n", track.Title, filePath) } c.sendNotification(fmt.Sprintf("Download completed: %s", downloadName), fmt.Sprintf("Saved at: %s", filePath)) diff --git a/ui/mainwindow.go b/ui/mainwindow.go index 51b4fce..adbf867 100644 --- a/ui/mainwindow.go +++ b/ui/mainwindow.go @@ -86,17 +86,21 @@ func NewMainWindow(fyneApp fyne.App, appName, displayAppName, appVersion string, m.container = container.NewBorder(nil, m.BottomPanel, nil, nil, m.BrowsingPane) m.Window.SetContent(m.container) m.Window.Resize(size) - app.PlaybackManager.OnSongChange(func(track, _ *mediaprovider.Track) { - if track == nil { + app.PlaybackManager.OnSongChange(func(item mediaprovider.MediaItem, _ *mediaprovider.Track) { + if item == nil { m.Window.SetTitle(displayAppName) return } - artistDisp := strings.Join(track.ArtistNames, ", ") - m.Window.SetTitle(fmt.Sprintf("%s – %s · %s", track.Name, artistDisp, displayAppName)) + meta := item.Metadata() + artistDisp := "" + if tr, ok := item.(*mediaprovider.Track); ok { + artistDisp = strings.Join(tr.ArtistNames, ", ") + } + m.Window.SetTitle(fmt.Sprintf("%s – %s · %s", meta.Name, artistDisp, displayAppName)) if m.App.Config.Application.ShowTrackChangeNotification { // TODO: Once Fyne issue #2935 is resolved, show album cover fyne.CurrentApp().SendNotification(&fyne.Notification{ - Title: track.Name, + Title: meta.Name, Content: artistDisp, }) } diff --git a/ui/widgets/largenowplayingcard.go b/ui/widgets/largenowplayingcard.go index f2d97fd..cc3ad33 100644 --- a/ui/widgets/largenowplayingcard.go +++ b/ui/widgets/largenowplayingcard.go @@ -108,20 +108,23 @@ func (n *LargeNowPlayingCard) SetDisplayedFavorite(favorite bool) { n.favorite.Refresh() } -func (n *LargeNowPlayingCard) Update(track *mediaprovider.Track) { - if track != nil { - n.trackName.Segments[0].(*widget.TextSegment).Text = track.Name - n.artistName.BuildSegments(track.ArtistNames, track.ArtistIDs) - n.albumName.Text = track.Album - n.rating.Rating = track.Rating - n.favorite.Favorite = track.Favorite - } else { +func (n *LargeNowPlayingCard) Update(item mediaprovider.MediaItem) { + if item == nil { n.trackName.Segments[0].(*widget.TextSegment).Text = "" n.artistName.BuildSegments([]string{}, []string{}) n.albumName.Text = "" n.rating.Rating = 0 n.favorite.Favorite = false } + meta := item.Metadata() + n.trackName.Segments[0].(*widget.TextSegment).Text = meta.Name + + if tr, ok := item.(*mediaprovider.Track); ok { + n.artistName.BuildSegments(tr.ArtistNames, tr.ArtistIDs) + n.albumName.Text = tr.Album + n.rating.Rating = tr.Rating + n.favorite.Favorite = tr.Favorite + } n.Refresh() } diff --git a/ui/widgets/nowplayingcard.go b/ui/widgets/nowplayingcard.go index c097513..e5ae046 100644 --- a/ui/widgets/nowplayingcard.go +++ b/ui/widgets/nowplayingcard.go @@ -119,15 +119,21 @@ func (n *NowPlayingCard) CreateRenderer() fyne.WidgetRenderer { return widget.NewSimpleRenderer(c) } -func (n *NowPlayingCard) Update(track *mediaprovider.Track, cover image.Image) { - if track != nil { - n.trackName.SetText(track.Name) - n.artistName.BuildSegments(track.ArtistNames, track.ArtistIDs) - n.albumName.SetText(track.Album) - } else { +func (n *NowPlayingCard) Update(track mediaprovider.MediaItem, cover image.Image) { + if track == nil { n.trackName.SetText("") n.artistName.BuildSegments([]string{}, []string{}) n.albumName.SetText("") + + } else { + n.trackName.SetText(track.Metadata().Name) + if tr, ok := track.(*mediaprovider.Track); ok { + n.artistName.BuildSegments(tr.ArtistNames, tr.ArtistIDs) + n.albumName.SetText(tr.Album) + } else { + n.artistName.BuildSegments([]string{}, []string{}) + n.albumName.SetText("") + } } n.trackName.Hidden = n.trackName.Text == "" n.artistName.Hidden = len(n.artistName.Segments) == 0 diff --git a/ui/widgets/playqueuelist.go b/ui/widgets/playqueuelist.go index 2c9d80f..c402f21 100644 --- a/ui/widgets/playqueuelist.go +++ b/ui/widgets/playqueuelist.go @@ -360,7 +360,7 @@ func (p *PlayQueueListRow) Update(tm *util.TrackListModel, rowNum int) { p.imageLoader.Load(tm.Track.CoverArtID) p.EnsureUnfocused() p.trackID = tr.ID - p.title.Text = tr.Name + p.title.Text = tr.Title p.artist.BuildSegments(tr.ArtistNames, tr.ArtistIDs) p.time.Text = util.SecondsToTimeString(float64(tr.Duration)) changed = true diff --git a/ui/widgets/tracklist.go b/ui/widgets/tracklist.go index bcdd395..d5f5d43 100644 --- a/ui/widgets/tracklist.go +++ b/ui/widgets/tracklist.go @@ -422,7 +422,7 @@ func (t *Tracklist) doSortTracks() { t.tracks = t.tracksOrigOrder } case ColumnTitle: - t.stringSort(func(tr *util.TrackListModel) string { return tr.Track.Name }) + t.stringSort(func(tr *util.TrackListModel) string { return tr.Track.Title }) case ColumnArtist: t.stringSort(func(tr *util.TrackListModel) string { return strings.Join(tr.Track.ArtistNames, ", ") }) case ColumnAlbum: diff --git a/ui/widgets/tracklistrow.go b/ui/widgets/tracklistrow.go index 41654cd..0e9bf0f 100644 --- a/ui/widgets/tracklistrow.go +++ b/ui/widgets/tracklistrow.go @@ -261,7 +261,7 @@ func (t *tracklistRowBase) Update(tm *util.TrackListModel, rowNum int) { t.EnsureUnfocused() t.trackID = tr.ID - t.name.Segments[0].(*widget.TextSegment).Text = tr.Name + t.name.Segments[0].(*widget.TextSegment).Text = tr.Title t.artist.BuildSegments(tr.ArtistNames, tr.ArtistIDs) t.album.BuildSegments([]string{tr.Album}, []string{tr.AlbumID}) t.dur.Text = util.SecondsToTimeString(float64(tr.Duration)) From c6c1341f8fad3403ec54a00be915d080559e2fbc Mon Sep 17 00:00:00 2001 From: Drew Weymouth Date: Fri, 31 May 2024 17:21:33 -0700 Subject: [PATCH 03/25] fix some logic errors from refactoring --- backend/mpmedia_mac.go | 13 +++++--- backend/mpris.go | 52 ++++++++++++++++++++----------- backend/playbackengine.go | 11 +++---- backend/playbackmanager.go | 2 +- backend/savedplayqueue.go | 2 +- ui/mainwindow.go | 4 +-- ui/widgets/largenowplayingcard.go | 5 +-- 7 files changed, 53 insertions(+), 36 deletions(-) diff --git a/backend/mpmedia_mac.go b/backend/mpmedia_mac.go index 3a30c1c..dff1fcc 100644 --- a/backend/mpmedia_mac.go +++ b/backend/mpmedia_mac.go @@ -71,11 +71,12 @@ func InitMPMediaHandler(playbackManager *PlaybackManager, artURLLookup func(trac mp.playbackManager.OnSongChange(func(track mediaprovider.MediaItem, _ *mediaprovider.Track) { // Asynchronously because artwork fetching can take time - var meta mediaprovider.MediaItemMetadata - if track == nil { - meta = track.Metadata() + var meta *mediaprovider.MediaItemMetadata + if track != nil { + m := track.Metadata() + meta = &m } - go mp.updateMetadata(&meta) + go mp.updateMetadata(meta) }) mp.playbackManager.OnStopped(func() { @@ -106,7 +107,9 @@ func (mp *MPMediaHandler) updateMetadata(meta *mediaprovider.MediaItemMetadata) title = meta.Name var err error if artURL, err = mp.artURLLookup(meta.CoverArtID); err != nil { - log.Printf("error fetching art url: %s", err.Error()) + if meta.CoverArtID != "" { + log.Printf("error fetching art url: %s", err.Error()) + } } artist = strings.Join(meta.Artists, ", ") duration = meta.Duration diff --git a/backend/mpris.go b/backend/mpris.go index 2801e56..440747c 100644 --- a/backend/mpris.go +++ b/backend/mpris.go @@ -251,32 +251,48 @@ func (m *MPRISHandler) Metadata() (types.Metadata, error) { trackObjPath = m.curTrackPath } status := m.pm.PlayerStatus() - var tr mediaprovider.Track + + var meta mediaprovider.MediaItemMetadata + // metadata that can come only from tracks + var discNumber, trackNumber, userRating, playCount, year int + var genre string + if np := m.pm.NowPlaying(); np != nil && status.State != player.Stopped { + meta = np.Metadata() if track, ok := np.(*mediaprovider.Track); ok { - tr = *track + discNumber = track.DiscNumber + trackNumber = track.TrackNumber + userRating = track.Rating + playCount = track.PlayCount + year = track.Year + genre = track.Genre } } var artURL string - if tr.ID != "" && m.ArtURLLookup != nil { - if u, err := m.ArtURLLookup(tr.CoverArtID); err == nil { + if meta.ID != "" && m.ArtURLLookup != nil { + if u, err := m.ArtURLLookup(meta.CoverArtID); err == nil { artURL = u } } - return types.Metadata{ - TrackId: dbus.ObjectPath(trackObjPath), - Length: secondsToMicroseconds(status.Duration), - Title: tr.Title, - Album: tr.Album, - Artist: tr.ArtistNames, - DiscNumber: tr.DiscNumber, - TrackNumber: tr.TrackNumber, - Genre: []string{tr.Genre}, - UserRating: float64(tr.Rating) / 5, - ContentCreated: strconv.Itoa(tr.Year), - UseCount: tr.PlayCount, - ArtUrl: artURL, - }, nil + mprisMeta := types.Metadata{ + TrackId: dbus.ObjectPath(trackObjPath), + Length: secondsToMicroseconds(status.Duration), + Title: meta.Name, + Album: meta.Album, + Artist: meta.Artists, + DiscNumber: discNumber, + TrackNumber: trackNumber, + UserRating: float64(userRating) / 5, + UseCount: playCount, + ArtUrl: artURL, + } + if genre != "" { + mprisMeta.Genre = []string{genre} + } + if year != 0 { + mprisMeta.ContentCreated = strconv.Itoa(year) + } + return mprisMeta, nil } func (m *MPRISHandler) Volume() (float64, error) { diff --git a/backend/playbackengine.go b/backend/playbackengine.go index 46db90c..f2e2526 100644 --- a/backend/playbackengine.go +++ b/backend/playbackengine.go @@ -44,7 +44,7 @@ type playbackEngine struct { player player.BasePlayer playTimeStopwatch util.Stopwatch - curTrackTime float64 + curTrackDuration float64 latestTrackPosition float64 // cleared by checkScrobble callbacksDisabled bool @@ -400,10 +400,7 @@ func (p *playbackEngine) handleOnTrackChange() { } } p.wasStopped = false - p.curTrackTime = 0 - if tr, ok := p.playQueue[p.nowPlayingIdx].(*mediaprovider.Track); ok { - p.curTrackTime = float64(tr.Duration) - } + p.curTrackDuration = float64(p.playQueue[p.nowPlayingIdx].Metadata().Duration) p.sendNowPlayingScrobble() // Must come before invokeOnChangeCallbacks b/c track may immediately be scrobbled p.invokeOnSongChangeCallbacks() p.doUpdateTimePos(false) @@ -512,10 +509,10 @@ func (p *playbackEngine) checkScrobble() { } playDur := p.playTimeStopwatch.Elapsed() - if playDur.Seconds() < 0.1 || p.curTrackTime < 0.1 { + if playDur.Seconds() < 0.1 || p.curTrackDuration < 0.1 { return } - pcnt := playDur.Seconds() / p.curTrackTime * 100 + pcnt := playDur.Seconds() / p.curTrackDuration * 100 timeThresholdMet := p.scrobbleCfg.ThresholdTimeSeconds >= 0 && playDur.Seconds() >= float64(p.scrobbleCfg.ThresholdTimeSeconds) diff --git a/backend/playbackmanager.go b/backend/playbackmanager.go index 4471c8e..6c8e175 100644 --- a/backend/playbackmanager.go +++ b/backend/playbackmanager.go @@ -281,7 +281,7 @@ func (p *PlaybackManager) SeekFraction(fraction float64) error { } else if fraction > 1 { fraction = 1 } - target := p.engine.curTrackTime * fraction + target := p.engine.curTrackDuration * fraction return p.engine.SeekSeconds(target) } diff --git a/backend/savedplayqueue.go b/backend/savedplayqueue.go index ad664b9..62a944f 100644 --- a/backend/savedplayqueue.go +++ b/backend/savedplayqueue.go @@ -32,7 +32,7 @@ func SavePlayQueue(serverID string, pm *PlaybackManager, filepath string, server trackIDs := make([]string, 0, len(queue)) for _, item := range queue { if _, ok := item.(*mediaprovider.Track); ok { - // dont' save radio stations in play queue + // don't save radio stations in play queue trackIDs = append(trackIDs, item.Metadata().ID) } } diff --git a/ui/mainwindow.go b/ui/mainwindow.go index adbf867..68d5e10 100644 --- a/ui/mainwindow.go +++ b/ui/mainwindow.go @@ -94,9 +94,9 @@ func NewMainWindow(fyneApp fyne.App, appName, displayAppName, appVersion string, meta := item.Metadata() artistDisp := "" if tr, ok := item.(*mediaprovider.Track); ok { - artistDisp = strings.Join(tr.ArtistNames, ", ") + artistDisp = " – " + strings.Join(tr.ArtistNames, ", ") } - m.Window.SetTitle(fmt.Sprintf("%s – %s · %s", meta.Name, artistDisp, displayAppName)) + m.Window.SetTitle(fmt.Sprintf("%s%s · %s", meta.Name, artistDisp, displayAppName)) if m.App.Config.Application.ShowTrackChangeNotification { // TODO: Once Fyne issue #2935 is resolved, show album cover fyne.CurrentApp().SendNotification(&fyne.Notification{ diff --git a/ui/widgets/largenowplayingcard.go b/ui/widgets/largenowplayingcard.go index cc3ad33..a602182 100644 --- a/ui/widgets/largenowplayingcard.go +++ b/ui/widgets/largenowplayingcard.go @@ -115,13 +115,14 @@ func (n *LargeNowPlayingCard) Update(item mediaprovider.MediaItem) { n.albumName.Text = "" n.rating.Rating = 0 n.favorite.Favorite = false + return } meta := item.Metadata() n.trackName.Segments[0].(*widget.TextSegment).Text = meta.Name + n.artistName.BuildSegments(meta.Artists, meta.ArtistIDs) + n.albumName.Text = meta.Album if tr, ok := item.(*mediaprovider.Track); ok { - n.artistName.BuildSegments(tr.ArtistNames, tr.ArtistIDs) - n.albumName.Text = tr.Album n.rating.Rating = tr.Rating n.favorite.Favorite = tr.Favorite } From 82d45460969d850a60cf4aa9223af74eea46ecbe Mon Sep 17 00:00:00 2001 From: Drew Weymouth Date: Sat, 1 Jun 2024 10:15:18 -0700 Subject: [PATCH 04/25] refactor tracklist to use MediaItem, even if it only contains tracks --- backend/playbackengine.go | 8 ++-- backend/playbackmanager.go | 4 +- sharedutil/sharedutil.go | 57 +++++++++++++++++++++++++++++ ui/browsing/nowplayingpage.go | 15 ++++---- ui/util/tracklistutil.go | 58 ++++++++++++++++------------- ui/widgets/playqueuelist.go | 69 +++++++++++++++++++++-------------- ui/widgets/tracklist.go | 42 ++++++++++----------- ui/widgets/tracklistrow.go | 10 ++--- 8 files changed, 170 insertions(+), 93 deletions(-) diff --git a/backend/playbackengine.go b/backend/playbackengine.go index f2e2526..f4589eb 100644 --- a/backend/playbackengine.go +++ b/backend/playbackengine.go @@ -248,7 +248,7 @@ func (p *playbackEngine) StopAndClearPlayQueue() { } func (p *playbackEngine) GetPlayQueue() []mediaprovider.MediaItem { - return p.deepCopyTrackSlice(p.playQueue) + return p.deepCopyMediaItemSlice(p.playQueue) } // Any time the user changes the favorite status of a track elsewhere in the app, @@ -274,8 +274,8 @@ func (p *playbackEngine) OnTrackRatingChanged(id string, rating int) { // Replaces the play queue with the given set of tracks. // Does not stop playback if the currently playing track is in the new queue, // but updates the now playing index to point to the first instance of the track in the new queue. -func (p *playbackEngine) UpdatePlayQueue(tracks []*mediaprovider.Track) error { - newQueue := p.copyTrackSliceToMediaItemSlice(tracks) +func (p *playbackEngine) UpdatePlayQueue(items []mediaprovider.MediaItem) error { + newQueue := p.deepCopyMediaItemSlice(items) newNowPlayingIdx := -1 if p.nowPlayingIdx >= 0 { nowPlayingID := p.playQueue[p.nowPlayingIdx].Metadata().ID @@ -548,7 +548,7 @@ func (p *playbackEngine) sendNowPlayingScrobble() { // creates a deep copy of the track info so that we can maintain our own state // (play count increases, favorite, and rating) without messing up other views' track models -func (p *playbackEngine) deepCopyTrackSlice(tracks []mediaprovider.MediaItem) []mediaprovider.MediaItem { +func (p *playbackEngine) deepCopyMediaItemSlice(tracks []mediaprovider.MediaItem) []mediaprovider.MediaItem { newTracks := make([]mediaprovider.MediaItem, len(tracks)) for i, tr := range tracks { newTracks[i] = tr.Copy() diff --git a/backend/playbackmanager.go b/backend/playbackmanager.go index 6c8e175..2581082 100644 --- a/backend/playbackmanager.go +++ b/backend/playbackmanager.go @@ -126,8 +126,8 @@ func (p *PlaybackManager) LoadTracks(tracks []*mediaprovider.Track, insertQueueM // Replaces the play queue with the given set of tracks. // Does not stop playback if the currently playing track is in the new queue, // but updates the now playing index to point to the first instance of the track in the new queue. -func (p *PlaybackManager) UpdatePlayQueue(tracks []*mediaprovider.Track) error { - return p.engine.UpdatePlayQueue(tracks) +func (p *PlaybackManager) UpdatePlayQueue(items []mediaprovider.MediaItem) error { + return p.engine.UpdatePlayQueue(items) } func (p *PlaybackManager) PlayAlbum(albumID string, firstTrack int, shuffle bool) error { diff --git a/sharedutil/sharedutil.go b/sharedutil/sharedutil.go index c407783..b549331 100644 --- a/sharedutil/sharedutil.go +++ b/sharedutil/sharedutil.go @@ -112,6 +112,9 @@ const ( MoveDown ) +// TODO: it's a shame the below function is just duplicated for a slice of mediaprovider.MediaItem +// Find out if there's a better way with refactoring. + // Reorder tracks and return a new track slice. // idxToMove must contain only valid indexes into tracks, and no repeats func ReorderTracks(tracks []*mediaprovider.Track, idxToMove []int, op TrackReorderOp) []*mediaprovider.Track { @@ -166,6 +169,60 @@ func ReorderTracks(tracks []*mediaprovider.Track, idxToMove []int, op TrackReord return newTracks } +// Reorder media items and return a new item slice. +// idxToMove must contain only valid indexes into items, and no repeats +func ReorderMediaItems(items []mediaprovider.MediaItem, idxToMove []int, op TrackReorderOp) []mediaprovider.MediaItem { + newTracks := make([]mediaprovider.MediaItem, len(items)) + switch op { + case MoveToTop: + topIdx := 0 + botIdx := len(idxToMove) + idxToMoveSet := ToSet(idxToMove) + for i, t := range items { + if _, ok := idxToMoveSet[i]; ok { + newTracks[topIdx] = t + topIdx++ + } else { + newTracks[botIdx] = t + botIdx++ + } + } + case MoveToBottom: + topIdx := 0 + botIdx := len(items) - len(idxToMove) + idxToMoveSet := ToSet(idxToMove) + for i, t := range items { + if _, ok := idxToMoveSet[i]; ok { + newTracks[botIdx] = t + botIdx++ + } else { + newTracks[topIdx] = t + topIdx++ + } + } + case MoveUp: + first := firstIdxCanMoveUp(idxToMove) + copy(newTracks, items) + for _, i := range idxToMove { + if i < first { + continue + } + newTracks[i-1], newTracks[i] = newTracks[i], newTracks[i-1] + } + case MoveDown: + last := lastIdxCanMoveDown(idxToMove, len(items)) + copy(newTracks, items) + for i := len(idxToMove) - 1; i >= 0; i-- { + idx := idxToMove[i] + if idx > last { + continue + } + newTracks[idx+1], newTracks[idx] = newTracks[idx], newTracks[idx+1] + } + } + return newTracks +} + func firstIdxCanMoveUp(idxs []int) int { prevIdx := -1 slices.Sort(idxs) diff --git a/ui/browsing/nowplayingpage.go b/ui/browsing/nowplayingpage.go index c182bf8..c0334b2 100644 --- a/ui/browsing/nowplayingpage.go +++ b/ui/browsing/nowplayingpage.go @@ -43,7 +43,7 @@ type NowPlayingPage struct { curRelatedID string // id of track currrently used to populate related list totalTime float64 lastPlayPos float64 - queue []*mediaprovider.Track + queue []mediaprovider.MediaItem related []*mediaprovider.Track lyricLock sync.Mutex @@ -122,7 +122,7 @@ func NewNowPlayingPage( a.queueList = widgets.NewPlayQueueList(a.im) a.relatedList = widgets.NewPlayQueueList(a.im) - a.queueList.OnReorderTracks = a.doSetNewTrackOrder + a.queueList.OnReorderItems = a.doSetNewTrackOrder a.queueList.OnDownload = contr.ShowDownloadDialog a.queueList.OnShare = func(tracks []*mediaprovider.Track) { if len(tracks) > 0 { @@ -401,12 +401,11 @@ func (a *NowPlayingPage) Reload() { a.relatedList.DisableRating = !a.canRate a.relatedList.DisableSharing = !a.canShare - // TODO - //a.queue = a.pm.GetPlayQueue() - a.queueList.SetTracks(a.queue) + a.queue = a.pm.GetPlayQueue() + a.queueList.SetItems(a.queue) a.totalTime = 0.0 for _, tr := range a.queue { - a.totalTime += float64(tr.Duration) + a.totalTime += float64(tr.Metadata().Duration) } a.formatStatusLine() } @@ -459,11 +458,11 @@ func (a *NowPlayingPage) doSetNewTrackOrder(trackIDs []string, op sharedutil.Tra trackIDSet := sharedutil.ToSet(trackIDs) idxs := make([]int, 0, len(trackIDs)) for i, tr := range a.queue { - if _, ok := trackIDSet[tr.ID]; ok { + if _, ok := trackIDSet[tr.Metadata().ID]; ok { idxs = append(idxs, i) } } - newTracks := sharedutil.ReorderTracks(a.queue, idxs, op) + newTracks := sharedutil.ReorderMediaItems(a.queue, idxs, op) a.pm.UpdatePlayQueue(newTracks) } diff --git a/ui/util/tracklistutil.go b/ui/util/tracklistutil.go index 0601329..d24e611 100644 --- a/ui/util/tracklistutil.go +++ b/ui/util/tracklistutil.go @@ -8,76 +8,82 @@ import ( ) type TrackListModel struct { - Track *mediaprovider.Track + Item mediaprovider.MediaItem Selected bool } +// Returns the item as a *mediaprovider.Track, or panics if not a Track +// Use for tracklists that can only contain tracks (ie not PlayQueueList) +func (t TrackListModel) Track() *mediaprovider.Track { + return t.Item.(*mediaprovider.Track) +} + func ToTrackListModels(trs []*mediaprovider.Track) []*TrackListModel { return sharedutil.MapSlice(trs, func(tr *mediaprovider.Track) *TrackListModel { - return &TrackListModel{Track: tr, Selected: false} + return &TrackListModel{Item: tr, Selected: false} }) } -func SelectedTracks(tracks []*TrackListModel) []*mediaprovider.Track { - return sharedutil.FilterMapSlice(tracks, func(tm *TrackListModel) (*mediaprovider.Track, bool) { - return tm.Track, tm.Selected +func SelectedTracks(items []*TrackListModel) []*mediaprovider.Track { + return sharedutil.FilterMapSlice(items, func(tm *TrackListModel) (*mediaprovider.Track, bool) { + return tm.Track(), tm.Selected }) } -func SelectedTrackIDs(tracks []*TrackListModel) []string { - return sharedutil.FilterMapSlice(tracks, func(tm *TrackListModel) (string, bool) { - return tm.Track.ID, tm.Selected +func SelectedItemIDs(items []*TrackListModel) []string { + return sharedutil.FilterMapSlice(items, func(tm *TrackListModel) (string, bool) { + return tm.Item.Metadata().ID, tm.Selected }) } -func SelectTrack(tracks []*TrackListModel, idx int) { - if tracks[idx].Selected { +func SelectItem(items []*TrackListModel, idx int) { + if items[idx].Selected { return } - UnselectAllTracks(tracks) - tracks[idx].Selected = true + UnselectAllItems(items) + items[idx].Selected = true } -func SelectAllTracks(tracks []*TrackListModel) { - for _, tm := range tracks { +func SelectAllItems(items []*TrackListModel) { + for _, tm := range items { tm.Selected = true } } -func UnselectAllTracks(tracks []*TrackListModel) { - for _, tm := range tracks { +func UnselectAllItems(items []*TrackListModel) { + for _, tm := range items { tm.Selected = false } } -func SelectTrackRange(tracks []*TrackListModel, idx int) { - if tracks[idx].Selected { +func SelectItemRange(items []*TrackListModel, idx int) { + if items[idx].Selected { return } lastSelected := -1 - for i := len(tracks) - 1; i >= 0; i-- { - if tracks[i].Selected { + for i := len(items) - 1; i >= 0; i-- { + if items[i].Selected { lastSelected = i break } } if lastSelected < 0 { - tracks[idx].Selected = true + items[idx].Selected = true return } from := min(idx, lastSelected) to := max(idx, lastSelected) for i := from; i <= to; i++ { - tracks[i].Selected = true + items[i].Selected = true } } -func FindTrackByID(tracks []*TrackListModel, id string) (*mediaprovider.Track, int) { - idx := slices.IndexFunc(tracks, func(tr *TrackListModel) bool { - return tr.Track.ID == id +func FindTrackByID(items []*TrackListModel, id string) (*mediaprovider.Track, int) { + idx := slices.IndexFunc(items, func(tr *TrackListModel) bool { + return tr.Item.Metadata().ID == id }) if idx >= 0 { - return tracks[idx].Track, idx + return items[idx].Track(), idx } return nil, -1 } diff --git a/ui/widgets/playqueuelist.go b/ui/widgets/playqueuelist.go index c402f21..d7ac04d 100644 --- a/ui/widgets/playqueuelist.go +++ b/ui/widgets/playqueuelist.go @@ -23,6 +23,11 @@ import ( const playQueueListThumbnailSize = 52 +type PlayQueueListModel struct { + Item mediaprovider.MediaItem + Selected bool +} + type PlayQueueList struct { widget.BaseWidget @@ -33,12 +38,12 @@ type PlayQueueList struct { OnAddToPlaylist func(trackIDs []string) OnSetFavorite func(trackIDs []string, fav bool) OnSetRating func(trackIDs []string, rating int) - OnRemoveFromQueue func(trackIDs []string) + OnRemoveFromQueue func(itemIDs []string) OnDownload func(tracks []*mediaprovider.Track, downloadName string) OnShare func(tracks []*mediaprovider.Track) OnShowArtistPage func(artistID string) OnPlayTrackAt func(idx int) - OnReorderTracks func(trackIDs []string, op sharedutil.TrackReorderOp) + OnReorderItems func(itemIDs []string, op sharedutil.TrackReorderOp) list *FocusList menu *widget.PopUpMenu @@ -49,7 +54,7 @@ type PlayQueueList struct { colLayout *layouts.ColumnsLayout tracksMutex sync.RWMutex - tracks []*util.TrackListModel + items []*util.TrackListModel } func NewPlayQueueList(im *backend.ImageManager) *PlayQueueList { @@ -78,16 +83,16 @@ func NewPlayQueueList(im *backend.ImageManager) *PlayQueueList { // we could have removed tracks from the list in between // Fyne calling the length callback and this update callback // so the itemID may be out of bounds. if so, do nothing. - if itemID >= len(p.tracks) { + if itemID >= len(p.items) { p.tracksMutex.RUnlock() return } - model := p.tracks[itemID] + model := p.items[itemID] p.tracksMutex.RUnlock() tr := item.(*PlayQueueListRow) p.list.SetItemForID(itemID, tr) - if tr.trackID != model.Track.ID || tr.ListItemID != itemID { + if tr.trackID != model.Item.Metadata().ID || tr.ListItemID != itemID { tr.ListItemID = itemID } tr.Update(model, itemID+1) @@ -100,7 +105,17 @@ func NewPlayQueueList(im *backend.ImageManager) *PlayQueueList { func (p *PlayQueueList) SetTracks(trs []*mediaprovider.Track) { p.tracksMutex.Lock() p.list.ClearItemForIDMap() - p.tracks = util.ToTrackListModels(trs) + p.items = util.ToTrackListModels(trs) + p.tracksMutex.Unlock() + p.Refresh() +} + +func (p *PlayQueueList) SetItems(items []mediaprovider.MediaItem) { + p.tracksMutex.Lock() + p.list.ClearItemForIDMap() + p.items = sharedutil.MapSlice(items, func(item mediaprovider.MediaItem) *util.TrackListModel { + return &util.TrackListModel{Item: item} + }) p.tracksMutex.Unlock() p.Refresh() } @@ -109,8 +124,8 @@ func (p *PlayQueueList) SetTracks(trs []*mediaprovider.Track) { func (p *PlayQueueList) SetNowPlaying(trackID string) { prevNowPlaying := p.nowPlayingID p.tracksMutex.RLock() - trPrev, idxPrev := util.FindTrackByID(p.tracks, prevNowPlaying) - tr, idx := util.FindTrackByID(p.tracks, trackID) + trPrev, idxPrev := util.FindTrackByID(p.items, prevNowPlaying) + tr, idx := util.FindTrackByID(p.items, trackID) p.tracksMutex.RUnlock() p.nowPlayingID = trackID if trPrev != nil { @@ -123,14 +138,14 @@ func (p *PlayQueueList) SetNowPlaying(trackID string) { func (p *PlayQueueList) SelectAll() { p.tracksMutex.RLock() - util.SelectAllTracks(p.tracks) + util.SelectAllItems(p.items) p.tracksMutex.RUnlock() p.list.Refresh() } func (p *PlayQueueList) UnselectAll() { p.tracksMutex.RLock() - util.UnselectAllTracks(p.tracks) + util.UnselectAllItems(p.items) p.tracksMutex.RUnlock() p.Refresh() } @@ -138,7 +153,7 @@ func (p *PlayQueueList) UnselectAll() { func (p *PlayQueueList) lenTracks() int { p.tracksMutex.RLock() defer p.tracksMutex.RUnlock() - return len(p.tracks) + return len(p.items) } func (t *PlayQueueList) onArtistTapped(artistID string) { @@ -172,19 +187,19 @@ func (p *PlayQueueList) onSelectTrack(idx int) { func (p *PlayQueueList) selectTrack(idx int) { p.tracksMutex.RLock() defer p.tracksMutex.RUnlock() - util.SelectTrack(p.tracks, idx) + util.SelectItem(p.items, idx) } func (p *PlayQueueList) selectAddOrRemove(idx int) { p.tracksMutex.RLock() defer p.tracksMutex.RUnlock() - p.tracks[idx].Selected = !p.tracks[idx].Selected + p.items[idx].Selected = !p.items[idx].Selected } func (p *PlayQueueList) selectRange(idx int) { p.tracksMutex.RLock() defer p.tracksMutex.RUnlock() - util.SelectTrackRange(p.tracks, idx) + util.SelectItemRange(p.items, idx) } func (p *PlayQueueList) onShowContextMenu(e *fyne.PointEvent, trackIdx int) { @@ -233,8 +248,8 @@ func (p *PlayQueueList) onShowContextMenu(e *fyne.PointEvent, trackIdx int) { }) remove.Icon = theme.ContentRemoveIcon() reorder := util.NewReorderTracksSubmenu(func(tro sharedutil.TrackReorderOp) { - if p.OnReorderTracks != nil { - p.OnReorderTracks(p.selectedTrackIDs(), tro) + if p.OnReorderItems != nil { + p.OnReorderItems(p.selectedTrackIDs(), tro) } }) @@ -262,13 +277,13 @@ func (p *PlayQueueList) onShowContextMenu(e *fyne.PointEvent, trackIdx int) { func (t *PlayQueueList) selectedTracks() []*mediaprovider.Track { t.tracksMutex.RLock() defer t.tracksMutex.RUnlock() - return util.SelectedTracks(t.tracks) + return util.SelectedTracks(t.items) } func (t *PlayQueueList) selectedTrackIDs() []string { t.tracksMutex.RLock() defer t.tracksMutex.RUnlock() - return util.SelectedTrackIDs(t.tracks) + return util.SelectedItemIDs(t.items) } func (p *PlayQueueList) CreateRenderer() fyne.WidgetRenderer { @@ -355,19 +370,19 @@ func (p *PlayQueueListRow) Update(tm *util.TrackListModel, rowNum int) { // Update info that can change if this row is bound to // a new track (*mediaprovider.Track) - tr := tm.Track - if tr.ID != p.trackID { - p.imageLoader.Load(tm.Track.CoverArtID) + meta := tm.Item.Metadata() + if meta.ID != p.trackID { + p.imageLoader.Load(meta.CoverArtID) p.EnsureUnfocused() - p.trackID = tr.ID - p.title.Text = tr.Title - p.artist.BuildSegments(tr.ArtistNames, tr.ArtistIDs) - p.time.Text = util.SecondsToTimeString(float64(tr.Duration)) + p.trackID = meta.ID + p.title.Text = meta.Name + p.artist.BuildSegments(meta.Artists, meta.ArtistIDs) + p.time.Text = util.SecondsToTimeString(float64(meta.Duration)) changed = true } // Render whether track is playing or not - if isPlaying := p.playQueueList.nowPlayingID == tr.ID; isPlaying != p.isPlaying { + if isPlaying := p.playQueueList.nowPlayingID == meta.ID; isPlaying != p.isPlaying { p.isPlaying = isPlaying p.title.TextStyle.Bold = isPlaying diff --git a/ui/widgets/tracklist.go b/ui/widgets/tracklist.go index d5f5d43..0957799 100644 --- a/ui/widgets/tracklist.go +++ b/ui/widgets/tracklist.go @@ -171,7 +171,7 @@ func NewTracklist(tracks []*mediaprovider.Track, im *backend.ImageManager, useCo tr := item.(TracklistRow) t.list.SetItemForID(itemID, tr) - if tr.TrackID() != model.Track.ID || tr.ItemID() != itemID { + if tr.TrackID() != model.Item.Metadata().ID || tr.ItemID() != itemID { tr.SetItemID(itemID) } i := -1 // signal that we want to display the actual track num. @@ -206,7 +206,7 @@ func (t *Tracklist) TrackAt(idx int) *mediaprovider.Track { log.Println("error: Tracklist.TrackAt: index out of range") return nil } - return t.tracks[idx].Track + return t.tracks[idx].Track() } func (t *Tracklist) SetVisibleColumns(cols []string) { @@ -324,7 +324,7 @@ func (t *Tracklist) GetTracks() []*mediaprovider.Track { t.tracksMutex.RLock() defer t.tracksMutex.RUnlock() return sharedutil.MapSlice(t.tracks, func(tm *util.TrackListModel) *mediaprovider.Track { - return tm.Track + return tm.Track() }) } @@ -338,7 +338,7 @@ func (t *Tracklist) AppendTracks(trs []*mediaprovider.Track) { func (t *Tracklist) SelectAll() { t.tracksMutex.RLock() - util.SelectAllTracks(t.tracks) + util.SelectAllItems(t.tracks) t.tracksMutex.RUnlock() t.list.Refresh() } @@ -350,7 +350,7 @@ func (t *Tracklist) UnselectAll() { func (t *Tracklist) unselectAll() { t.tracksMutex.RLock() - util.UnselectAllTracks(t.tracks) + util.UnselectAllItems(t.tracks) t.tracksMutex.RUnlock() } @@ -358,7 +358,7 @@ func (t *Tracklist) SelectAndScrollToTrack(trackID string) { t.tracksMutex.RLock() idx := -1 for i, tr := range t.tracks { - if tr.Track.ID == trackID { + if tr.Item.Metadata().ID == trackID { idx = i tr.Selected = true } else { @@ -422,30 +422,30 @@ func (t *Tracklist) doSortTracks() { t.tracks = t.tracksOrigOrder } case ColumnTitle: - t.stringSort(func(tr *util.TrackListModel) string { return tr.Track.Title }) + t.stringSort(func(tr *util.TrackListModel) string { return tr.Track().Title }) case ColumnArtist: - t.stringSort(func(tr *util.TrackListModel) string { return strings.Join(tr.Track.ArtistNames, ", ") }) + t.stringSort(func(tr *util.TrackListModel) string { return strings.Join(tr.Track().ArtistNames, ", ") }) case ColumnAlbum: - t.stringSort(func(tr *util.TrackListModel) string { return tr.Track.Album }) + t.stringSort(func(tr *util.TrackListModel) string { return tr.Track().Album }) case ColumnPath: - t.stringSort(func(tr *util.TrackListModel) string { return tr.Track.FilePath }) + t.stringSort(func(tr *util.TrackListModel) string { return tr.Track().FilePath }) case ColumnRating: - t.intSort(func(tr *util.TrackListModel) int64 { return int64(tr.Track.Rating) }) + t.intSort(func(tr *util.TrackListModel) int64 { return int64(tr.Track().Rating) }) case ColumnTime: - t.intSort(func(tr *util.TrackListModel) int64 { return int64(tr.Track.Duration) }) + t.intSort(func(tr *util.TrackListModel) int64 { return int64(tr.Track().Duration) }) case ColumnYear: - t.intSort(func(tr *util.TrackListModel) int64 { return int64(tr.Track.Year) }) + t.intSort(func(tr *util.TrackListModel) int64 { return int64(tr.Track().Year) }) case ColumnSize: - t.intSort(func(tr *util.TrackListModel) int64 { return tr.Track.Size }) + t.intSort(func(tr *util.TrackListModel) int64 { return tr.Track().Size }) case ColumnPlays: - t.intSort(func(tr *util.TrackListModel) int64 { return int64(tr.Track.PlayCount) }) + t.intSort(func(tr *util.TrackListModel) int64 { return int64(tr.Track().PlayCount) }) case ColumnComment: - t.stringSort(func(tr *util.TrackListModel) string { return tr.Track.Comment }) + t.stringSort(func(tr *util.TrackListModel) string { return tr.Track().Comment }) case ColumnBitrate: - t.intSort(func(tr *util.TrackListModel) int64 { return int64(tr.Track.BitRate) }) + t.intSort(func(tr *util.TrackListModel) int64 { return int64(tr.Track().BitRate) }) case ColumnFavorite: t.intSort(func(tr *util.TrackListModel) int64 { - if tr.Track.Favorite { + if tr.Track().Favorite { return 1 } return 0 @@ -492,13 +492,13 @@ func (t *Tracklist) selectAddOrRemove(idx int) { func (t *Tracklist) selectTrack(idx int) { t.tracksMutex.RLock() defer t.tracksMutex.RUnlock() - util.SelectTrack(t.tracks, idx) + util.SelectItem(t.tracks, idx) } func (t *Tracklist) selectRange(idx int) { t.tracksMutex.RLock() defer t.tracksMutex.RUnlock() - util.SelectTrackRange(t.tracks, idx) + util.SelectItemRange(t.tracks, idx) } func (t *Tracklist) onShowContextMenu(e *fyne.PointEvent, trackIdx int) { @@ -663,7 +663,7 @@ func (t *Tracklist) selectedTracks() []*mediaprovider.Track { func (t *Tracklist) SelectedTrackIDs() []string { t.tracksMutex.RLock() defer t.tracksMutex.RUnlock() - return util.SelectedTrackIDs(t.tracks) + return util.SelectedItemIDs(t.tracks) } func (t *Tracklist) lenTracks() int { diff --git a/ui/widgets/tracklistrow.go b/ui/widgets/tracklistrow.go index 0e9bf0f..394d0bd 100644 --- a/ui/widgets/tracklistrow.go +++ b/ui/widgets/tracklistrow.go @@ -175,8 +175,8 @@ func NewExpandedTracklistRow(tracklist *Tracklist, im *backend.ImageManager, pla } func (t *ExpandedTracklistRow) Update(tm *util.TrackListModel, rowNum int) { - if t.trackID != tm.Track.ID { - t.imageLoader.Load(tm.Track.CoverArtID) + if t.trackID != tm.Track().ID { + t.imageLoader.Load(tm.Track().CoverArtID) } t.tracklistRowBase.Update(tm, rowNum) } @@ -256,10 +256,10 @@ func (t *tracklistRowBase) Update(tm *util.TrackListModel, rowNum int) { // Update info that can change if this row is bound to // a new track (*mediaprovider.Track) - tr := tm.Track - if tr.ID != t.trackID { + tr := tm.Track() + if id := tr.ID; id != t.trackID { t.EnsureUnfocused() - t.trackID = tr.ID + t.trackID = id t.name.Segments[0].(*widget.TextSegment).Text = tr.Title t.artist.BuildSegments(tr.ArtistNames, tr.ArtistIDs) From 3d6bd910f20029da913035f775d7e5c160952af2 Mon Sep 17 00:00:00 2001 From: Drew Weymouth Date: Sat, 1 Jun 2024 10:32:52 -0700 Subject: [PATCH 05/25] fix nil interface bug --- backend/mediaprovider/model.go | 5 +++-- sharedutil/sharedutil.go | 9 ++++++--- 2 files changed, 9 insertions(+), 5 deletions(-) diff --git a/backend/mediaprovider/model.go b/backend/mediaprovider/model.go index 9e7c896..3367512 100644 --- a/backend/mediaprovider/model.go +++ b/backend/mediaprovider/model.go @@ -170,7 +170,7 @@ type MediaItem interface { Copy() MediaItem } -func (t Track) Metadata() MediaItemMetadata { +func (t *Track) Metadata() MediaItemMetadata { return MediaItemMetadata{ Type: MediaItemTypeTrack, ID: t.ID, @@ -183,12 +183,13 @@ func (t Track) Metadata() MediaItemMetadata { Duration: t.Duration, } } + func (t *Track) Copy() MediaItem { new := *t return &new } -func (r RadioStation) Metadata() MediaItemMetadata { +func (r *RadioStation) Metadata() MediaItemMetadata { return MediaItemMetadata{ Type: MediaItemTypeRadioStation, ID: r.ID, diff --git a/sharedutil/sharedutil.go b/sharedutil/sharedutil.go index b549331..00bcfb7 100644 --- a/sharedutil/sharedutil.go +++ b/sharedutil/sharedutil.go @@ -84,10 +84,13 @@ func FindMediaItemByID(id string, items []mediaprovider.MediaItem) mediaprovider } func MediaItemIDOrEmptyStr(item mediaprovider.MediaItem) string { - if item == nil { - return "" + if tr, ok := item.(*mediaprovider.Track); ok && tr != nil { + return tr.ID } - return item.Metadata().ID + if rd, ok := item.(*mediaprovider.RadioStation); ok && rd != nil { + return rd.ID + } + return "" } func AlbumIDOrEmptyStr(track *mediaprovider.Track) string { From efcfe2a99f84abf9610c958acdc13d27a5443669 Mon Sep 17 00:00:00 2001 From: Drew Weymouth Date: Sat, 1 Jun 2024 10:42:31 -0700 Subject: [PATCH 06/25] refactor: make ReorderItems generic --- sharedutil/sharedutil.go | 81 ++++++----------------------------- sharedutil/sharedutil_test.go | 10 ++--- ui/browsing/nowplayingpage.go | 2 +- ui/browsing/playlistpage.go | 2 +- 4 files changed, 19 insertions(+), 76 deletions(-) diff --git a/sharedutil/sharedutil.go b/sharedutil/sharedutil.go index 00bcfb7..bad0e27 100644 --- a/sharedutil/sharedutil.go +++ b/sharedutil/sharedutil.go @@ -115,67 +115,10 @@ const ( MoveDown ) -// TODO: it's a shame the below function is just duplicated for a slice of mediaprovider.MediaItem -// Find out if there's a better way with refactoring. - -// Reorder tracks and return a new track slice. +// Reorder items and return a new track slice. // idxToMove must contain only valid indexes into tracks, and no repeats -func ReorderTracks(tracks []*mediaprovider.Track, idxToMove []int, op TrackReorderOp) []*mediaprovider.Track { - newTracks := make([]*mediaprovider.Track, len(tracks)) - switch op { - case MoveToTop: - topIdx := 0 - botIdx := len(idxToMove) - idxToMoveSet := ToSet(idxToMove) - for i, t := range tracks { - if _, ok := idxToMoveSet[i]; ok { - newTracks[topIdx] = t - topIdx++ - } else { - newTracks[botIdx] = t - botIdx++ - } - } - case MoveToBottom: - topIdx := 0 - botIdx := len(tracks) - len(idxToMove) - idxToMoveSet := ToSet(idxToMove) - for i, t := range tracks { - if _, ok := idxToMoveSet[i]; ok { - newTracks[botIdx] = t - botIdx++ - } else { - newTracks[topIdx] = t - topIdx++ - } - } - case MoveUp: - first := firstIdxCanMoveUp(idxToMove) - copy(newTracks, tracks) - for _, i := range idxToMove { - if i < first { - continue - } - newTracks[i-1], newTracks[i] = newTracks[i], newTracks[i-1] - } - case MoveDown: - last := lastIdxCanMoveDown(idxToMove, len(tracks)) - copy(newTracks, tracks) - for i := len(idxToMove) - 1; i >= 0; i-- { - idx := idxToMove[i] - if idx > last { - continue - } - newTracks[idx+1], newTracks[idx] = newTracks[idx], newTracks[idx+1] - } - } - return newTracks -} - -// Reorder media items and return a new item slice. -// idxToMove must contain only valid indexes into items, and no repeats -func ReorderMediaItems(items []mediaprovider.MediaItem, idxToMove []int, op TrackReorderOp) []mediaprovider.MediaItem { - newTracks := make([]mediaprovider.MediaItem, len(items)) +func ReorderItems[T any](items []T, idxToMove []int, op TrackReorderOp) []T { + newItems := make([]T, len(items)) switch op { case MoveToTop: topIdx := 0 @@ -183,10 +126,10 @@ func ReorderMediaItems(items []mediaprovider.MediaItem, idxToMove []int, op Trac idxToMoveSet := ToSet(idxToMove) for i, t := range items { if _, ok := idxToMoveSet[i]; ok { - newTracks[topIdx] = t + newItems[topIdx] = t topIdx++ } else { - newTracks[botIdx] = t + newItems[botIdx] = t botIdx++ } } @@ -196,34 +139,34 @@ func ReorderMediaItems(items []mediaprovider.MediaItem, idxToMove []int, op Trac idxToMoveSet := ToSet(idxToMove) for i, t := range items { if _, ok := idxToMoveSet[i]; ok { - newTracks[botIdx] = t + newItems[botIdx] = t botIdx++ } else { - newTracks[topIdx] = t + newItems[topIdx] = t topIdx++ } } case MoveUp: first := firstIdxCanMoveUp(idxToMove) - copy(newTracks, items) + copy(newItems, items) for _, i := range idxToMove { if i < first { continue } - newTracks[i-1], newTracks[i] = newTracks[i], newTracks[i-1] + newItems[i-1], newItems[i] = newItems[i], newItems[i-1] } case MoveDown: last := lastIdxCanMoveDown(idxToMove, len(items)) - copy(newTracks, items) + copy(newItems, items) for i := len(idxToMove) - 1; i >= 0; i-- { idx := idxToMove[i] if idx > last { continue } - newTracks[idx+1], newTracks[idx] = newTracks[idx], newTracks[idx+1] + newItems[idx+1], newItems[idx] = newItems[idx], newItems[idx+1] } } - return newTracks + return newItems } func firstIdxCanMoveUp(idxs []int) int { diff --git a/sharedutil/sharedutil_test.go b/sharedutil/sharedutil_test.go index 05b5312..4d395e4 100644 --- a/sharedutil/sharedutil_test.go +++ b/sharedutil/sharedutil_test.go @@ -7,7 +7,7 @@ import ( "github.com/dweymouth/supersonic/backend/mediaprovider" ) -func Test_ReorderTracks(t *testing.T) { +func Test_ReorderItems(t *testing.T) { tracks := []*mediaprovider.Track{ {ID: "a"}, // 0 {ID: "b"}, // 1 @@ -27,7 +27,7 @@ func Test_ReorderTracks(t *testing.T) { {ID: "b"}, {ID: "e"}, } - newTracks := ReorderTracks(tracks, idxToMove, MoveToTop) + newTracks := ReorderItems(tracks, idxToMove, MoveToTop) if !tracklistsEqual(t, newTracks, want) { t.Error("ReorderTracks: MoveToTop order incorrect") } @@ -42,7 +42,7 @@ func Test_ReorderTracks(t *testing.T) { {ID: "c"}, {ID: "f"}, } - newTracks = ReorderTracks(tracks, idxToMove, MoveToBottom) + newTracks = ReorderItems(tracks, idxToMove, MoveToBottom) if !tracklistsEqual(t, newTracks, want) { t.Error("ReorderTracks: MoveToBottom order incorrect") } @@ -57,7 +57,7 @@ func Test_ReorderTracks(t *testing.T) { {ID: "f"}, {ID: "e"}, } - newTracks = ReorderTracks(tracks, idxToMove, MoveUp) + newTracks = ReorderItems(tracks, idxToMove, MoveUp) if !tracklistsEqual(t, newTracks, want) { t.Error("ReorderTracks: MoveUp order incorrect") } @@ -72,7 +72,7 @@ func Test_ReorderTracks(t *testing.T) { {ID: "e"}, {ID: "f"}, } - newTracks = ReorderTracks(tracks, idxToMove, MoveDown) + newTracks = ReorderItems(tracks, idxToMove, MoveDown) if !tracklistsEqual(t, newTracks, want) { t.Error("ReorderTracks: MoveDown order incorrect") } diff --git a/ui/browsing/nowplayingpage.go b/ui/browsing/nowplayingpage.go index c0334b2..6c7401f 100644 --- a/ui/browsing/nowplayingpage.go +++ b/ui/browsing/nowplayingpage.go @@ -462,7 +462,7 @@ func (a *NowPlayingPage) doSetNewTrackOrder(trackIDs []string, op sharedutil.Tra idxs = append(idxs, i) } } - newTracks := sharedutil.ReorderMediaItems(a.queue, idxs, op) + newTracks := sharedutil.ReorderItems(a.queue, idxs, op) a.pm.UpdatePlayQueue(newTracks) } diff --git a/ui/browsing/playlistpage.go b/ui/browsing/playlistpage.go index 4044fff..c7dc245 100644 --- a/ui/browsing/playlistpage.go +++ b/ui/browsing/playlistpage.go @@ -187,7 +187,7 @@ func (a *PlaylistPage) doSetNewTrackOrder(op sharedutil.TrackReorderOp) { idxs = append(idxs, i) } } - newTracks := sharedutil.ReorderTracks(a.tracks, idxs, op) + newTracks := sharedutil.ReorderItems(a.tracks, idxs, op) ids := sharedutil.TracksToIDs(newTracks) if err := a.sm.Server.ReplacePlaylistTracks(a.playlistID, ids); err != nil { log.Printf("error updating playlist: %s", err.Error()) From a88d29887a109fcf49b8729c96c051cd52aec519 Mon Sep 17 00:00:00 2001 From: Drew Weymouth Date: Sat, 1 Jun 2024 10:49:26 -0700 Subject: [PATCH 07/25] defensive programming to mitigate nil dereference --- backend/mediaprovider/model.go | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/backend/mediaprovider/model.go b/backend/mediaprovider/model.go index 3367512..51c7525 100644 --- a/backend/mediaprovider/model.go +++ b/backend/mediaprovider/model.go @@ -171,6 +171,9 @@ type MediaItem interface { } func (t *Track) Metadata() MediaItemMetadata { + if t == nil { + return MediaItemMetadata{} + } return MediaItemMetadata{ Type: MediaItemTypeTrack, ID: t.ID, @@ -190,6 +193,9 @@ func (t *Track) Copy() MediaItem { } func (r *RadioStation) Metadata() MediaItemMetadata { + if r == nil { + return MediaItemMetadata{} + } return MediaItemMetadata{ Type: MediaItemTypeRadioStation, ID: r.ID, From 0c004baadd6f166118ff48af471f28a881e2c078 Mon Sep 17 00:00:00 2001 From: Drew Weymouth Date: Sat, 1 Jun 2024 11:18:22 -0700 Subject: [PATCH 08/25] start work on radios page --- ui/browsing/radiospage.go | 237 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 237 insertions(+) create mode 100644 ui/browsing/radiospage.go diff --git a/ui/browsing/radiospage.go b/ui/browsing/radiospage.go new file mode 100644 index 0000000..af313f1 --- /dev/null +++ b/ui/browsing/radiospage.go @@ -0,0 +1,237 @@ +package browsing + +import ( + "log" + "net/url" + "strings" + + "github.com/dweymouth/supersonic/backend/mediaprovider" + "github.com/dweymouth/supersonic/sharedutil" + "github.com/dweymouth/supersonic/ui/controller" + "github.com/dweymouth/supersonic/ui/layouts" + "github.com/dweymouth/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" +) + +var _ fyne.Widget = (*ArtistPage)(nil) + +type RadiosPage struct { + widget.BaseWidget + + contr *controller.Controller + rp mediaprovider.RadioProvider + radios []*mediaprovider.RadioStation + list *RadioList + + titleDisp *widget.RichText + container *fyne.Container + searcher *widgets.SearchEntry +} + +func NewRadiosPage(contr *controller.Controller, rp mediaprovider.RadioProvider) *RadiosPage { + return newRadiosPage(contr, rp, "", 0) +} + +func newRadiosPage(contr *controller.Controller, rp mediaprovider.RadioProvider, searchText string, scrollPos float32) *RadiosPage { + a := &RadiosPage{ + contr: contr, + rp: rp, + titleDisp: widget.NewRichTextWithText("Internet Radio Stations"), + } + a.ExtendBaseWidget(a) + a.titleDisp.Segments[0].(*widget.TextSegment).Style.SizeName = theme.SizeNameHeadingText + a.list = NewRadioList() + a.searcher = widgets.NewSearchEntry() + a.searcher.PlaceHolder = "Search page" + a.searcher.OnSearched = a.onSearched + a.searcher.Entry.Text = searchText + a.buildContainer() + go a.load(searchText != "", scrollPos) + return a +} + +// should be called asynchronously +func (a *RadiosPage) load(searchOnLoad bool, scrollPos float32) { + radios, err := a.rp.GetRadioStations() + if err != nil { + log.Printf("error loading radios: %v", err.Error()) + } + a.radios = radios + if searchOnLoad { + a.onSearched(a.searcher.Entry.Text) + if scrollPos != 0 { + a.list.list.ScrollToOffset(scrollPos) + } + } else { + a.list.SetRadios(a.radios) + if scrollPos != 0 { + a.list.list.ScrollToOffset(scrollPos) + return + } + a.list.Refresh() + } +} + +func (a *RadiosPage) onSearched(query string) { + // since the radios list is returned in full non-paginated, we will do our own + // simple search based on the radio name, rather than calling a server API + if query == "" { + a.list.SetRadios(a.radios) + } else { + query = strings.ToLower(query) + result := sharedutil.FilterSlice(a.radios, func(x *mediaprovider.RadioStation) bool { + return strings.Contains(strings.ToLower(x.Name), query) + }) + a.list.SetRadios(result) + } + a.list.list.ScrollTo(0) +} + +var _ Searchable = (*RadiosPage)(nil) + +func (a *RadiosPage) SearchWidget() fyne.Focusable { + return a.searcher +} + +var _ Scrollable = (*RadiosPage)(nil) + +func (a *RadiosPage) Scroll(amount float32) { + a.list.list.ScrollToOffset(a.list.list.GetScrollOffset() + amount) +} + +func (a *RadiosPage) Route() controller.Route { + // TODO + //return controller.RadiosRoute() + return controller.Route{} +} + +func (a *RadiosPage) Reload() { + go a.load(false, 0) +} + +func (a *RadiosPage) Save() SavedPage { + return &savedrRadiosPage{ + contr: a.contr, + rp: a.rp, + searchText: a.searcher.Entry.Text, + scrollPos: a.list.list.GetScrollOffset(), + } +} + +type savedrRadiosPage struct { + contr *controller.Controller + rp mediaprovider.RadioProvider + searchText string + scrollPos float32 +} + +func (s *savedrRadiosPage) Restore() Page { + return newRadiosPage(s.contr, s.rp, s.searchText, s.scrollPos) +} + +func (a *RadiosPage) buildContainer() { + searchVbox := container.NewVBox(layout.NewSpacer(), a.searcher, layout.NewSpacer()) + a.container = container.New(&layout.CustomPaddedLayout{LeftPadding: 15, RightPadding: 15, TopPadding: 5, BottomPadding: 15}, + container.NewBorder( + container.New(&layout.CustomPaddedLayout{LeftPadding: -5}, + container.NewHBox(a.titleDisp, layout.NewSpacer(), searchVbox)), + nil, nil, nil, a.list)) +} + +func (a *RadiosPage) CreateRenderer() fyne.WidgetRenderer { + return widget.NewSimpleRenderer(a.container) +} + +type RadioList struct { + widget.BaseWidget + + OnPlay func(string) + + radios []*mediaprovider.RadioStation + + columnsLayout *layouts.ColumnsLayout + hdr *widgets.ListHeader + list *widgets.FocusList + container *fyne.Container +} + +type RadioListRow struct { + widgets.FocusListRowBase + + Item *mediaprovider.RadioStation + + nameLabel *widget.Label + homePageLink *widget.Hyperlink +} + +func NewRadioListRow(layout *layouts.ColumnsLayout) *RadioListRow { + a := &RadioListRow{ + nameLabel: widget.NewLabel(""), + homePageLink: widget.NewHyperlink("", nil), + } + a.ExtendBaseWidget(a) + a.nameLabel.Truncation = fyne.TextTruncateEllipsis + a.homePageLink.Truncation = fyne.TextTruncateEllipsis + a.Content = container.New(layout, a.nameLabel, a.homePageLink) + return a +} + +func NewRadioList() *RadioList { + a := &RadioList{ + columnsLayout: layouts.NewColumnsLayout([]float32{-1, 125, 125}), + } + a.ExtendBaseWidget(a) + a.hdr = widgets.NewListHeader([]widgets.ListColumn{ + {Text: "Name", Alignment: fyne.TextAlignLeading, CanToggleVisible: false}, + {Text: "Home Page", Alignment: fyne.TextAlignTrailing, CanToggleVisible: false}}, + a.columnsLayout) + a.hdr.DisableSorting = true + a.list = widgets.NewFocusList( + func() int { return len(a.radios) }, + func() fyne.CanvasObject { + r := NewRadioListRow(a.columnsLayout) + r.OnDoubleTapped = func() { a.onPlayRadio(r.Item) } + r.OnFocusNeighbor = func(up bool) { + a.list.FocusNeighbor(r.ItemID(), up) + } + return r + }, + func(id widget.ListItemID, item fyne.CanvasObject) { + row := item.(*RadioListRow) + a.list.SetItemForID(id, row) + if row.Item != a.radios[id] { + row.EnsureUnfocused() + row.ListItemID = id + row.Item = a.radios[id] + row.nameLabel.Text = row.Item.Name + row.homePageLink.Text = row.Item.HomePageURL + if u, err := url.Parse(row.Item.HomePageURL); err == nil { + row.homePageLink.URL = u + } + row.Refresh() + } + }, + ) + a.container = container.NewBorder(a.hdr, nil, nil, nil, a.list) + return a +} + +func (g *RadioList) SetRadios(radios []*mediaprovider.RadioStation) { + g.radios = radios + g.Refresh() +} + +func (a *RadioList) onPlayRadio(item *mediaprovider.RadioStation) { + if a.OnPlay != nil { + a.OnPlay(item.ID) + } +} + +func (a *RadioList) CreateRenderer() fyne.WidgetRenderer { + return widget.NewSimpleRenderer(a.container) +} From 77d21717f6c11d6a22bdc20a6e5bc9374b1fa448 Mon Sep 17 00:00:00 2001 From: Drew Weymouth Date: Sat, 1 Jun 2024 11:20:13 -0700 Subject: [PATCH 09/25] add missed files in previous commit --- backend/mediaprovider/mediaprovider.go | 4 ++++ .../subsonic/subsonicmediaprovider.go | 19 +++++++++++++++++++ 2 files changed, 23 insertions(+) diff --git a/backend/mediaprovider/mediaprovider.go b/backend/mediaprovider/mediaprovider.go index 648a363..c3d97d7 100644 --- a/backend/mediaprovider/mediaprovider.go +++ b/backend/mediaprovider/mediaprovider.go @@ -267,6 +267,10 @@ type LyricsProvider interface { GetLyrics(track *Track) (*Lyrics, error) } +type RadioProvider interface { + GetRadioStations() ([]*RadioStation, error) +} + type JukeboxProvider interface { JukeboxStart() error JukeboxStop() error diff --git a/backend/mediaprovider/subsonic/subsonicmediaprovider.go b/backend/mediaprovider/subsonic/subsonicmediaprovider.go index bddffea..bbe7dcd 100644 --- a/backend/mediaprovider/subsonic/subsonicmediaprovider.go +++ b/backend/mediaprovider/subsonic/subsonicmediaprovider.go @@ -386,6 +386,7 @@ func (s *subsonicMediaProvider) GetLyrics(track *mediaprovider.Track) (*mediapro } // CanSavePlayQueue interface +var _ mediaprovider.CanSavePlayQueue = (*subsonicMediaProvider)(nil) func (s *subsonicMediaProvider) SavePlayQueue(trackIDs []string, currentTrackIdx int, timeSeconds int) error { if len(trackIDs) == 0 { @@ -413,6 +414,24 @@ func (s *subsonicMediaProvider) GetPlayQueue() (*mediaprovider.SavedPlayQueue, e return savedQueue, nil } +// RadioProvider interface +var _ mediaprovider.RadioProvider = (*subsonicMediaProvider)(nil) + +func (s *subsonicMediaProvider) GetRadioStations() ([]*mediaprovider.RadioStation, error) { + rs, err := s.client.GetInternetRadioStations() + if err != nil { + return nil, err + } + return sharedutil.MapSlice(rs, func(rs *subsonic.InternetRadioStation) *mediaprovider.RadioStation { + return &mediaprovider.RadioStation{ + ID: "radio-" + strings.ReplaceAll(rs.Name, " ", ""), + Name: rs.Name, + HomePageURL: rs.HomePageUrl, + StreamURL: rs.StreamUrl, + } + }), nil +} + func toTrack(ch *subsonic.Child) *mediaprovider.Track { if ch == nil { return nil From bfa3dc9892769c44d11e40e21e3082d7d9cdd4bc Mon Sep 17 00:00:00 2001 From: Drew Weymouth Date: Sat, 1 Jun 2024 12:18:28 -0700 Subject: [PATCH 10/25] more progress on radios --- .../subsonic/subsonicmediaprovider.go | 1 + backend/playbackengine.go | 25 +++++++++++++++++++ backend/playbackmanager.go | 4 +++ ui/browsing/browsingpane.go | 6 +++-- ui/browsing/radiospage.go | 25 ++++++++++++------- ui/browsing/router.go | 4 +++ ui/controller/routes.go | 5 ++++ ui/mainwindow.go | 11 ++++++++ 8 files changed, 70 insertions(+), 11 deletions(-) diff --git a/backend/mediaprovider/subsonic/subsonicmediaprovider.go b/backend/mediaprovider/subsonic/subsonicmediaprovider.go index bbe7dcd..fbef25c 100644 --- a/backend/mediaprovider/subsonic/subsonicmediaprovider.go +++ b/backend/mediaprovider/subsonic/subsonicmediaprovider.go @@ -424,6 +424,7 @@ func (s *subsonicMediaProvider) GetRadioStations() ([]*mediaprovider.RadioStatio } return sharedutil.MapSlice(rs, func(rs *subsonic.InternetRadioStation) *mediaprovider.RadioStation { return &mediaprovider.RadioStation{ + // TODO - subsonic library is missing ID in its radiostation object. add it ID: "radio-" + strings.ReplaceAll(rs.Name, " ", ""), Name: rs.Name, HomePageURL: rs.HomePageUrl, diff --git a/backend/playbackengine.go b/backend/playbackengine.go index f4589eb..91154e4 100644 --- a/backend/playbackengine.go +++ b/backend/playbackengine.go @@ -235,6 +235,31 @@ func (p *playbackEngine) LoadTracks(tracks []*mediaprovider.Track, insertQueueMo return nil } +func (p *playbackEngine) LoadRadioStation(radio *mediaprovider.RadioStation, insertMode InsertQueueMode) { + if insertMode == Replace { + p.player.Stop() + p.nowPlayingIdx = -1 + p.playQueue = nil + } + needToSetNext := insertMode == InsertNext || (insertMode == Append && p.nowPlayingIdx == len(p.playQueue)-1) + insertIdx := len(p.playQueue) + if insertMode == InsertNext { + insertIdx = p.nowPlayingIdx + 1 + } + new := make([]mediaprovider.MediaItem, len(p.playQueue)+1) + firstHalf := p.playQueue[:insertIdx] + copy(new, firstHalf) + new[len(firstHalf)] = radio + copy(new[len(firstHalf)+1:], p.playQueue[insertIdx:]) + p.playQueue = new + + if needToSetNext { + p.setNextTrack(p.nowPlayingIdx + 1) + } + + p.invokeNoArgCallbacks(p.onQueueChange) +} + // Stop playback and clear the play queue. func (p *playbackEngine) StopAndClearPlayQueue() { changed := len(p.playQueue) > 0 diff --git a/backend/playbackmanager.go b/backend/playbackmanager.go index 2581082..9d06e67 100644 --- a/backend/playbackmanager.go +++ b/backend/playbackmanager.go @@ -182,6 +182,10 @@ func (p *PlaybackManager) PlaySimilarSongs(id string) { }) } +func (p *PlaybackManager) LoadRadioStation(station *mediaprovider.RadioStation, queueMode InsertQueueMode) { + p.engine.LoadRadioStation(station, queueMode) +} + func (p *PlaybackManager) fetchAndPlayTracks(fetchFn func() ([]*mediaprovider.Track, error)) { if songs, err := fetchFn(); err != nil { log.Printf("error fetching tracks: %s", err.Error()) diff --git a/ui/browsing/browsingpane.go b/ui/browsing/browsingpane.go index 4fce17d..055f3ec 100644 --- a/ui/browsing/browsingpane.go +++ b/ui/browsing/browsingpane.go @@ -140,11 +140,13 @@ func (b *BrowsingPane) AddSettingsMenuSeparator() { fyne.NewMenuItemSeparator()) } -func (b *BrowsingPane) AddNavigationButton(icon fyne.Resource, pageName controller.PageName, action func()) { +func (b *BrowsingPane) AddNavigationButton(icon fyne.Resource, pageName controller.PageName, action func()) *widget.Button { // make a copy of the icon, because it can change the color browsingPaneIcon := theme.NewThemedResource(icon) - b.navBtnsContainer.Add(widget.NewButtonWithIcon("", browsingPaneIcon, action)) + btn := widget.NewButtonWithIcon("", browsingPaneIcon, action) + b.navBtnsContainer.Add(btn) b.navBtnsPageMap[pageName] = browsingPaneIcon + return btn } func (b *BrowsingPane) DisableNavigationButtons() { diff --git a/ui/browsing/radiospage.go b/ui/browsing/radiospage.go index af313f1..ba2caa6 100644 --- a/ui/browsing/radiospage.go +++ b/ui/browsing/radiospage.go @@ -5,6 +5,7 @@ import ( "net/url" "strings" + "github.com/dweymouth/supersonic/backend" "github.com/dweymouth/supersonic/backend/mediaprovider" "github.com/dweymouth/supersonic/sharedutil" "github.com/dweymouth/supersonic/ui/controller" @@ -25,6 +26,7 @@ type RadiosPage struct { contr *controller.Controller rp mediaprovider.RadioProvider + pm *backend.PlaybackManager radios []*mediaprovider.RadioStation list *RadioList @@ -33,19 +35,24 @@ type RadiosPage struct { searcher *widgets.SearchEntry } -func NewRadiosPage(contr *controller.Controller, rp mediaprovider.RadioProvider) *RadiosPage { - return newRadiosPage(contr, rp, "", 0) +func NewRadiosPage(contr *controller.Controller, rp mediaprovider.RadioProvider, pm *backend.PlaybackManager) *RadiosPage { + return newRadiosPage(contr, rp, pm, "", 0) } -func newRadiosPage(contr *controller.Controller, rp mediaprovider.RadioProvider, searchText string, scrollPos float32) *RadiosPage { +func newRadiosPage(contr *controller.Controller, rp mediaprovider.RadioProvider, pm *backend.PlaybackManager, searchText string, scrollPos float32) *RadiosPage { a := &RadiosPage{ contr: contr, rp: rp, + pm: pm, titleDisp: widget.NewRichTextWithText("Internet Radio Stations"), } a.ExtendBaseWidget(a) a.titleDisp.Segments[0].(*widget.TextSegment).Style.SizeName = theme.SizeNameHeadingText a.list = NewRadioList() + a.list.OnPlay = func(station *mediaprovider.RadioStation) { + pm.LoadRadioStation(station, backend.Replace) + pm.PlayFromBeginning() + } a.searcher = widgets.NewSearchEntry() a.searcher.PlaceHolder = "Search page" a.searcher.OnSearched = a.onSearched @@ -105,9 +112,7 @@ func (a *RadiosPage) Scroll(amount float32) { } func (a *RadiosPage) Route() controller.Route { - // TODO - //return controller.RadiosRoute() - return controller.Route{} + return controller.RadiosRoute() } func (a *RadiosPage) Reload() { @@ -118,6 +123,7 @@ func (a *RadiosPage) Save() SavedPage { return &savedrRadiosPage{ contr: a.contr, rp: a.rp, + pm: a.pm, searchText: a.searcher.Entry.Text, scrollPos: a.list.list.GetScrollOffset(), } @@ -126,12 +132,13 @@ func (a *RadiosPage) Save() SavedPage { type savedrRadiosPage struct { contr *controller.Controller rp mediaprovider.RadioProvider + pm *backend.PlaybackManager searchText string scrollPos float32 } func (s *savedrRadiosPage) Restore() Page { - return newRadiosPage(s.contr, s.rp, s.searchText, s.scrollPos) + return newRadiosPage(s.contr, s.rp, s.pm, s.searchText, s.scrollPos) } func (a *RadiosPage) buildContainer() { @@ -150,7 +157,7 @@ func (a *RadiosPage) CreateRenderer() fyne.WidgetRenderer { type RadioList struct { widget.BaseWidget - OnPlay func(string) + OnPlay func(*mediaprovider.RadioStation) radios []*mediaprovider.RadioStation @@ -228,7 +235,7 @@ func (g *RadioList) SetRadios(radios []*mediaprovider.RadioStation) { func (a *RadioList) onPlayRadio(item *mediaprovider.RadioStation) { if a.OnPlay != nil { - a.OnPlay(item.ID) + a.OnPlay(item) } } diff --git a/ui/browsing/router.go b/ui/browsing/router.go index fd7089a..9252309 100644 --- a/ui/browsing/router.go +++ b/ui/browsing/router.go @@ -55,6 +55,10 @@ func (r Router) CreatePage(rte controller.Route) Page { return NewPlaylistsPage(r.Controller, r.widgetPool, &r.App.Config.PlaylistsPage, r.App.ServerManager.Server) case controller.Tracks: return NewTracksPage(r.Controller, &r.App.Config.TracksPage, r.widgetPool, r.App.ServerManager.Server, r.App.ImageManager) + case controller.Radios: + var rp mediaprovider.RadioProvider + rp, _ = r.App.ServerManager.Server.(mediaprovider.RadioProvider) + return NewRadiosPage(r.Controller, rp, r.App.PlaybackManager) } return nil } diff --git a/ui/controller/routes.go b/ui/controller/routes.go index dc2255a..83b9e22 100644 --- a/ui/controller/routes.go +++ b/ui/controller/routes.go @@ -15,6 +15,7 @@ const ( Playlist Playlists Tracks + Radios ) type Route struct { @@ -61,6 +62,10 @@ func ArtistsRoute() Route { return Route{Page: Artists} } +func RadiosRoute() Route { + return Route{Page: Radios} +} + func NowPlayingRoute(highlightedTrackID string) Route { return Route{Page: NowPlaying, Arg: highlightedTrackID} } diff --git a/ui/mainwindow.go b/ui/mainwindow.go index 68d5e10..99d56f7 100644 --- a/ui/mainwindow.go +++ b/ui/mainwindow.go @@ -53,6 +53,9 @@ type MainWindow struct { theme *theme.MyTheme haveSystemTray bool container *fyne.Container + + // needs to bes shown/hidden when switching between servers based on whether they support radio + radioBtn *widget.Button } func NewMainWindow(fyneApp fyne.App, appName, displayAppName, appVersion string, app *backend.App, size fyne.Size) MainWindow { @@ -183,6 +186,11 @@ func (m *MainWindow) RunOnServerConnectedTasks(app *backend.App, displayAppName } m.App.Config.Application.LastCheckedVersion = t } + + _, supportsRadio := m.App.ServerManager.Server.(mediaprovider.RadioProvider) + m.radioBtn.Hidden = !supportsRadio + m.radioBtn.Refresh() + m.App.SaveConfigFile() } @@ -265,6 +273,9 @@ func (m *MainWindow) addNavigationButtons() { m.BrowsingPane.AddNavigationButton(theme.TracksIcon, controller.Tracks, func() { m.Router.NavigateTo(controller.TracksRoute()) }) + m.radioBtn = m.BrowsingPane.AddNavigationButton(theme.TracksIcon /*todo*/, controller.Radios, func() { + m.Router.NavigateTo(controller.RadiosRoute()) + }) } func (m *MainWindow) addShortcuts() { From e180197852cb081b31b2436d339297b0509f7f1a Mon Sep 17 00:00:00 2001 From: Drew Weymouth Date: Sat, 1 Jun 2024 12:28:48 -0700 Subject: [PATCH 11/25] fix some interface assertion and nil bugs, and album page not showing now playing --- ui/browsing/albumpage.go | 4 +++- ui/browsing/nowplayingpage.go | 3 +++ ui/util/tracklistutil.go | 4 ++-- ui/widgets/playqueuelist.go | 10 +++++----- ui/widgets/tracklist.go | 16 ++++++++-------- 5 files changed, 21 insertions(+), 16 deletions(-) diff --git a/ui/browsing/albumpage.go b/ui/browsing/albumpage.go index ae4d634..c2e6339 100644 --- a/ui/browsing/albumpage.go +++ b/ui/browsing/albumpage.go @@ -130,7 +130,9 @@ func (a *AlbumPage) Route() controller.Route { return controller.AlbumRoute(a.albumID) } -func (a *AlbumPage) OnSongChange(track, lastScrobbledIfAny *mediaprovider.Track) { +var _ CanShowNowPlaying = (*AlbumPage)(nil) + +func (a *AlbumPage) OnSongChange(track mediaprovider.MediaItem, lastScrobbledIfAny *mediaprovider.Track) { a.nowPlayingID = sharedutil.MediaItemIDOrEmptyStr(track) a.tracklist.SetNowPlaying(a.nowPlayingID) a.tracklist.IncrementPlayCount(sharedutil.MediaItemIDOrEmptyStr(lastScrobbledIfAny)) diff --git a/ui/browsing/nowplayingpage.go b/ui/browsing/nowplayingpage.go index 6c7401f..8f769cb 100644 --- a/ui/browsing/nowplayingpage.go +++ b/ui/browsing/nowplayingpage.go @@ -273,6 +273,9 @@ func (a *NowPlayingPage) onImageLoaded(img image.Image, err error) { log.Printf("error loading cover art: %v\n", err) return } + if img == nil { + return + } a.card.SetCoverImage(img) c := dominantcolor.Find(img) if c == a.background.StartColor { diff --git a/ui/util/tracklistutil.go b/ui/util/tracklistutil.go index d24e611..3f46646 100644 --- a/ui/util/tracklistutil.go +++ b/ui/util/tracklistutil.go @@ -78,12 +78,12 @@ func SelectItemRange(items []*TrackListModel, idx int) { } } -func FindTrackByID(items []*TrackListModel, id string) (*mediaprovider.Track, int) { +func FindItemByID(items []*TrackListModel, id string) (mediaprovider.MediaItem, int) { idx := slices.IndexFunc(items, func(tr *TrackListModel) bool { return tr.Item.Metadata().ID == id }) if idx >= 0 { - return items[idx].Track(), idx + return items[idx].Item, idx } return nil, -1 } diff --git a/ui/widgets/playqueuelist.go b/ui/widgets/playqueuelist.go index d7ac04d..a99ff4a 100644 --- a/ui/widgets/playqueuelist.go +++ b/ui/widgets/playqueuelist.go @@ -120,14 +120,14 @@ func (p *PlayQueueList) SetItems(items []mediaprovider.MediaItem) { p.Refresh() } -// Sets the currently playing track ID and updates the list rendering -func (p *PlayQueueList) SetNowPlaying(trackID string) { +// Sets the currently playing item ID and updates the list rendering +func (p *PlayQueueList) SetNowPlaying(itemID string) { prevNowPlaying := p.nowPlayingID p.tracksMutex.RLock() - trPrev, idxPrev := util.FindTrackByID(p.items, prevNowPlaying) - tr, idx := util.FindTrackByID(p.items, trackID) + trPrev, idxPrev := util.FindItemByID(p.items, prevNowPlaying) + tr, idx := util.FindItemByID(p.items, itemID) p.tracksMutex.RUnlock() - p.nowPlayingID = trackID + p.nowPlayingID = itemID if trPrev != nil { p.list.RefreshItem(idxPrev) } diff --git a/ui/widgets/tracklist.go b/ui/widgets/tracklist.go index 0957799..8fe39d5 100644 --- a/ui/widgets/tracklist.go +++ b/ui/widgets/tracklist.go @@ -271,8 +271,8 @@ func (t *Tracklist) SetSorting(sorting TracklistSort) { func (t *Tracklist) SetNowPlaying(trackID string) { prevNowPlaying := t.nowPlayingID t.tracksMutex.RLock() - trPrev, idxPrev := util.FindTrackByID(t.tracks, prevNowPlaying) - tr, idx := util.FindTrackByID(t.tracks, trackID) + trPrev, idxPrev := util.FindItemByID(t.tracks, prevNowPlaying) + tr, idx := util.FindItemByID(t.tracks, trackID) t.tracksMutex.RUnlock() t.nowPlayingID = trackID if trPrev != nil { @@ -286,10 +286,10 @@ func (t *Tracklist) SetNowPlaying(trackID string) { // Increments the play count of the given track and updates the list rendering func (t *Tracklist) IncrementPlayCount(trackID string) { t.tracksMutex.RLock() - tr, idx := util.FindTrackByID(t.tracks, trackID) + tr, idx := util.FindItemByID(t.tracks, trackID) t.tracksMutex.RUnlock() if tr != nil { - tr.PlayCount += 1 + tr.(*mediaprovider.Track).PlayCount += 1 t.list.RefreshItem(idx) } } @@ -581,9 +581,9 @@ func (t *Tracklist) onShowContextMenu(e *fyne.PointEvent, trackIdx int) { func (t *Tracklist) onSetFavorite(trackID string, fav bool) { t.tracksMutex.RLock() - tr, _ := util.FindTrackByID(t.tracks, trackID) + item, _ := util.FindItemByID(t.tracks, trackID) t.tracksMutex.RUnlock() - t.onSetFavorites([]*mediaprovider.Track{tr}, fav, false) + t.onSetFavorites([]*mediaprovider.Track{item.(*mediaprovider.Track)}, fav, false) } func (t *Tracklist) onSetFavorites(tracks []*mediaprovider.Track, fav bool, needRefresh bool) { @@ -602,9 +602,9 @@ func (t *Tracklist) onSetFavorites(tracks []*mediaprovider.Track, fav bool, need func (t *Tracklist) onSetRating(trackID string, rating int) { // update our own track model t.tracksMutex.RLock() - tr, _ := util.FindTrackByID(t.tracks, trackID) + item, _ := util.FindItemByID(t.tracks, trackID) t.tracksMutex.RUnlock() - t.onSetRatings([]*mediaprovider.Track{tr}, rating, false) + t.onSetRatings([]*mediaprovider.Track{item.(*mediaprovider.Track)}, rating, false) } func (t *Tracklist) onSetRatings(tracks []*mediaprovider.Track, rating int, needRefresh bool) { From 605c62ea7a33afc54a1413e0fb3d1e1d765b5abc Mon Sep 17 00:00:00 2001 From: Drew Weymouth Date: Sat, 1 Jun 2024 12:33:09 -0700 Subject: [PATCH 12/25] use radio icon and tweak icon padding --- res/bundled.go | 2 +- res/icons/remix_design/broadcast.svg | 2 +- ui/mainwindow.go | 2 +- ui/theme/theme.go | 2 +- ui/widgets/tracklist.go | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/res/bundled.go b/res/bundled.go index d284ce7..5852714 100644 --- a/res/bundled.go +++ b/res/bundled.go @@ -88,7 +88,7 @@ var ResFilterSvg = &fyne.StaticResource{ var ResBroadcastSvg = &fyne.StaticResource{ StaticName: "broadcast.svg", StaticContent: []byte( - "\n\n \n"), + "\n\n \n"), } var ResRepeatSvg = &fyne.StaticResource{ StaticName: "repeat.svg", diff --git a/res/icons/remix_design/broadcast.svg b/res/icons/remix_design/broadcast.svg index ff53661..724c40b 100644 --- a/res/icons/remix_design/broadcast.svg +++ b/res/icons/remix_design/broadcast.svg @@ -1,4 +1,4 @@ - + \ No newline at end of file diff --git a/ui/mainwindow.go b/ui/mainwindow.go index 99d56f7..7a896d5 100644 --- a/ui/mainwindow.go +++ b/ui/mainwindow.go @@ -273,7 +273,7 @@ func (m *MainWindow) addNavigationButtons() { m.BrowsingPane.AddNavigationButton(theme.TracksIcon, controller.Tracks, func() { m.Router.NavigateTo(controller.TracksRoute()) }) - m.radioBtn = m.BrowsingPane.AddNavigationButton(theme.TracksIcon /*todo*/, controller.Radios, func() { + m.radioBtn = m.BrowsingPane.AddNavigationButton(theme.RadioIcon, controller.Radios, func() { m.Router.NavigateTo(controller.RadiosRoute()) }) } diff --git a/ui/theme/theme.go b/ui/theme/theme.go index f2f93d9..0dab7ee 100644 --- a/ui/theme/theme.go +++ b/ui/theme/theme.go @@ -31,7 +31,7 @@ const ( var ( AlbumIcon fyne.Resource = theme.NewThemedResource(res.ResDiscSvg) ArtistIcon fyne.Resource = theme.NewThemedResource(res.ResPeopleSvg) - BroadcastIcon fyne.Resource = theme.NewThemedResource(res.ResBroadcastSvg) + RadioIcon fyne.Resource = theme.NewThemedResource(res.ResBroadcastSvg) FavoriteIcon fyne.Resource = theme.NewThemedResource(res.ResHeartFilledSvg) NotFavoriteIcon fyne.Resource = theme.NewThemedResource(res.ResHeartOutlineSvg) NowPlayingIcon fyne.Resource = theme.NewThemedResource(res.ResHeadphonesSvg) diff --git a/ui/widgets/tracklist.go b/ui/widgets/tracklist.go index 8fe39d5..14962f8 100644 --- a/ui/widgets/tracklist.go +++ b/ui/widgets/tracklist.go @@ -534,7 +534,7 @@ func (t *Tracklist) onShowContextMenu(e *fyne.PointEvent, trackIdx int) { t.songRadioMenuItem = fyne.NewMenuItem("Play song radio", func() { t.onPlaySongRadio(t.selectedTracks()) }) - t.songRadioMenuItem.Icon = myTheme.BroadcastIcon + t.songRadioMenuItem.Icon = myTheme.RadioIcon t.ctxMenu.Items = append(t.ctxMenu.Items, play, shuffle, playNext, add, t.songRadioMenuItem) } From dcd714453e77425a0ea923ec9f824e7c7ca8bc6f Mon Sep 17 00:00:00 2001 From: Drew Weymouth Date: Sat, 1 Jun 2024 12:41:05 -0700 Subject: [PATCH 13/25] prevent radio stations from being seeked --- backend/playbackengine.go | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/backend/playbackengine.go b/backend/playbackengine.go index 91154e4..37f315d 100644 --- a/backend/playbackengine.go +++ b/backend/playbackengine.go @@ -50,6 +50,7 @@ type playbackEngine struct { playQueue []mediaprovider.MediaItem nowPlayingIdx int + isRadio bool wasStopped bool // true iff player was stopped before handleOnTrackChange invocation loopMode LoopMode @@ -184,6 +185,9 @@ func (p *playbackEngine) SeekBackOrPrevious() error { // Seek to given absolute position in the current track by seconds. func (p *playbackEngine) SeekSeconds(sec float64) error { + if p.isRadio { + return nil // can't seek radio stations + } return p.player.SeekSeconds(sec) } @@ -424,8 +428,11 @@ func (p *playbackEngine) handleOnTrackChange() { p.nowPlayingIdx = 0 // wrapped around } } + nowPlaying := p.playQueue[p.nowPlayingIdx] + _, isRadio := nowPlaying.(*mediaprovider.RadioStation) + p.isRadio = isRadio p.wasStopped = false - p.curTrackDuration = float64(p.playQueue[p.nowPlayingIdx].Metadata().Duration) + p.curTrackDuration = float64(nowPlaying.Metadata().Duration) p.sendNowPlayingScrobble() // Must come before invokeOnChangeCallbacks b/c track may immediately be scrobbled p.invokeOnSongChangeCallbacks() p.doUpdateTimePos(false) @@ -641,7 +648,12 @@ func (p *playbackEngine) doUpdateTimePos(seeked bool) { if s.TimePos > p.latestTrackPosition { p.latestTrackPosition = s.TimePos } + duration := s.Duration + if p.isRadio { + // MPV reports buffered duration - we don't want to show this + duration = 0 + } for _, cb := range p.onPlayTimeUpdate { - cb(s.TimePos, s.Duration, seeked) + cb(s.TimePos, duration, seeked) } } From 976fdb6e3b4eb5d83d88af15d1907dbb3088e17b Mon Sep 17 00:00:00 2001 From: Drew Weymouth Date: Sat, 1 Jun 2024 12:46:04 -0700 Subject: [PATCH 14/25] reset rating and favorite to 0/false for radio stations --- ui/widgets/largenowplayingcard.go | 3 +++ 1 file changed, 3 insertions(+) diff --git a/ui/widgets/largenowplayingcard.go b/ui/widgets/largenowplayingcard.go index a602182..8bb304b 100644 --- a/ui/widgets/largenowplayingcard.go +++ b/ui/widgets/largenowplayingcard.go @@ -125,6 +125,9 @@ func (n *LargeNowPlayingCard) Update(item mediaprovider.MediaItem) { if tr, ok := item.(*mediaprovider.Track); ok { n.rating.Rating = tr.Rating n.favorite.Favorite = tr.Favorite + } else { + n.rating.Rating = 0 + n.favorite.Favorite = false } n.Refresh() From d3ad28d8a1391c6915f78ee4eebbf0d150901e49 Mon Sep 17 00:00:00 2001 From: Drew Weymouth Date: Sat, 1 Jun 2024 13:08:15 -0700 Subject: [PATCH 15/25] render whether radio station is playing on Radios page --- ui/browsing/radiospage.go | 49 +++++++++++++++++++++++++++++++++------ 1 file changed, 42 insertions(+), 7 deletions(-) diff --git a/ui/browsing/radiospage.go b/ui/browsing/radiospage.go index ba2caa6..fdc4947 100644 --- a/ui/browsing/radiospage.go +++ b/ui/browsing/radiospage.go @@ -30,6 +30,8 @@ type RadiosPage struct { radios []*mediaprovider.RadioStation list *RadioList + nowPlayingID string + titleDisp *widget.RichText container *fyne.Container searcher *widgets.SearchEntry @@ -48,7 +50,7 @@ func newRadiosPage(contr *controller.Controller, rp mediaprovider.RadioProvider, } a.ExtendBaseWidget(a) a.titleDisp.Segments[0].(*widget.TextSegment).Style.SizeName = theme.SizeNameHeadingText - a.list = NewRadioList() + a.list = NewRadioList(&a.nowPlayingID) a.list.OnPlay = func(station *mediaprovider.RadioStation) { pm.LoadRadioStation(station, backend.Replace) pm.PlayFromBeginning() @@ -111,6 +113,17 @@ func (a *RadiosPage) Scroll(amount float32) { a.list.list.ScrollToOffset(a.list.list.GetScrollOffset() + amount) } +var _ CanShowNowPlaying = (*RadiosPage)(nil) + +func (a *RadiosPage) OnSongChange(playing mediaprovider.MediaItem, _ *mediaprovider.Track) { + if playing != nil { + a.nowPlayingID = playing.Metadata().ID + } else { + a.nowPlayingID = "" + } + a.list.Refresh() +} + func (a *RadiosPage) Route() controller.Route { return controller.RadiosRoute() } @@ -165,20 +178,22 @@ type RadioList struct { hdr *widgets.ListHeader list *widgets.FocusList container *fyne.Container + playingIcon fyne.CanvasObject } type RadioListRow struct { widgets.FocusListRowBase - Item *mediaprovider.RadioStation + Item *mediaprovider.RadioStation + IsPlaying bool - nameLabel *widget.Label + nameLabel *widget.RichText homePageLink *widget.Hyperlink } func NewRadioListRow(layout *layouts.ColumnsLayout) *RadioListRow { a := &RadioListRow{ - nameLabel: widget.NewLabel(""), + nameLabel: widget.NewRichTextWithText(""), homePageLink: widget.NewHyperlink("", nil), } a.ExtendBaseWidget(a) @@ -188,10 +203,13 @@ func NewRadioListRow(layout *layouts.ColumnsLayout) *RadioListRow { return a } -func NewRadioList() *RadioList { +func NewRadioList(nowPlayingIDPtr *string) *RadioList { a := &RadioList{ - columnsLayout: layouts.NewColumnsLayout([]float32{-1, 125, 125}), + columnsLayout: layouts.NewColumnsLayout([]float32{-1, -1}), } + playIcon := theme.NewThemedResource(theme.MediaPlayIcon()) + playIcon.ColorName = theme.ColorNamePrimary + a.playingIcon = container.NewCenter(widget.NewIcon(playIcon)) a.ExtendBaseWidget(a) a.hdr = widgets.NewListHeader([]widgets.ListColumn{ {Text: "Name", Alignment: fyne.TextAlignLeading, CanToggleVisible: false}, @@ -211,15 +229,32 @@ func NewRadioList() *RadioList { func(id widget.ListItemID, item fyne.CanvasObject) { row := item.(*RadioListRow) a.list.SetItemForID(id, row) + changed := false if row.Item != a.radios[id] { row.EnsureUnfocused() row.ListItemID = id row.Item = a.radios[id] - row.nameLabel.Text = row.Item.Name + row.nameLabel.Segments[0].(*widget.TextSegment).Text = row.Item.Name row.homePageLink.Text = row.Item.HomePageURL if u, err := url.Parse(row.Item.HomePageURL); err == nil { row.homePageLink.URL = u } + changed = true + } + isPlaying := *nowPlayingIDPtr == row.Item.ID + if row.IsPlaying != isPlaying { + row.IsPlaying = isPlaying + row.nameLabel.Segments[0].(*widget.TextSegment).Style.TextStyle.Bold = isPlaying + if isPlaying { + row.Content.(*fyne.Container).Objects[0] = + container.NewBorder(nil, nil, a.playingIcon, nil, + container.New(layout.NewCustomPaddedLayout(0, 0, -5, 0), row.nameLabel)) + } else { + row.Content.(*fyne.Container).Objects[0] = row.nameLabel + } + changed = true + } + if changed { row.Refresh() } }, From c1858fa53d5b2898ccc640233c44c2d286c865b0 Mon Sep 17 00:00:00 2001 From: Drew Weymouth Date: Sat, 1 Jun 2024 13:19:25 -0700 Subject: [PATCH 16/25] show message when no radio stations available --- ui/browsing/radiospage.go | 37 ++++++++++++++++++++++++++++++------- 1 file changed, 30 insertions(+), 7 deletions(-) diff --git a/ui/browsing/radiospage.go b/ui/browsing/radiospage.go index fdc4947..5a95e59 100644 --- a/ui/browsing/radiospage.go +++ b/ui/browsing/radiospage.go @@ -32,9 +32,10 @@ type RadiosPage struct { nowPlayingID string - titleDisp *widget.RichText - container *fyne.Container - searcher *widgets.SearchEntry + titleDisp *widget.RichText + noRadiosMsg fyne.CanvasObject + container *fyne.Container + searcher *widgets.SearchEntry } func NewRadiosPage(contr *controller.Controller, rp mediaprovider.RadioProvider, pm *backend.PlaybackManager) *RadiosPage { @@ -59,6 +60,21 @@ func newRadiosPage(contr *controller.Controller, rp mediaprovider.RadioProvider, a.searcher.PlaceHolder = "Search page" a.searcher.OnSearched = a.onSearched a.searcher.Entry.Text = searchText + + a.noRadiosMsg = container.NewCenter( + container.New(layout.NewCustomPaddedVBoxLayout(-10), + container.NewCenter( + container.NewBorder(nil, nil, + widget.NewIcon(theme.InfoIcon()), nil, + widget.NewRichText(&widget.TextSegment{ + Text: "No radio stations available", + Style: widget.RichTextStyleSubHeading, + }))), + widget.NewLabel("Configure your music server to add radio stations"), + ), + ) + a.noRadiosMsg.Hide() + a.buildContainer() go a.load(searchText != "", scrollPos) return a @@ -70,6 +86,11 @@ func (a *RadiosPage) load(searchOnLoad bool, scrollPos float32) { if err != nil { log.Printf("error loading radios: %v", err.Error()) } + if len(radios) == 0 { + a.noRadiosMsg.Show() + } else { + a.noRadiosMsg.Hide() + } a.radios = radios if searchOnLoad { a.onSearched(a.searcher.Entry.Text) @@ -157,10 +178,12 @@ func (s *savedrRadiosPage) Restore() Page { func (a *RadiosPage) buildContainer() { searchVbox := container.NewVBox(layout.NewSpacer(), a.searcher, layout.NewSpacer()) a.container = container.New(&layout.CustomPaddedLayout{LeftPadding: 15, RightPadding: 15, TopPadding: 5, BottomPadding: 15}, - container.NewBorder( - container.New(&layout.CustomPaddedLayout{LeftPadding: -5}, - container.NewHBox(a.titleDisp, layout.NewSpacer(), searchVbox)), - nil, nil, nil, a.list)) + container.NewStack(a.noRadiosMsg, + container.NewBorder( + container.New(&layout.CustomPaddedLayout{LeftPadding: -5}, + container.NewHBox(a.titleDisp, layout.NewSpacer(), searchVbox)), + nil, nil, nil, a.list)), + ) } func (a *RadiosPage) CreateRenderer() fyne.WidgetRenderer { From 00444836af564ca0b9ea496f07567464634aa197 Mon Sep 17 00:00:00 2001 From: Drew Weymouth Date: Sat, 1 Jun 2024 13:23:59 -0700 Subject: [PATCH 17/25] fix home page col alignment and no radios msg positioning --- ui/browsing/radiospage.go | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/ui/browsing/radiospage.go b/ui/browsing/radiospage.go index 5a95e59..3145677 100644 --- a/ui/browsing/radiospage.go +++ b/ui/browsing/radiospage.go @@ -178,11 +178,11 @@ func (s *savedrRadiosPage) Restore() Page { func (a *RadiosPage) buildContainer() { searchVbox := container.NewVBox(layout.NewSpacer(), a.searcher, layout.NewSpacer()) a.container = container.New(&layout.CustomPaddedLayout{LeftPadding: 15, RightPadding: 15, TopPadding: 5, BottomPadding: 15}, - container.NewStack(a.noRadiosMsg, - container.NewBorder( - container.New(&layout.CustomPaddedLayout{LeftPadding: -5}, - container.NewHBox(a.titleDisp, layout.NewSpacer(), searchVbox)), - nil, nil, nil, a.list)), + container.NewBorder( + container.New(&layout.CustomPaddedLayout{LeftPadding: -5}, + container.NewHBox(a.titleDisp, layout.NewSpacer(), searchVbox)), + nil, nil, nil, + container.NewStack(a.noRadiosMsg, a.list)), ) } @@ -236,7 +236,7 @@ func NewRadioList(nowPlayingIDPtr *string) *RadioList { a.ExtendBaseWidget(a) a.hdr = widgets.NewListHeader([]widgets.ListColumn{ {Text: "Name", Alignment: fyne.TextAlignLeading, CanToggleVisible: false}, - {Text: "Home Page", Alignment: fyne.TextAlignTrailing, CanToggleVisible: false}}, + {Text: "Home Page", Alignment: fyne.TextAlignLeading, CanToggleVisible: false}}, a.columnsLayout) a.hdr.DisableSorting = true a.list = widgets.NewFocusList( From 61f8dd00ff5289601c0345258cf20e7d8134aa85 Mon Sep 17 00:00:00 2001 From: Drew Weymouth Date: Sat, 1 Jun 2024 17:11:23 -0700 Subject: [PATCH 18/25] add context menu to radio list for enqueuing next or later --- ui/browsing/radiospage.go | 86 +++++++++++++++++++++++++++++++++++---- 1 file changed, 78 insertions(+), 8 deletions(-) diff --git a/ui/browsing/radiospage.go b/ui/browsing/radiospage.go index 3145677..cdcbf7b 100644 --- a/ui/browsing/radiospage.go +++ b/ui/browsing/radiospage.go @@ -10,6 +10,7 @@ import ( "github.com/dweymouth/supersonic/sharedutil" "github.com/dweymouth/supersonic/ui/controller" "github.com/dweymouth/supersonic/ui/layouts" + myTheme "github.com/dweymouth/supersonic/ui/theme" "github.com/dweymouth/supersonic/ui/widgets" "fyne.io/fyne/v2" @@ -52,10 +53,8 @@ func newRadiosPage(contr *controller.Controller, rp mediaprovider.RadioProvider, a.ExtendBaseWidget(a) a.titleDisp.Segments[0].(*widget.TextSegment).Style.SizeName = theme.SizeNameHeadingText a.list = NewRadioList(&a.nowPlayingID) - a.list.OnPlay = func(station *mediaprovider.RadioStation) { - pm.LoadRadioStation(station, backend.Replace) - pm.PlayFromBeginning() - } + a.list.OnPlay = a.onPlay + a.list.OnQueue = a.onQueue a.searcher = widgets.NewSearchEntry() a.searcher.PlaceHolder = "Search page" a.searcher.OnSearched = a.onSearched @@ -107,6 +106,19 @@ func (a *RadiosPage) load(searchOnLoad bool, scrollPos float32) { } } +func (a *RadiosPage) onPlay(station *mediaprovider.RadioStation) { + a.pm.LoadRadioStation(station, backend.Replace) + a.pm.PlayFromBeginning() +} + +func (a *RadiosPage) onQueue(station *mediaprovider.RadioStation, next bool) { + queueMode := backend.Append + if next { + queueMode = backend.InsertNext + } + a.pm.LoadRadioStation(station, queueMode) +} + func (a *RadiosPage) onSearched(query string) { // since the radios list is returned in full non-paginated, we will do our own // simple search based on the radio name, rather than calling a server API @@ -193,22 +205,26 @@ func (a *RadiosPage) CreateRenderer() fyne.WidgetRenderer { type RadioList struct { widget.BaseWidget - OnPlay func(*mediaprovider.RadioStation) + OnPlay func(*mediaprovider.RadioStation) + OnQueue func(r *mediaprovider.RadioStation, next bool) - radios []*mediaprovider.RadioStation + radios []*mediaprovider.RadioStation + selected *RadioListRow columnsLayout *layouts.ColumnsLayout hdr *widgets.ListHeader list *widgets.FocusList container *fyne.Container playingIcon fyne.CanvasObject + menu *widget.PopUpMenu } type RadioListRow struct { widgets.FocusListRowBase - Item *mediaprovider.RadioStation - IsPlaying bool + Item *mediaprovider.RadioStation + IsPlaying bool + OnTappedSecondary func(*fyne.PointEvent) nameLabel *widget.RichText homePageLink *widget.Hyperlink @@ -226,6 +242,12 @@ func NewRadioListRow(layout *layouts.ColumnsLayout) *RadioListRow { return a } +func (a *RadioListRow) TappedSecondary(e *fyne.PointEvent) { + if a.OnTappedSecondary != nil { + a.OnTappedSecondary(e) + } +} + func NewRadioList(nowPlayingIDPtr *string) *RadioList { a := &RadioList{ columnsLayout: layouts.NewColumnsLayout([]float32{-1, -1}), @@ -243,7 +265,21 @@ func NewRadioList(nowPlayingIDPtr *string) *RadioList { func() int { return len(a.radios) }, func() fyne.CanvasObject { r := NewRadioListRow(a.columnsLayout) + r.OnTapped = func() { + r.Selected = true + if a.selected != nil { + // unselect old row + a.selected.Selected = false + a.selected.Refresh() + } + a.selected = r + r.Refresh() + } r.OnDoubleTapped = func() { a.onPlayRadio(r.Item) } + r.OnTappedSecondary = func(e *fyne.PointEvent) { + r.OnTapped() // handle selection + a.showMenu(e.AbsolutePosition) + } r.OnFocusNeighbor = func(up bool) { a.list.FocusNeighbor(r.ItemID(), up) } @@ -286,6 +322,40 @@ func NewRadioList(nowPlayingIDPtr *string) *RadioList { return a } +func (a *RadioList) showMenu(pos fyne.Position) { + if a.menu == nil { + play := fyne.NewMenuItem("Play", func() { + if a.OnPlay != nil { + a.OnPlay(a.selected.Item) + } + }) + play.Icon = theme.MediaPlayIcon() + + playNext := fyne.NewMenuItem("Play next", func() { + if a.OnQueue != nil { + a.OnQueue(a.selected.Item, true) + } + }) + playNext.Icon = myTheme.PlayNextIcon + + append := fyne.NewMenuItem("Add to queue", func() { + if a.OnQueue != nil { + a.OnQueue(a.selected.Item, false) + } + }) + append.Icon = theme.ContentAddIcon() + + a.menu = widget.NewPopUpMenu(fyne.NewMenu("", + play, + playNext, + append, + ), + fyne.CurrentApp().Driver().CanvasForObject(a), + ) + } + a.menu.ShowAtPosition(pos) +} + func (g *RadioList) SetRadios(radios []*mediaprovider.RadioStation) { g.radios = radios g.Refresh() From b7af1e716c7ab99bb966d970d5e6ef6e3c42a28f Mon Sep 17 00:00:00 2001 From: Drew Weymouth Date: Sat, 1 Jun 2024 17:42:40 -0700 Subject: [PATCH 19/25] use radio icon for image placeholder for radio stations --- backend/imagemanager.go | 9 --------- res/bundled.go | 2 +- res/icons/remix_design/broadcast.svg | 2 +- ui/bottompanel.go | 30 ++++++++++------------------ ui/dialogs/searchdialog.go | 2 +- ui/mainwindow.go | 3 +-- ui/widgets/gridview.go | 2 +- ui/widgets/imageplaceholder.go | 8 ++++---- ui/widgets/largenowplayingcard.go | 2 ++ ui/widgets/nowplayingcard.go | 24 ++++++++++++---------- ui/widgets/playqueuelist.go | 5 +++++ 11 files changed, 40 insertions(+), 49 deletions(-) diff --git a/backend/imagemanager.go b/backend/imagemanager.go index f8102ee..e9050ce 100644 --- a/backend/imagemanager.go +++ b/backend/imagemanager.go @@ -120,15 +120,6 @@ func (i *ImageManager) GetCoverThumbnail(coverID string) (image.Image, error) { return i.fetchAndCacheCoverFromDiskOrServer(context.Background(), coverID, i.thumbnailCache.DefaultTTL, nil) } -// GetCoverThumbnailWithTTL fetches the cover for the given coverID and updates the TTL -// in the in-memory image cache. It blocks until the image fetch is complete. -func (i *ImageManager) GetCoverThumbnailWithTTL(coverID string, ttl time.Duration) (image.Image, error) { - if img, err := i.thumbnailCache.GetWithNewTTL(coverID, ttl); err == nil { - return img, nil - } - return i.fetchAndCacheCoverFromDiskOrServer(context.Background(), coverID, ttl, nil) -} - // GetFullSizeCoverArt fetches the full size cover image for the given coverID. // It blocks until the fetch is complete. func (i *ImageManager) GetFullSizeCoverArt(coverID string) (image.Image, error) { diff --git a/res/bundled.go b/res/bundled.go index 5852714..3e1b5d0 100644 --- a/res/bundled.go +++ b/res/bundled.go @@ -88,7 +88,7 @@ var ResFilterSvg = &fyne.StaticResource{ var ResBroadcastSvg = &fyne.StaticResource{ StaticName: "broadcast.svg", StaticContent: []byte( - "\n\n \n"), + "\n\n \n"), } var ResRepeatSvg = &fyne.StaticResource{ StaticName: "repeat.svg", diff --git a/res/icons/remix_design/broadcast.svg b/res/icons/remix_design/broadcast.svg index 724c40b..ab4b918 100644 --- a/res/icons/remix_design/broadcast.svg +++ b/res/icons/remix_design/broadcast.svg @@ -1,4 +1,4 @@ - + \ No newline at end of file diff --git a/ui/bottompanel.go b/ui/bottompanel.go index a99de31..58387eb 100644 --- a/ui/bottompanel.go +++ b/ui/bottompanel.go @@ -1,13 +1,11 @@ package ui import ( - "image" - "time" - "github.com/dweymouth/supersonic/backend" "github.com/dweymouth/supersonic/backend/mediaprovider" "github.com/dweymouth/supersonic/ui/controller" "github.com/dweymouth/supersonic/ui/layouts" + "github.com/dweymouth/supersonic/ui/util" "github.com/dweymouth/supersonic/ui/widgets" "fyne.io/fyne/v2" @@ -18,19 +16,18 @@ import ( type BottomPanel struct { widget.BaseWidget - ImageManager *backend.ImageManager + imageLoader util.ThumbnailLoader NowPlaying *widgets.NowPlayingCard Controls *widgets.PlayerControls AuxControls *widgets.AuxControls - coverArtID string - container *fyne.Container + container *fyne.Container } var _ fyne.Widget = (*BottomPanel)(nil) -func NewBottomPanel(pm *backend.PlaybackManager, contr *controller.Controller) *BottomPanel { +func NewBottomPanel(pm *backend.PlaybackManager, im *backend.ImageManager, contr *controller.Controller) *BottomPanel { bp := &BottomPanel{} bp.ExtendBaseWidget(bp) @@ -105,6 +102,8 @@ func NewBottomPanel(pm *backend.PlaybackManager, contr *controller.Controller) * pm.SetNextLoopMode() }) + bp.imageLoader = util.NewThumbnailLoader(im, bp.NowPlaying.SetImage) + bp.container = container.New(layouts.NewLeftMiddleRightLayout(500), bp.NowPlaying, bp.Controls, bp.AuxControls) return bp @@ -112,20 +111,11 @@ func NewBottomPanel(pm *backend.PlaybackManager, contr *controller.Controller) * func (bp *BottomPanel) onSongChange(song mediaprovider.MediaItem, _ *mediaprovider.Track) { if song == nil { - bp.NowPlaying.Update(nil, nil) + bp.NowPlaying.Update(nil) + bp.imageLoader.Load("") } else { - meta := song.Metadata() - bp.coverArtID = meta.CoverArtID - var im image.Image - if bp.ImageManager != nil { - // set image to expire not long after the length of the song - // if song is played through without much pausing, image will still - // be in cache for the next song if it's from the same album, or - // if the user navigates to the album page for the track - imgTTLSec := meta.Duration + 30 - im, _ = bp.ImageManager.GetCoverThumbnailWithTTL(meta.CoverArtID, time.Duration(imgTTLSec)*time.Second) - } - bp.NowPlaying.Update(song, im) + bp.NowPlaying.Update(song) + bp.imageLoader.Load(song.Metadata().CoverArtID) } } diff --git a/ui/dialogs/searchdialog.go b/ui/dialogs/searchdialog.go index 6e140d6..8e24b5e 100644 --- a/ui/dialogs/searchdialog.go +++ b/ui/dialogs/searchdialog.go @@ -264,7 +264,7 @@ func (s *searchResult) Update(result *mediaprovider.SearchResult) { } s.id = result.ID s.contentType = result.Type - s.image.CenterIcon = placeholderIconForContentType(result.Type) + s.image.PlaceholderIcon = placeholderIconForContentType(result.Type) s.imageLoader.Load(result.CoverID) s.title.SetText(result.Name) diff --git a/ui/mainwindow.go b/ui/mainwindow.go index 7a896d5..dbc3440 100644 --- a/ui/mainwindow.go +++ b/ui/mainwindow.go @@ -84,8 +84,7 @@ func NewMainWindow(fyneApp fyne.App, appName, displayAppName, appVersion string, m.Controller.ReloadFunc = m.BrowsingPane.Reload m.Controller.CurPageFunc = m.BrowsingPane.CurrentPage - m.BottomPanel = NewBottomPanel(app.PlaybackManager, m.Controller) - m.BottomPanel.ImageManager = app.ImageManager + m.BottomPanel = NewBottomPanel(app.PlaybackManager, app.ImageManager, m.Controller) m.container = container.NewBorder(nil, m.BottomPanel, nil, nil, m.BrowsingPane) m.Window.SetContent(m.container) m.Window.Resize(size) diff --git a/ui/widgets/gridview.go b/ui/widgets/gridview.go index 8d9c8dd..5776413 100644 --- a/ui/widgets/gridview.go +++ b/ui/widgets/gridview.go @@ -321,7 +321,7 @@ func (g *GridView) doUpdateItemCard(itemIdx int, card *GridViewItem) { } card.ItemIndex = itemIdx g.itemForIndex[itemIdx] = card - card.Cover.Im.CenterIcon = g.Placeholder + card.Cover.Im.PlaceholderIcon = g.Placeholder if !card.NeedsUpdate(item) && card.ItemIndex == itemIdx { // nothing to do g.stateMutex.Unlock() diff --git a/ui/widgets/imageplaceholder.go b/ui/widgets/imageplaceholder.go index de552ae..b7bf6c0 100644 --- a/ui/widgets/imageplaceholder.go +++ b/ui/widgets/imageplaceholder.go @@ -15,8 +15,8 @@ import ( // a placeholder with a rectangular border frame // and an icon positioned in the center of the frame. type ImagePlaceholder struct { - ScaleMode canvas.ImageScale - CenterIcon fyne.Resource + ScaleMode canvas.ImageScale + PlaceholderIcon fyne.Resource widget.BaseWidget content *fyne.Container @@ -31,7 +31,7 @@ type ImagePlaceholder struct { } func NewImagePlaceholder(centerIcon fyne.Resource, minSize float32) *ImagePlaceholder { - i := &ImagePlaceholder{minSize: minSize, CenterIcon: centerIcon} + i := &ImagePlaceholder{minSize: minSize, PlaceholderIcon: centerIcon} i.ExtendBaseWidget(i) i.iconImage = canvas.NewImageFromResource(centerIcon) i.iconImage.FillMode = canvas.ImageFillContain @@ -94,7 +94,7 @@ func (i *ImagePlaceholder) MinSize() fyne.Size { func (i *ImagePlaceholder) Refresh() { i.border.Hidden = i.HaveImage() - i.iconImage.Resource = i.CenterIcon + i.iconImage.Resource = i.PlaceholderIcon i.iconImage.Hidden = i.HaveImage() i.imageDisp.Hidden = !i.HaveImage() i.imageDisp.ScaleMode = i.ScaleMode diff --git a/ui/widgets/largenowplayingcard.go b/ui/widgets/largenowplayingcard.go index 8bb304b..e558ae4 100644 --- a/ui/widgets/largenowplayingcard.go +++ b/ui/widgets/largenowplayingcard.go @@ -125,9 +125,11 @@ func (n *LargeNowPlayingCard) Update(item mediaprovider.MediaItem) { if tr, ok := item.(*mediaprovider.Track); ok { n.rating.Rating = tr.Rating n.favorite.Favorite = tr.Favorite + n.cover.PlaceholderIcon = myTheme.TracksIcon } else { n.rating.Rating = 0 n.favorite.Favorite = false + n.cover.PlaceholderIcon = myTheme.RadioIcon } n.Refresh() diff --git a/ui/widgets/nowplayingcard.go b/ui/widgets/nowplayingcard.go index e5ae046..9cf2b2b 100644 --- a/ui/widgets/nowplayingcard.go +++ b/ui/widgets/nowplayingcard.go @@ -25,7 +25,7 @@ type NowPlayingCard struct { trackName *widget.Hyperlink artistName *MultiHyperlink albumName *widget.Hyperlink - cover *TappableImage + cover *ImagePlaceholder menu *widget.PopUpMenu ratingMenu *fyne.MenuItem @@ -45,17 +45,16 @@ func NewNowPlayingCard() *NowPlayingCard { albumName: widget.NewHyperlink("", nil), } n.ExtendBaseWidget(n) - n.cover = NewTappableImage(n.onShowCoverImage) + n.cover = NewImagePlaceholder(myTheme.TracksIcon, 85) + n.cover.OnTapped = n.onShowCoverImage n.cover.OnTappedSecondary = n.showMenu + n.cover.ScaleMode = canvas.ImageScaleFastest + n.cover.Hidden = true n.trackName.Hidden = true n.albumName.Hidden = true n.albumName.Truncation = fyne.TextTruncateEllipsis n.trackName.Truncation = fyne.TextTruncateEllipsis n.trackName.TextStyle.Bold = true - n.cover.SetMinSize(fyne.NewSquareSize(85)) - n.cover.FillMode = canvas.ImageFillContain - n.cover.ScaleMode = canvas.ImageScaleFastest - n.cover.Hidden = true n.albumName.OnTapped = n.onAlbumNameTapped n.artistName.OnTapped = n.onArtistNameTapped n.trackName.OnTapped = n.onTrackNameTapped @@ -119,30 +118,35 @@ func (n *NowPlayingCard) CreateRenderer() fyne.WidgetRenderer { return widget.NewSimpleRenderer(c) } -func (n *NowPlayingCard) Update(track mediaprovider.MediaItem, cover image.Image) { +func (n *NowPlayingCard) Update(track mediaprovider.MediaItem) { if track == nil { n.trackName.SetText("") n.artistName.BuildSegments([]string{}, []string{}) n.albumName.SetText("") - + n.cover.Hidden = true } else { + n.cover.Hidden = false n.trackName.SetText(track.Metadata().Name) if tr, ok := track.(*mediaprovider.Track); ok { n.artistName.BuildSegments(tr.ArtistNames, tr.ArtistIDs) n.albumName.SetText(tr.Album) + n.cover.PlaceholderIcon = myTheme.TracksIcon } else { n.artistName.BuildSegments([]string{}, []string{}) n.albumName.SetText("") + n.cover.PlaceholderIcon = myTheme.RadioIcon } } n.trackName.Hidden = n.trackName.Text == "" n.artistName.Hidden = len(n.artistName.Segments) == 0 n.albumName.Hidden = n.albumName.Text == "" - n.cover.Image.Image = cover - n.cover.Hidden = cover == nil n.Refresh() } +func (n *NowPlayingCard) SetImage(cover image.Image) { + n.cover.SetImage(cover, true) +} + func (n *NowPlayingCard) showMenu(e *fyne.PointEvent) { if n.menu == nil { n.ratingMenu = util.NewRatingSubmenu(n.onSetRating) diff --git a/ui/widgets/playqueuelist.go b/ui/widgets/playqueuelist.go index a99ff4a..c2adde0 100644 --- a/ui/widgets/playqueuelist.go +++ b/ui/widgets/playqueuelist.go @@ -372,6 +372,11 @@ func (p *PlayQueueListRow) Update(tm *util.TrackListModel, rowNum int) { // a new track (*mediaprovider.Track) meta := tm.Item.Metadata() if meta.ID != p.trackID { + if meta.Type == mediaprovider.MediaItemTypeRadioStation { + p.cover.PlaceholderIcon = myTheme.RadioIcon + } else { + p.cover.PlaceholderIcon = myTheme.TracksIcon + } p.imageLoader.Load(meta.CoverArtID) p.EnsureUnfocused() p.trackID = meta.ID From 50a66284bb0d4396f6422048a5c7e687717e0ba0 Mon Sep 17 00:00:00 2001 From: Drew Weymouth Date: Sun, 2 Jun 2024 07:43:23 -0700 Subject: [PATCH 20/25] add Ctrl+8 navigation shortcut for radios page --- ui/browsing/browsingpane.go | 2 +- ui/mainwindow.go | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/ui/browsing/browsingpane.go b/ui/browsing/browsingpane.go index 055f3ec..a5279df 100644 --- a/ui/browsing/browsingpane.go +++ b/ui/browsing/browsingpane.go @@ -164,7 +164,7 @@ func (b *BrowsingPane) EnableNavigationButtons() { func (b *BrowsingPane) ActivateNavigationButton(num int) { if num < len(b.navBtnsContainer.Objects) { btn := b.navBtnsContainer.Objects[num].(*widget.Button) - if !btn.Disabled() { + if !btn.Disabled() && !btn.Hidden { btn.OnTapped() } } diff --git a/ui/mainwindow.go b/ui/mainwindow.go index dbc3440..46ee1bf 100644 --- a/ui/mainwindow.go +++ b/ui/mainwindow.go @@ -36,9 +36,10 @@ var ( ShortcutNavFive = desktop.CustomShortcut{KeyName: fyne.Key5, Modifier: os.ControlModifier} ShortcutNavSix = desktop.CustomShortcut{KeyName: fyne.Key6, Modifier: os.ControlModifier} ShortcutNavSeven = desktop.CustomShortcut{KeyName: fyne.Key7, Modifier: os.ControlModifier} + ShortcutNavEight = desktop.CustomShortcut{KeyName: fyne.Key8, Modifier: os.ControlModifier} NavShortcuts = []desktop.CustomShortcut{ShortcutNavOne, ShortcutNavTwo, ShortcutNavThree, - ShortcutNavFour, ShortcutNavFive, ShortcutNavSix, ShortcutNavSeven} + ShortcutNavFour, ShortcutNavFive, ShortcutNavSix, ShortcutNavSeven, ShortcutNavEight} ) type MainWindow struct { From 8de689dee403db62d3abec9effb89630854bb042 Mon Sep 17 00:00:00 2001 From: Drew Weymouth Date: Sun, 2 Jun 2024 08:38:46 -0700 Subject: [PATCH 21/25] show different ctx menu in PlayQueueList when selection contains radios --- ui/util/tracklistutil.go | 6 ++ ui/widgets/playqueuelist.go | 184 +++++++++++++++++++++++------------- 2 files changed, 123 insertions(+), 67 deletions(-) diff --git a/ui/util/tracklistutil.go b/ui/util/tracklistutil.go index 3f46646..3b70c65 100644 --- a/ui/util/tracklistutil.go +++ b/ui/util/tracklistutil.go @@ -30,6 +30,12 @@ func SelectedTracks(items []*TrackListModel) []*mediaprovider.Track { }) } +func SelectedItems(items []*TrackListModel) []mediaprovider.MediaItem { + return sharedutil.FilterMapSlice(items, func(tm *TrackListModel) (mediaprovider.MediaItem, bool) { + return tm.Item, tm.Selected + }) +} + func SelectedItemIDs(items []*TrackListModel) []string { return sharedutil.FilterMapSlice(items, func(tm *TrackListModel) (string, bool) { return tm.Item.Metadata().ID, tm.Selected diff --git a/ui/widgets/playqueuelist.go b/ui/widgets/playqueuelist.go index c2adde0..402fb61 100644 --- a/ui/widgets/playqueuelist.go +++ b/ui/widgets/playqueuelist.go @@ -46,7 +46,8 @@ type PlayQueueList struct { OnReorderItems func(itemIDs []string, op sharedutil.TrackReorderOp) list *FocusList - menu *widget.PopUpMenu + menu *widget.PopUpMenu // ctx menu for when only tracks are selected + radiosMenu *widget.PopUpMenu // ctx menu for when selection contains radios ratingSubmenu *fyne.MenuItem shareMenuItem *fyne.MenuItem @@ -205,73 +206,122 @@ func (p *PlayQueueList) selectRange(idx int) { func (p *PlayQueueList) onShowContextMenu(e *fyne.PointEvent, trackIdx int) { p.selectTrack(trackIdx) p.list.Refresh() - if p.menu == nil { - playlist := fyne.NewMenuItem("Add to playlist...", func() { - if p.OnAddToPlaylist != nil { - p.OnAddToPlaylist(p.selectedTrackIDs()) - } - }) - playlist.Icon = myTheme.PlaylistIcon - download := fyne.NewMenuItem("Download...", func() { - if p.OnDownload != nil { - p.OnDownload(p.selectedTracks(), "Selected tracks") - } - }) - download.Icon = theme.DownloadIcon() - p.shareMenuItem = fyne.NewMenuItem("Share...", func() { - if p.OnShare != nil { - p.OnShare(p.selectedTracks()) - } - }) - p.shareMenuItem.Icon = myTheme.ShareIcon - favorite := fyne.NewMenuItem("Set favorite", func() { - if p.OnSetFavorite != nil { - p.OnSetFavorite(p.selectedTrackIDs(), true) - } - }) - favorite.Icon = myTheme.FavoriteIcon - unfavorite := fyne.NewMenuItem("Unset favorite", func() { - if p.OnSetFavorite != nil { - p.OnSetFavorite(p.selectedTrackIDs(), false) - } - }) - unfavorite.Icon = myTheme.NotFavoriteIcon - p.ratingSubmenu = util.NewRatingSubmenu(func(rating int) { - if p.OnSetRating != nil { - p.OnSetRating(p.selectedTrackIDs(), rating) - } - }) - remove := fyne.NewMenuItem("Remove from queue", func() { - if p.OnRemoveFromQueue != nil { - p.OnRemoveFromQueue(p.selectedTrackIDs()) - } - }) - remove.Icon = theme.ContentRemoveIcon() - reorder := util.NewReorderTracksSubmenu(func(tro sharedutil.TrackReorderOp) { - if p.OnReorderItems != nil { - p.OnReorderItems(p.selectedTrackIDs(), tro) - } - }) + selected := p.selectedItems() - p.menu = widget.NewPopUpMenu( - fyne.NewMenu("", - playlist, - download, - p.shareMenuItem, - fyne.NewMenuItemSeparator(), - favorite, - unfavorite, - p.ratingSubmenu, - fyne.NewMenuItemSeparator(), - reorder, - remove, - ), - fyne.CurrentApp().Driver().CanvasForObject(p), - ) + allTracks := true + for _, item := range selected { + if item.Metadata().Type == mediaprovider.MediaItemTypeRadioStation { + allTracks = false + break + } } - p.ratingSubmenu.Disabled = p.DisableRating - p.shareMenuItem.Disabled = p.DisableSharing || len(p.selectedTracks()) != 1 - p.menu.ShowAtPosition(e.AbsolutePosition) + + var menu *widget.PopUpMenu + if allTracks { + p.ensureTracksMenu() + p.ratingSubmenu.Disabled = p.DisableRating + p.shareMenuItem.Disabled = p.DisableSharing || len(selected) != 1 + menu = p.menu + } else { + p.ensureRadiosMenu() + menu = p.radiosMenu + } + menu.ShowAtPosition(e.AbsolutePosition) +} + +func (p *PlayQueueList) ensureTracksMenu() { + if p.menu != nil { + return + } + playlist := fyne.NewMenuItem("Add to playlist...", func() { + if p.OnAddToPlaylist != nil { + p.OnAddToPlaylist(p.selectedItemIDs()) + } + }) + playlist.Icon = myTheme.PlaylistIcon + download := fyne.NewMenuItem("Download...", func() { + if p.OnDownload != nil { + p.OnDownload(p.selectedTracks(), "Selected tracks") + } + }) + download.Icon = theme.DownloadIcon() + p.shareMenuItem = fyne.NewMenuItem("Share...", func() { + if p.OnShare != nil { + p.OnShare(p.selectedTracks()) + } + }) + p.shareMenuItem.Icon = myTheme.ShareIcon + favorite := fyne.NewMenuItem("Set favorite", func() { + if p.OnSetFavorite != nil { + p.OnSetFavorite(p.selectedItemIDs(), true) + } + }) + favorite.Icon = myTheme.FavoriteIcon + unfavorite := fyne.NewMenuItem("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) + } + }) + remove := fyne.NewMenuItem("Remove from queue", func() { + if p.OnRemoveFromQueue != nil { + p.OnRemoveFromQueue(p.selectedItemIDs()) + } + }) + remove.Icon = theme.ContentRemoveIcon() + reorder := util.NewReorderTracksSubmenu(func(tro sharedutil.TrackReorderOp) { + if p.OnReorderItems != nil { + p.OnReorderItems(p.selectedItemIDs(), tro) + } + }) + + p.menu = widget.NewPopUpMenu( + fyne.NewMenu("", + playlist, + download, + p.shareMenuItem, + fyne.NewMenuItemSeparator(), + favorite, + unfavorite, + p.ratingSubmenu, + fyne.NewMenuItemSeparator(), + reorder, + remove, + ), + fyne.CurrentApp().Driver().CanvasForObject(p), + ) +} + +func (p *PlayQueueList) ensureRadiosMenu() { + if p.radiosMenu != nil { + return + } + remove := fyne.NewMenuItem("Remove from queue", func() { + if p.OnRemoveFromQueue != nil { + p.OnRemoveFromQueue(p.selectedItemIDs()) + } + }) + remove.Icon = theme.ContentRemoveIcon() + reorder := util.NewReorderTracksSubmenu(func(tro sharedutil.TrackReorderOp) { + if p.OnReorderItems != nil { + p.OnReorderItems(p.selectedItemIDs(), tro) + } + }) + p.radiosMenu = widget.NewPopUpMenu( + fyne.NewMenu("", remove, reorder), + fyne.CurrentApp().Driver().CanvasForObject(p), + ) +} + +func (t *PlayQueueList) selectedItems() []mediaprovider.MediaItem { + t.tracksMutex.RLock() + defer t.tracksMutex.RUnlock() + return util.SelectedItems(t.items) } func (t *PlayQueueList) selectedTracks() []*mediaprovider.Track { @@ -280,7 +330,7 @@ func (t *PlayQueueList) selectedTracks() []*mediaprovider.Track { return util.SelectedTracks(t.items) } -func (t *PlayQueueList) selectedTrackIDs() []string { +func (t *PlayQueueList) selectedItemIDs() []string { t.tracksMutex.RLock() defer t.tracksMutex.RUnlock() return util.SelectedItemIDs(t.items) From 0d848aecbd40565f5030c3e4b2f859b92f59a2c2 Mon Sep 17 00:00:00 2001 From: Drew Weymouth Date: Sun, 2 Jun 2024 08:46:07 -0700 Subject: [PATCH 22/25] don't crash with index out of range when removing radios from saved play queue --- backend/app.go | 2 +- backend/savedplayqueue.go | 7 +++++++ 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/backend/app.go b/backend/app.go index 4a3cca4..31a8970 100644 --- a/backend/app.go +++ b/backend/app.go @@ -378,7 +378,7 @@ func (a *App) LoadSavedPlayQueue() error { if err := a.PlaybackManager.LoadTracks(queue.Tracks, Replace, false); err != nil { return err } - if queue.TrackIndex >= 0 { + if queue.TrackIndex >= 0 && queue.TrackIndex < len(queue.Tracks) { // TODO: This isn't ideal but doesn't seem to cause an audible play-for-a-split-second artifact a.PlaybackManager.PlayTrackAt(queue.TrackIndex) a.PlaybackManager.Pause() diff --git a/backend/savedplayqueue.go b/backend/savedplayqueue.go index 62a944f..fd6e025 100644 --- a/backend/savedplayqueue.go +++ b/backend/savedplayqueue.go @@ -36,6 +36,13 @@ func SavePlayQueue(serverID string, pm *PlaybackManager, filepath string, server trackIDs = append(trackIDs, item.Metadata().ID) } } + if l := len(trackIDs); trackIdx >= l { + if l == 0 { + trackIdx = 0 + } else { + trackIdx = l - 1 + } + } saved := serializedSavedPlayQueue{ ServerID: serverID, From f1726385eb7df314e4c2aad0633efc9198e78625 Mon Sep 17 00:00:00 2001 From: Drew Weymouth Date: Sun, 2 Jun 2024 08:53:02 -0700 Subject: [PATCH 23/25] fix signature for OnSongChange callback for Tracks and Playlist pages --- ui/browsing/playlistpage.go | 6 ++++-- ui/browsing/trackspage.go | 6 ++++-- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/ui/browsing/playlistpage.go b/ui/browsing/playlistpage.go index c7dc245..4eb9f4b 100644 --- a/ui/browsing/playlistpage.go +++ b/ui/browsing/playlistpage.go @@ -126,8 +126,10 @@ func (a *PlaylistPage) Route() controller.Route { return controller.PlaylistRoute(a.playlistID) } -func (a *PlaylistPage) OnSongChange(track, lastScrobbledIfAny *mediaprovider.Track) { - a.nowPlayingID = sharedutil.MediaItemIDOrEmptyStr(track) +var _ CanShowNowPlaying = (*PlaylistPage)(nil) + +func (a *PlaylistPage) OnSongChange(item mediaprovider.MediaItem, lastScrobbledIfAny *mediaprovider.Track) { + a.nowPlayingID = sharedutil.MediaItemIDOrEmptyStr(item) a.tracklist.SetNowPlaying(a.nowPlayingID) a.tracklist.IncrementPlayCount(sharedutil.MediaItemIDOrEmptyStr(lastScrobbledIfAny)) } diff --git a/ui/browsing/trackspage.go b/ui/browsing/trackspage.go index d2bc587..2b6b505 100644 --- a/ui/browsing/trackspage.go +++ b/ui/browsing/trackspage.go @@ -95,8 +95,10 @@ func (t *TracksPage) Reload() { t.loader = widgets.NewTracklistLoader(t.tracklist, iter) } -func (t *TracksPage) OnSongChange(track, lastScrobbledIfAny *mediaprovider.Track) { - t.nowPlayingID = sharedutil.MediaItemIDOrEmptyStr(track) +var _ CanShowNowPlaying = (*TracksPage)(nil) + +func (t *TracksPage) OnSongChange(item mediaprovider.MediaItem, lastScrobbledIfAny *mediaprovider.Track) { + t.nowPlayingID = sharedutil.MediaItemIDOrEmptyStr(item) t.tracklist.SetNowPlaying(t.nowPlayingID) if t.searchTracklist != nil { t.searchTracklist.SetNowPlaying(t.nowPlayingID) From ef2e4c04e3d8cfa8da38baa201e99004104063fd Mon Sep 17 00:00:00 2001 From: Drew Weymouth Date: Sun, 2 Jun 2024 09:24:37 -0700 Subject: [PATCH 24/25] show home page url, hide rating/favorite widgets for radio --- ui/browsing/nowplayingpage.go | 6 ++++++ ui/widgets/largenowplayingcard.go | 22 ++++++++++++++++++---- 2 files changed, 24 insertions(+), 4 deletions(-) diff --git a/ui/browsing/nowplayingpage.go b/ui/browsing/nowplayingpage.go index 8f769cb..640f15d 100644 --- a/ui/browsing/nowplayingpage.go +++ b/ui/browsing/nowplayingpage.go @@ -6,6 +6,7 @@ import ( "image" "image/color" "log" + "net/url" "slices" "strings" "sync" @@ -113,6 +114,11 @@ func NewNowPlayingPage( a.card.OnArtistNameTapped = func(artistID string) { contr.NavigateTo(controller.ArtistRoute(artistID)) } + a.card.OnRadioURLTapped = func(urlText string) { + if u, err := url.Parse(urlText); err == nil { + fyne.CurrentApp().OpenURL(u) + } + } a.card.OnSetFavorite = func(fav bool) { a.contr.SetTrackFavorites([]string{a.nowPlayingID}, fav) } diff --git a/ui/widgets/largenowplayingcard.go b/ui/widgets/largenowplayingcard.go index e558ae4..84bdd2e 100644 --- a/ui/widgets/largenowplayingcard.go +++ b/ui/widgets/largenowplayingcard.go @@ -20,6 +20,7 @@ type LargeNowPlayingCard struct { DisableRating bool + isRadio bool trackName *widget.RichText artistName *MultiHyperlink albumName *widget.Hyperlink @@ -29,6 +30,7 @@ type LargeNowPlayingCard struct { OnArtistNameTapped func(artistID string) OnAlbumNameTapped func() + OnRadioURLTapped func(url string) OnSetRating func(rating int) OnSetFavorite func(favorite bool) } @@ -79,6 +81,12 @@ func (n *LargeNowPlayingCard) onAlbumNameTapped() { } func (n *LargeNowPlayingCard) onArtistNameTapped(artistID string) { + if n.isRadio { + if n.OnRadioURLTapped != nil { + n.OnRadioURLTapped(artistID) + } + return + } if n.OnArtistNameTapped != nil { n.OnArtistNameTapped(artistID) } @@ -119,17 +127,23 @@ func (n *LargeNowPlayingCard) Update(item mediaprovider.MediaItem) { } meta := item.Metadata() n.trackName.Segments[0].(*widget.TextSegment).Text = meta.Name - n.artistName.BuildSegments(meta.Artists, meta.ArtistIDs) n.albumName.Text = meta.Album + n.albumName.Hidden = meta.AlbumID == "" if tr, ok := item.(*mediaprovider.Track); ok { + n.artistName.BuildSegments(meta.Artists, meta.ArtistIDs) n.rating.Rating = tr.Rating n.favorite.Favorite = tr.Favorite n.cover.PlaceholderIcon = myTheme.TracksIcon - } else { - n.rating.Rating = 0 - n.favorite.Favorite = false + n.favorite.Hidden = false + n.rating.Hidden = false + n.isRadio = false + } else if rd, ok := item.(*mediaprovider.RadioStation); ok { + n.artistName.BuildSegments([]string{rd.HomePageURL}, []string{rd.HomePageURL}) + n.rating.Hidden = true + n.favorite.Hidden = true n.cover.PlaceholderIcon = myTheme.RadioIcon + n.isRadio = true } n.Refresh() From 068340d0c7a1817f22626d870866faf8e62845fc Mon Sep 17 00:00:00 2001 From: Drew Weymouth Date: Sun, 2 Jun 2024 09:31:09 -0700 Subject: [PATCH 25/25] fix non-hidden interpunct when rating/favorite are hidden --- ui/widgets/largenowplayingcard.go | 36 +++++++++++++++---------------- 1 file changed, 18 insertions(+), 18 deletions(-) diff --git a/ui/widgets/largenowplayingcard.go b/ui/widgets/largenowplayingcard.go index 84bdd2e..691dbcb 100644 --- a/ui/widgets/largenowplayingcard.go +++ b/ui/widgets/largenowplayingcard.go @@ -20,13 +20,14 @@ type LargeNowPlayingCard struct { DisableRating bool - isRadio bool - trackName *widget.RichText - artistName *MultiHyperlink - albumName *widget.Hyperlink - rating *StarRating - favorite *FavoriteIcon - cover *ImagePlaceholder + isRadio bool + trackName *widget.RichText + artistName *MultiHyperlink + albumName *widget.Hyperlink + rating *StarRating + favorite *FavoriteIcon + ratingFavoriteContainer *fyne.Container + cover *ImagePlaceholder OnArtistNameTapped func(artistID string) OnAlbumNameTapped func() @@ -51,17 +52,18 @@ func NewLargeNowPlayingCard() *LargeNowPlayingCard { n.cover.ScaleMode = canvas.ImageScaleFastest // set up the layout n.Content = n.cover + n.ratingFavoriteContainer = container.NewHBox( + layout.NewSpacer(), + n.favorite, + widget.NewLabel("·"), + n.rating, + layout.NewSpacer(), + ) n.Caption = container.New(layout.NewCustomPaddedVBoxLayout(theme.Padding()-13), n.trackName, n.artistName, n.albumName, - container.NewHBox( - layout.NewSpacer(), - n.favorite, - widget.NewLabel("·"), - n.rating, - layout.NewSpacer(), - ), + n.ratingFavoriteContainer, ) n.trackName.Segments[0].(*widget.TextSegment).Style.SizeName = theme.SizeNameSubHeadingText @@ -135,13 +137,11 @@ func (n *LargeNowPlayingCard) Update(item mediaprovider.MediaItem) { n.rating.Rating = tr.Rating n.favorite.Favorite = tr.Favorite n.cover.PlaceholderIcon = myTheme.TracksIcon - n.favorite.Hidden = false - n.rating.Hidden = false + n.ratingFavoriteContainer.Hidden = false n.isRadio = false } else if rd, ok := item.(*mediaprovider.RadioStation); ok { n.artistName.BuildSegments([]string{rd.HomePageURL}, []string{rd.HomePageURL}) - n.rating.Hidden = true - n.favorite.Hidden = true + n.ratingFavoriteContainer.Hidden = true n.cover.PlaceholderIcon = myTheme.RadioIcon n.isRadio = true }