diff --git a/backend/config.go b/backend/config.go index d45acff..8dfc16e 100644 --- a/backend/config.go +++ b/backend/config.go @@ -69,10 +69,6 @@ type FavoritesPageConfig struct { TracklistColumns []string } -type NowPlayingPageConfig struct { - TracklistColumns []string -} - type PlaylistPageConfig struct { TracklistColumns []string } @@ -117,21 +113,20 @@ type TranscodingConfig struct { } type Config struct { - Application AppConfig - Servers []*ServerConfig - AlbumPage AlbumPageConfig - AlbumsPage AlbumsPageConfig - ArtistPage ArtistPageConfig - FavoritesPage FavoritesPageConfig - NowPlayingPage NowPlayingPageConfig - PlaylistPage PlaylistPageConfig - PlaylistsPage PlaylistsPageConfig - TracksPage TracksPageConfig - LocalPlayback LocalPlaybackConfig - Scrobbling ScrobbleConfig - ReplayGain ReplayGainConfig - Transcoding TranscodingConfig - Theme ThemeConfig + Application AppConfig + Servers []*ServerConfig + AlbumPage AlbumPageConfig + AlbumsPage AlbumsPageConfig + ArtistPage ArtistPageConfig + FavoritesPage FavoritesPageConfig + PlaylistPage PlaylistPageConfig + PlaylistsPage PlaylistsPageConfig + TracksPage TracksPageConfig + LocalPlayback LocalPlaybackConfig + Scrobbling ScrobbleConfig + ReplayGain ReplayGainConfig + Transcoding TranscodingConfig + Theme ThemeConfig } var SupportedStartupPages = []string{"Albums", "Favorites", "Playlists"} @@ -167,9 +162,6 @@ func DefaultConfig(appVersionTag string) *Config { TracklistColumns: []string{"Artist", "Album", "Time", "Plays"}, InitialView: "Albums", }, - NowPlayingPage: NowPlayingPageConfig{ - TracklistColumns: []string{"Artist", "Album", "Time", "Plays"}, - }, PlaylistPage: PlaylistPageConfig{ TracklistColumns: []string{"Artist", "Album", "Time", "Plays"}, }, diff --git a/backend/imagemanager.go b/backend/imagemanager.go index 96da5bf..f8102ee 100644 --- a/backend/imagemanager.go +++ b/backend/imagemanager.go @@ -136,20 +136,26 @@ func (i *ImageManager) GetFullSizeCoverArt(coverID string) (image.Image, error) i.cachedFullSizeCoverAccessedAt = time.Now().UnixMilli() return i.cachedFullSizeCover, nil } - if i.s.Server == nil { - return nil, errors.New("logged out") - } + return i.getFullSizeCoverArtFromServer(context.Background(), coverID, nil) +} - i.serverFetchSema <- struct{}{} // acquire - im, err := i.s.Server.GetCoverArt(coverID, 0) - <-i.serverFetchSema // release - if err != nil { - return nil, err - } - i.cachedFullSizeCover = im - i.cachedFullSizeCoverID = coverID - i.cachedFullSizeCoverAccessedAt = time.Now().UnixMilli() - return im, nil +// GetCoverThumbnailAsync asynchronously fetches the cover image for the given ID, +// and invokes the callback on completion. It returns a context.CancelFunc which can be used to +// cancel the fetch. The callback will not be invoked if the fetch is cancelled before completion. +// The cancel func must be invoked to avoid resource leaks. Use GetCoverThumbnail if cancellation is not needed. +func (i *ImageManager) GetFullSizeCoverArtAsync(coverID string, cb func(image.Image, error)) context.CancelFunc { + ctx, cancel := context.WithCancel(context.Background()) + go func() { + if i.cachedFullSizeCoverID == coverID { + i.cachedFullSizeCoverAccessedAt = time.Now().UnixMilli() + if ctx.Err() == nil { + cb(i.cachedFullSizeCover, nil) + } + } else { + i.getFullSizeCoverArtFromServer(ctx, coverID, cb) + } + }() + return cancel } // GetCoverArtURL returns the URL for the locally cached cover thumbnail, if it exists. @@ -267,6 +273,33 @@ func (i *ImageManager) fetchAndCacheCoverFromServer(ctx context.Context, coverID } } +func (i *ImageManager) getFullSizeCoverArtFromServer(ctx context.Context, coverID string, cb func(image.Image, error)) (image.Image, error) { + if i.s.Server == nil { + err := errors.New("logged out") + if ctx.Err() == nil && cb != nil { + cb(nil, err) + } + return nil, err + } + + select { + case <-ctx.Done(): + return nil, context.Canceled + case i.serverFetchSema <- struct{}{}: // acquire + im, err := i.s.Server.GetCoverArt(coverID, 0) + <-i.serverFetchSema // release + if err == nil { + i.cachedFullSizeCover = im + i.cachedFullSizeCoverID = coverID + i.cachedFullSizeCoverAccessedAt = time.Now().UnixMilli() + } + if ctx.Err() == nil && cb != nil { + cb(im, err) + } + return im, err + } +} + func (i *ImageManager) checkRefreshLocalCover(stat os.FileInfo, coverID string, ttl time.Duration) { if time.Since(stat.ModTime()) > cachedImageValidTime { i.fetchAndCacheCoverFromServer(context.Background(), coverID, ttl, nil) diff --git a/backend/playbackengine.go b/backend/playbackengine.go index ebc8419..04eaed9 100644 --- a/backend/playbackengine.go +++ b/backend/playbackengine.go @@ -61,6 +61,7 @@ type playbackEngine struct { onStopped []func() onPlaying []func() onPlayerChange []func() + onQueueChange []func() } func NewPlaybackEngine( @@ -216,15 +217,21 @@ func (p *playbackEngine) LoadTracks(tracks []*mediaprovider.Track, appendToQueue if needToSetNext { p.setNextTrack(p.nowPlayingIdx + 1) } + + p.invokeNoArgCallbacks(p.onQueueChange) return nil } // Stop playback and clear the play queue. func (p *playbackEngine) StopAndClearPlayQueue() { + changed := len(p.playQueue) > 0 p.player.Stop() p.doUpdateTimePos() p.playQueue = nil p.nowPlayingIdx = -1 + if changed { + p.invokeNoArgCallbacks(p.onQueueChange) + } } func (p *playbackEngine) GetPlayQueue() []*mediaprovider.Track { @@ -273,6 +280,7 @@ func (p *playbackEngine) UpdatePlayQueue(tracks []*mediaprovider.Track) error { p.setNextTrackAfterQueueUpdate() } + p.invokeNoArgCallbacks(p.onQueueChange) return nil } @@ -321,6 +329,8 @@ func (p *playbackEngine) RemoveTracksFromQueue(trackIDs []string) { p.setNextTrack(-1) } } + + p.invokeNoArgCallbacks(p.onQueueChange) } func (p *playbackEngine) SetReplayGainOptions(config ReplayGainConfig) { diff --git a/backend/playbackmanager.go b/backend/playbackmanager.go index e5288a5..ae1c70d 100644 --- a/backend/playbackmanager.go +++ b/backend/playbackmanager.go @@ -74,6 +74,11 @@ func (p *PlaybackManager) OnVolumeChange(cb func(int)) { p.engine.onVolumeChange = append(p.engine.onVolumeChange, cb) } +// Registers a callback that is notified whenever the play queue changes. +func (p *PlaybackManager) OnQueueChange(cb func()) { + p.engine.onQueueChange = append(p.engine.onQueueChange, cb) +} + // Registers a callback that is notified whenever the player has been seeked. func (p *PlaybackManager) OnSeek(cb func()) { p.engine.onSeek = append(p.engine.onSeek, cb) diff --git a/sharedutil/sharedutil.go b/sharedutil/sharedutil.go index d05a909..b1c60d3 100644 --- a/sharedutil/sharedutil.go +++ b/sharedutil/sharedutil.go @@ -102,6 +102,13 @@ func TrackIDOrEmptyStr(track *mediaprovider.Track) string { return track.ID } +func AlbumIDOrEmptyStr(track *mediaprovider.Track) string { + if track == nil { + return "" + } + return track.AlbumID +} + func TracksToIDs(tracks []*mediaprovider.Track) []string { return MapSlice(tracks, func(tr *mediaprovider.Track) string { return tr.ID diff --git a/ui/bottompanel.go b/ui/bottompanel.go index e80f504..5332624 100644 --- a/ui/bottompanel.go +++ b/ui/bottompanel.go @@ -2,7 +2,6 @@ package ui import ( "image" - "log" "time" "github.com/dweymouth/supersonic/backend" @@ -53,13 +52,8 @@ func NewBottomPanel(pm *backend.PlaybackManager, contr *controller.Controller) * }) bp.NowPlaying = widgets.NewNowPlayingCard() - bp.NowPlaying.OnShowCoverImage = func() { - im, err := bp.ImageManager.GetFullSizeCoverArt(bp.coverArtID) - if err != nil { - log.Printf("error getting full size cover image: %s", err.Error()) - } else { - contr.ShowPopUpImage(im) - } + bp.NowPlaying.OnCoverTapped = func() { + contr.NavigateTo(controller.NowPlayingRoute("")) } bp.NowPlaying.OnSetFavorite = func(fav bool) { contr.SetTrackFavorites([]string{pm.NowPlaying().ID}, fav) diff --git a/ui/browsing/browsingpane.go b/ui/browsing/browsingpane.go index fc275b6..6168c15 100644 --- a/ui/browsing/browsingpane.go +++ b/ui/browsing/browsingpane.go @@ -49,6 +49,10 @@ type CanShowPlayTime interface { OnPlayTimeUpdate(curTime, totalTime float64) } +type CanShowPlayQueue interface { + OnPlayQueueChange() +} + type BrowsingPane struct { widget.BaseWidget @@ -80,6 +84,7 @@ func NewBrowsingPane(app *backend.App, contr *controller.Controller) *BrowsingPa b.reload = widget.NewButtonWithIcon("", theme.ViewRefreshIcon(), b.Reload) b.app.PlaybackManager.OnSongChange(b.onSongChange) b.app.PlaybackManager.OnPlayTimeUpdate(b.onPlayTimeUpdate) + b.app.PlaybackManager.OnQueueChange(b.onQueueChange) bkgrnd := myTheme.NewThemedRectangle(myTheme.ColorNamePageBackground) b.pageContainer = container.NewStack(bkgrnd, layout.NewSpacer()) b.settingsBtn = widget.NewButtonWithIcon("", theme.SettingsIcon(), func() { @@ -222,6 +227,15 @@ func (b *BrowsingPane) onPlayTimeUpdate(cur, total float64) { } } +func (b *BrowsingPane) onQueueChange() { + if b.curPage == nil { + return + } + if p, ok := b.curPage.(CanShowPlayQueue); ok { + p.OnPlayQueueChange() + } +} + func (b *BrowsingPane) addPageToHistory(p Page, truncate bool) { if truncate { // allow garbage collection of pages that will be removed from the history diff --git a/ui/browsing/nowplayingpage.go b/ui/browsing/nowplayingpage.go index 552fce0..e14afa3 100644 --- a/ui/browsing/nowplayingpage.go +++ b/ui/browsing/nowplayingpage.go @@ -1,7 +1,9 @@ package browsing import ( + "context" "fmt" + "image" "log" "strings" @@ -18,6 +20,7 @@ import ( "fyne.io/fyne/v2" "fyne.io/fyne/v2/container" + "fyne.io/fyne/v2/layout" "fyne.io/fyne/v2/theme" "fyne.io/fyne/v2/widget" ) @@ -27,34 +30,35 @@ type NowPlayingPage struct { nowPlayingPageState - queue []*mediaprovider.Track - totalTime float64 - - title *widget.RichText - tracklist *widgets.Tracklist - statusLabel *widget.Label - nowPlayingID string - container *fyne.Container + queue []*mediaprovider.Track + queueList *widgets.PlayQueueList + lyricsViewer *widgets.LyricsViewer + imageLoadCancel context.CancelFunc + card *widgets.LargeNowPlayingCard + statusLabel *widget.Label + totalTime float64 + nowPlayingID string + albumID string + container *fyne.Container } type nowPlayingPageState struct { contr *controller.Controller pool *util.WidgetPool - conf *backend.NowPlayingPageConfig pm *backend.PlaybackManager + im *backend.ImageManager canRate bool } func NewNowPlayingPage( - highlightedTrackID string, contr *controller.Controller, pool *util.WidgetPool, - conf *backend.NowPlayingPageConfig, + im *backend.ImageManager, pm *backend.PlaybackManager, canRate bool, ) *NowPlayingPage { a := &NowPlayingPage{nowPlayingPageState: nowPlayingPageState{ - contr: contr, pool: pool, conf: conf, pm: pm, canRate: canRate, + contr: contr, pool: pool, im: im, pm: pm, canRate: canRate, }} a.ExtendBaseWidget(a) @@ -62,51 +66,89 @@ func NewNowPlayingPage( pm.OnPlaying(a.formatStatusLine) pm.OnStopped(a.formatStatusLine) - if t := a.pool.Obtain(util.WidgetTypeTracklist); t != nil { - a.tracklist = t.(*widgets.Tracklist) - a.tracklist.Reset() - } else { - a.tracklist = widgets.NewTracklist(nil) + a.card = widgets.NewLargeNowPlayingCard() + a.card.DisableRating = !canRate + a.card.OnAlbumNameTapped = func() { + contr.NavigateTo(controller.AlbumRoute(a.albumID)) } - a.tracklist.SetVisibleColumns(conf.TracklistColumns) - a.tracklist.OnVisibleColumnsChanged = func(cols []string) { - a.conf.TracklistColumns = cols + a.card.OnArtistNameTapped = func(artistID string) { + contr.NavigateTo(controller.ArtistRoute(artistID)) } - remove := fyne.NewMenuItem("Remove from queue", a.onRemoveSelectedFromQueue) - remove.Icon = theme.ContentRemoveIcon() - a.tracklist.Options = widgets.TracklistOptions{ - AutoNumber: true, - DisablePlaybackMenu: true, - DisableRating: !canRate, - AuxiliaryMenuItems: []*fyne.MenuItem{ - util.NewReorderTracksSubmenu(a.doSetNewTrackOrder), - remove, - }, + a.card.OnSetFavorite = func(fav bool) { + a.contr.SetTrackFavorites([]string{a.nowPlayingID}, fav) } - contr.ConnectTracklistActions(a.tracklist) - // override the default OnPlayTrackAt handler b/c we don't need to re-load the tracks into the queue - a.tracklist.OnPlayTrackAt = a.onPlayTrackAt - a.title = widget.NewRichTextWithText("Now Playing") - a.title.Segments[0].(*widget.TextSegment).Style.SizeName = widget.RichTextStyleHeading.SizeName + a.card.OnSetRating = func(rating int) { + a.contr.SetTrackRatings([]string{a.nowPlayingID}, rating) + } + + a.queueList = widgets.NewPlayQueueList(a.im) + a.queueList.OnReorderTracks = a.doSetNewTrackOrder + a.queueList.OnDownload = contr.ShowDownloadDialog + a.queueList.OnAddToPlaylist = contr.DoAddTracksToPlaylistWorkflow + a.queueList.OnPlayTrackAt = func(tracknum int) { + _ = a.pm.PlayTrackAt(tracknum) + } + a.queueList.OnShowArtistPage = func(artistID string) { + a.contr.NavigateTo(controller.ArtistRoute(artistID)) + } + a.queueList.OnRemoveFromQueue = func(trackIDs []string) { + a.queueList.UnselectAll() + a.pm.RemoveTracksFromQueue(trackIDs) + } + a.queueList.OnSetRating = func(trackIDs []string, rating int) { + contr.SetTrackRatings(trackIDs, rating) + if sharedutil.SliceContains(trackIDs, a.nowPlayingID) { + a.card.SetDisplayedRating(rating) + } + } + a.queueList.OnSetFavorite = func(trackIDs []string, fav bool) { + contr.SetTrackFavorites(trackIDs, fav) + if sharedutil.SliceContains(trackIDs, a.nowPlayingID) { + a.card.SetDisplayedFavorite(fav) + } + } + + a.lyricsViewer = widgets.NewLyricsViewer() a.statusLabel = widget.NewLabel("Stopped") - statusLabelCtr := container.New(&layouts.VboxCustomPadding{ExtraPad: -5}, - myTheme.NewThemedRectangle(theme.ColorNameInputBorder), - a.statusLabel, - ) - a.container = container.New(&layouts.MaxPadLayout{PadLeft: 15, PadRight: 15, PadTop: 5, PadBottom: 15}, - container.NewBorder(a.title, statusLabelCtr, nil, nil, a.tracklist)) - a.load(highlightedTrackID) + + a.Reload() return a } func (a *NowPlayingPage) CreateRenderer() fyne.WidgetRenderer { + if a.container == nil { + paddedLayout := &layouts.PercentPadLayout{ + LeftRightObjectPercent: .8, + TopBottomObjectPercent: .8, + } + mainContent := container.NewGridWithColumns(2, + container.New(paddedLayout, a.card), + container.New(paddedLayout, + util.AddHeaderBackground( + container.NewAppTabs( + container.NewTabItem("Play Queue", + container.NewBorder(layout.NewSpacer(), nil, nil, nil, a.queueList)), + container.NewTabItem("Lyrics", a.lyricsViewer), + )))) + a.container = container.NewStack( + mainContent, + container.NewVBox( + layout.NewSpacer(), + container.NewBorder(nil, nil, util.NewHSpace(1), util.NewHSpace(1), + myTheme.NewThemedRectangle(theme.ColorNameInputBorder)), + a.statusLabel, + ), + ) + } return widget.NewSimpleRenderer(a.container) } func (a *NowPlayingPage) Save() SavedPage { - a.tracklist.Clear() - a.pool.Release(util.WidgetTypeTracklist, a.tracklist) + if a.imageLoadCancel != nil { + a.imageLoadCancel() + } nps := a.nowPlayingPageState + a.pool.Release(util.WidgetTypeNowPlayingPage, a) return &nps } @@ -114,20 +156,50 @@ func (a *NowPlayingPage) Route() controller.Route { return controller.NowPlayingRoute("") } -func (a *NowPlayingPage) Tapped(*fyne.PointEvent) { - a.tracklist.UnselectAll() -} - -func (a *NowPlayingPage) SelectAll() { - a.tracklist.SelectAll() -} - var _ CanShowNowPlaying = (*NowPlayingPage)(nil) func (a *NowPlayingPage) OnSongChange(song, lastScrobbledIfAny *mediaprovider.Track) { + if a.imageLoadCancel != nil { + a.imageLoadCancel() + } a.nowPlayingID = sharedutil.TrackIDOrEmptyStr(song) - a.tracklist.SetNowPlaying(a.nowPlayingID) - a.tracklist.IncrementPlayCount(sharedutil.TrackIDOrEmptyStr(lastScrobbledIfAny)) + a.queueList.SetNowPlaying(a.nowPlayingID) + + a.albumID = sharedutil.AlbumIDOrEmptyStr(song) + a.card.Update(song) + if song == nil { + a.card.SetCoverImage(nil) + return + } + a.imageLoadCancel = a.im.GetFullSizeCoverArtAsync(song.CoverArtID, func(img image.Image, err error) { + if err != nil { + log.Printf("error loading cover art: %v\n", err) + } else { + a.card.SetCoverImage(img) + } + }) +} + +func (a *NowPlayingPage) OnPlayQueueChange() { + a.Reload() +} + +func (a *NowPlayingPage) Reload() { + a.queue = a.pm.GetPlayQueue() + a.queueList.SetTracks(a.queue) + a.totalTime = 0.0 + for _, tr := range a.queue { + a.totalTime += float64(tr.Duration) + } + a.formatStatusLine() +} + +func (s *nowPlayingPageState) Restore() Page { + if page := s.pool.Obtain(util.WidgetTypeNowPlayingPage).(*NowPlayingPage); page != nil { + page.Reload() + return page + } + return NewNowPlayingPage(s.contr, s.pool, s.im, s.pm, s.canRate) } var _ CanShowPlayTime = (*NowPlayingPage)(nil) @@ -136,6 +208,29 @@ func (a *NowPlayingPage) OnPlayTimeUpdate(_, _ float64) { a.formatStatusLine() } +var _ CanSelectAll = (*NowPlayingPage)(nil) + +func (a *NowPlayingPage) SelectAll() { + a.queueList.SelectAll() +} + +var _ fyne.Tappable = (*NowPlayingPage)(nil) + +func (a *NowPlayingPage) Tapped(*fyne.PointEvent) { + a.queueList.UnselectAll() +} + +func (a *NowPlayingPage) doSetNewTrackOrder(trackIDs []string, op sharedutil.TrackReorderOp) { + idxs := make([]int, 0, len(trackIDs)) + for i, tr := range a.queue { + if sharedutil.SliceContains(trackIDs, tr.ID) { + idxs = append(idxs, i) + } + } + newTracks := sharedutil.ReorderTracks(a.queue, idxs, op) + a.pm.UpdatePlayQueue(newTracks) +} + func (a *NowPlayingPage) formatStatusLine() { curPlayer := a.pm.CurrentPlayer() playerStats := curPlayer.GetStatus() @@ -196,62 +291,3 @@ func (a *NowPlayingPage) formatMediaInfoStr(player player.BasePlayer) string { // i.e. 24 bit files get reported as 32 bit. Also b/c bit depth isn't meaningful for lossy. return fmt.Sprintf("%s %g kHz, %d kbps", codec, float64(audioInfo.Samplerate)/1000, audioInfo.Bitrate/1000) } - -func (a *NowPlayingPage) Reload() { - a.load("") -} - -var _ Scrollable = (*NowPlayingPage)(nil) - -func (a *NowPlayingPage) Scroll(scrollAmt float32) { - a.tracklist.Scroll(scrollAmt) -} - -func (a *NowPlayingPage) onPlayTrackAt(tracknum int) { - _ = a.pm.PlayTrackAt(tracknum) -} - -func (a *NowPlayingPage) onRemoveSelectedFromQueue() { - a.pm.RemoveTracksFromQueue(a.tracklist.SelectedTrackIDs()) - a.tracklist.UnselectAll() - a.Reload() -} - -func (a *NowPlayingPage) doSetNewTrackOrder(op sharedutil.TrackReorderOp) { - // Since the tracklist view may be sorted in a different order than the - // actual running order, we need to get the IDs of the selected tracks - // from the tracklist and convert them to indices in the *original* run order - ids := a.tracklist.SelectedTrackIDs() - idxs := make([]int, 0, len(ids)) - for i, tr := range a.queue { - if sharedutil.SliceContains(ids, tr.ID) { - idxs = append(idxs, i) - } - } - newTracks := sharedutil.ReorderTracks(a.queue, idxs, op) - a.pm.UpdatePlayQueue(newTracks) - - // force-switch back to unsorted view to show new track order - a.tracklist.SetSorting(widgets.TracklistSort{}) - a.tracklist.SetTracks(newTracks) - a.tracklist.UnselectAll() -} - -// does not make calls to server - can safely be run in UI callbacks -func (a *NowPlayingPage) load(highlightedTrackID string) { - a.queue = a.pm.GetPlayQueue() - a.tracklist.SetTracks(a.queue) - a.tracklist.SetNowPlaying(a.nowPlayingID) - if highlightedTrackID != "" { - a.tracklist.SelectAndScrollToTrack(highlightedTrackID) - } - a.totalTime = 0.0 - for _, tr := range a.queue { - a.totalTime += float64(tr.Duration) - } - a.formatStatusLine() -} - -func (s *nowPlayingPageState) Restore() Page { - return NewNowPlayingPage("", s.contr, s.pool, s.conf, s.pm, s.canRate) -} diff --git a/ui/browsing/router.go b/ui/browsing/router.go index 4c5b418..900ce7e 100644 --- a/ui/browsing/router.go +++ b/ui/browsing/router.go @@ -30,6 +30,7 @@ func NewRouter(app *backend.App, controller *controller.Controller, nav Navigati } func (r Router) CreatePage(rte controller.Route) Page { + _, canRate := r.App.ServerManager.Server.(mediaprovider.SupportsRating) switch rte.Page { case controller.Album: return NewAlbumPage(rte.Arg, &r.App.Config.AlbumPage, r.widgetPool, r.App.PlaybackManager, r.App.ServerManager.Server, r.App.ImageManager, r.Controller) @@ -41,13 +42,14 @@ func (r Router) CreatePage(rte controller.Route) Page { return NewArtistsPage(r.Controller, r.widgetPool, r.App.PlaybackManager, r.App.ServerManager.Server, r.App.ImageManager) case controller.Favorites: return NewFavoritesPage(&r.App.Config.FavoritesPage, r.widgetPool, r.Controller, r.App.ServerManager.Server, r.App.PlaybackManager, r.App.ImageManager) + case controller.Fullscreen: + return NewNowPlayingPage(r.Controller, r.widgetPool, r.App.ImageManager, r.App.PlaybackManager, canRate) case controller.Genre: return NewGenrePage(rte.Arg, r.widgetPool, r.Controller, r.App.PlaybackManager, r.App.ServerManager.Server, r.App.ImageManager) case controller.Genres: return NewGenresPage(r.Controller, r.App.ServerManager.Server) case controller.NowPlaying: - _, canRate := r.App.ServerManager.Server.(mediaprovider.SupportsRating) - return NewNowPlayingPage(rte.Arg, r.Controller, r.widgetPool, &r.App.Config.NowPlayingPage, r.App.PlaybackManager, canRate) + return NewNowPlayingPage(r.Controller, r.widgetPool, r.App.ImageManager, r.App.PlaybackManager, canRate) case controller.Playlist: return NewPlaylistPage(rte.Arg, &r.App.Config.PlaylistPage, r.widgetPool, r.Controller, r.App.ServerManager, r.App.PlaybackManager, r.App.ImageManager) case controller.Playlists: diff --git a/ui/controller/routes.go b/ui/controller/routes.go index dc2255a..a351b8f 100644 --- a/ui/controller/routes.go +++ b/ui/controller/routes.go @@ -8,6 +8,7 @@ const ( Albums Artist Artists + Fullscreen Genre Genres Favorites diff --git a/ui/layouts/percentpadlayout.go b/ui/layouts/percentpadlayout.go new file mode 100644 index 0000000..1ec8411 --- /dev/null +++ b/ui/layouts/percentpadlayout.go @@ -0,0 +1,44 @@ +package layouts + +import "fyne.io/fyne/v2" + +// Centers and pads a given item by a percent (0-1) +// of the width and height that should be taken by the objects +type PercentPadLayout struct { + LeftRightObjectPercent float32 + TopBottomObjectPercent float32 +} + +func (l *PercentPadLayout) MinSize(objects []fyne.CanvasObject) fyne.Size { + var objMinSize fyne.Size + for _, obj := range objects { + objMinSize = objMinSize.Max(obj.MinSize()) + } + if objMinSize.IsZero() { + return objMinSize + } + + return fyne.Size{ + Width: objMinSize.Width / l.LeftRightObjectPercent, + Height: objMinSize.Height / l.LeftRightObjectPercent, + } +} + +func (l *PercentPadLayout) Layout(objects []fyne.CanvasObject, size fyne.Size) { + if len(objects) == 0 { + return + } + + objSize := fyne.NewSize( + size.Width*l.LeftRightObjectPercent, + size.Height*l.TopBottomObjectPercent, + ) + objPos := fyne.NewPos( + size.Width*(1-l.LeftRightObjectPercent)/2, + size.Height*(1-l.TopBottomObjectPercent)/2, + ) + for _, obj := range objects { + obj.Resize(objSize) + obj.Move(objPos) + } +} diff --git a/ui/util/tracklistutil.go b/ui/util/tracklistutil.go new file mode 100644 index 0000000..f94049c --- /dev/null +++ b/ui/util/tracklistutil.go @@ -0,0 +1,101 @@ +package util + +import ( + "github.com/dweymouth/supersonic/backend/mediaprovider" + "github.com/dweymouth/supersonic/sharedutil" +) + +type TrackListModel struct { + Track *mediaprovider.Track + Selected bool +} + +func ToTrackListModels(trs []*mediaprovider.Track) []*TrackListModel { + return sharedutil.MapSlice(trs, func(tr *mediaprovider.Track) *TrackListModel { + return &TrackListModel{Track: tr, Selected: false} + }) +} + +func SelectedTrackModels(tracks []*TrackListModel) []*TrackListModel { + return sharedutil.FilterSlice(tracks, func(tm *TrackListModel) bool { + return tm.Selected + }) +} + +func SelectedTracks(tracks []*TrackListModel) []*mediaprovider.Track { + return sharedutil.MapSlice(SelectedTrackModels(tracks), func(tm *TrackListModel) *mediaprovider.Track { + return tm.Track + }) +} + +func SelectedTrackIDs(tracks []*TrackListModel) []string { + return sharedutil.MapSlice(SelectedTrackModels(tracks), func(tm *TrackListModel) string { + return tm.Track.ID + }) +} + +func SelectTrack(tracks []*TrackListModel, idx int) { + if tracks[idx].Selected { + return + } + UnselectAllTracks(tracks) + tracks[idx].Selected = true +} + +func SelectAllTracks(tracks []*TrackListModel) { + for _, tm := range tracks { + tm.Selected = true + } +} + +func UnselectAllTracks(tracks []*TrackListModel) { + for _, tm := range tracks { + tm.Selected = false + } +} + +func SelectTrackRange(tracks []*TrackListModel, idx int) { + if tracks[idx].Selected { + return + } + lastSelected := -1 + for i := len(tracks) - 1; i >= 0; i-- { + if tracks[i].Selected { + lastSelected = i + break + } + } + if lastSelected < 0 { + tracks[idx].Selected = true + return + } + from := minInt(idx, lastSelected) + to := maxInt(idx, lastSelected) + for i := from; i <= to; i++ { + tracks[i].Selected = true + } +} + +func FindTrackByID(tracks []*TrackListModel, id string) (*mediaprovider.Track, int) { + idx := sharedutil.Find(tracks, func(tr *TrackListModel) bool { + return tr.Track.ID == id + }) + if idx >= 0 { + return tracks[idx].Track, idx + } + return nil, -1 +} + +func minInt(a, b int) int { + if a < b { + return a + } + return b +} + +func maxInt(a, b int) int { + if a > b { + return a + } + return b +} diff --git a/ui/util/util.go b/ui/util/util.go index f00c4ef..8513276 100644 --- a/ui/util/util.go +++ b/ui/util/util.go @@ -212,6 +212,24 @@ func AddHeaderBackground(obj fyne.CanvasObject) *fyne.Container { obj)) } +func NewTruncatingRichText() *widget.RichText { + rt := widget.NewRichTextWithText("") + rt.Truncation = fyne.TextTruncateEllipsis + return rt +} + +func NewTruncatingLabel() *widget.Label { + rt := widget.NewLabel("") + rt.Truncation = fyne.TextTruncateEllipsis + return rt +} + +func NewTrailingAlignLabel() *widget.Label { + rt := widget.NewLabel("") + rt.Alignment = fyne.TextAlignTrailing + return rt +} + type HSpace struct { widget.BaseWidget diff --git a/ui/util/widgetpool.go b/ui/util/widgetpool.go index 5a81d12..b8c574e 100644 --- a/ui/util/widgetpool.go +++ b/ui/util/widgetpool.go @@ -15,6 +15,7 @@ const ( WidgetTypeGridView WidgetTypePlaylistPageHeader WidgetTypeTracklist + WidgetTypeNowPlayingPage // keep at bottom numWidgetTypes diff --git a/ui/widgets/captionedimage.go b/ui/widgets/captionedimage.go new file mode 100644 index 0000000..83d858d --- /dev/null +++ b/ui/widgets/captionedimage.go @@ -0,0 +1,113 @@ +package widgets + +import ( + "fyne.io/fyne/v2" + "fyne.io/fyne/v2/widget" +) + +type Aspectable interface { + fyne.CanvasObject + Aspect() float32 +} + +type CaptionedImage struct { + widget.BaseWidget + + Content Aspectable + Caption fyne.CanvasObject +} + +var _ fyne.Widget = (*CaptionedImage)(nil) + +func NewCaptionedImage(content Aspectable, caption fyne.CanvasObject) *CaptionedImage { + c := &CaptionedImage{Content: content, Caption: caption} + c.ExtendBaseWidget(c) + return c +} + +func (c *CaptionedImage) CreateRenderer() fyne.WidgetRenderer { + return &captionedImageRenderer{ + ci: c, + } +} + +type captionedImageRenderer struct { + ci *CaptionedImage +} + +var _ fyne.WidgetRenderer = (*captionedImageRenderer)(nil) + +func (*captionedImageRenderer) Destroy() { + // intentionally blank +} + +func (c *captionedImageRenderer) Layout(s fyne.Size) { + content := c.ci.Content + aspect := content.Aspect() + caption := c.ci.Caption + var captionHeight float32 + if caption != nil { + captionHeight = caption.MinSize().Height + } + + // max height that could be allocated to Content + contentMaxedHeight := s.Height + if caption != nil { + contentMaxedHeight -= captionHeight + } + + // aspect ratio of Content if it were maxed out + maxedAspect := s.Width / contentMaxedHeight + if maxedAspect > aspect { + // Will use full height, but not full width + width := contentMaxedHeight * aspect + content.Resize(fyne.NewSize(width, contentMaxedHeight)) + xStart := (s.Width - width) / 2 + content.Move(fyne.NewPos(xStart, 0)) + if caption != nil { + caption.Resize(fyne.NewSize(width, captionHeight)) + caption.Move(fyne.NewPos(xStart, contentMaxedHeight)) + } + return + } + + // Content will not use full height + // Positioning of Content and Caption will be adjusted to center + contentH := s.Width / aspect + yStart := (s.Height - contentH - captionHeight) / 2 + content.Resize(fyne.NewSize(s.Width, contentH)) + content.Move(fyne.NewPos(0, yStart)) + if caption != nil { + caption.Resize(fyne.NewSize(s.Width, captionHeight)) + caption.Move(fyne.NewPos(0, yStart+contentH)) + } +} + +func (c *captionedImageRenderer) MinSize() fyne.Size { + objSize := c.ci.Content.MinSize() + if c.ci.Caption == nil { + return objSize + } + cptSize := c.ci.Caption.MinSize() + return fyne.NewSize( + fyne.Max(objSize.Width, cptSize.Width), + objSize.Height+cptSize.Height, + ) +} + +func (c *captionedImageRenderer) Objects() []fyne.CanvasObject { + if c.ci.Caption != nil { + return []fyne.CanvasObject{c.ci.Content, c.ci.Caption} + } + return []fyne.CanvasObject{c.ci.Content} +} + +func (c *captionedImageRenderer) Refresh() { + c.ci.Content.Refresh() + if cap := c.ci.Caption; cap != nil { + cap.Refresh() + } + // aspect may have changed on Content, + // so we need to re-layout + c.Layout(c.ci.Size()) +} diff --git a/ui/widgets/favoriteicon.go b/ui/widgets/favoriteicon.go new file mode 100644 index 0000000..5f1f2ce --- /dev/null +++ b/ui/widgets/favoriteicon.go @@ -0,0 +1,27 @@ +package widgets + +import ( + "github.com/dweymouth/supersonic/ui/theme" +) + +type FavoriteIcon struct { + TappableIcon + + Favorite bool +} + +func NewFavoriteIcon() *FavoriteIcon { + f := &FavoriteIcon{} + f.Resource = theme.NotFavoriteIcon + f.ExtendBaseWidget(f) + return f +} + +func (f *FavoriteIcon) Refresh() { + if f.Favorite { + f.Resource = theme.FavoriteIcon + } else { + f.Resource = theme.NotFavoriteIcon + } + f.BaseWidget.Refresh() +} diff --git a/ui/widgets/imageplaceholder.go b/ui/widgets/imageplaceholder.go index 03c3a82..de552ae 100644 --- a/ui/widgets/imageplaceholder.go +++ b/ui/widgets/imageplaceholder.go @@ -51,6 +51,13 @@ func NewImagePlaceholder(centerIcon fyne.Resource, minSize float32) *ImagePlaceh return i } +func (i *ImagePlaceholder) Aspect() float32 { + if i.image != nil { + return i.imageDisp.Aspect() + } + return 1.0 +} + func (i *ImagePlaceholder) HaveImage() bool { return i.image != nil } diff --git a/ui/widgets/largenowplayingcard.go b/ui/widgets/largenowplayingcard.go new file mode 100644 index 0000000..4278519 --- /dev/null +++ b/ui/widgets/largenowplayingcard.go @@ -0,0 +1,132 @@ +package widgets + +import ( + "image" + + "fyne.io/fyne/v2" + "fyne.io/fyne/v2/canvas" + "fyne.io/fyne/v2/container" + "fyne.io/fyne/v2/layout" + "fyne.io/fyne/v2/theme" + "fyne.io/fyne/v2/widget" + "github.com/dweymouth/supersonic/backend/mediaprovider" + "github.com/dweymouth/supersonic/ui/layouts" + myTheme "github.com/dweymouth/supersonic/ui/theme" +) + +// Shows the current album art, track name, artist name, and album name +// for the currently playing track. Placed into the left side of the BottomPanel. +type LargeNowPlayingCard struct { + CaptionedImage + + DisableRating bool + + trackName *widget.RichText + artistName *MultiHyperlink + albumName *widget.Hyperlink + rating *StarRating + favorite *FavoriteIcon + cover *ImagePlaceholder + + OnArtistNameTapped func(artistID string) + OnAlbumNameTapped func() + OnSetRating func(rating int) + OnSetFavorite func(favorite bool) +} + +func NewLargeNowPlayingCard() *LargeNowPlayingCard { + n := &LargeNowPlayingCard{ + trackName: widget.NewRichTextWithText(""), + artistName: NewMultiHyperlink(), + albumName: widget.NewHyperlink("", nil), + rating: NewStarRating(), + favorite: NewFavoriteIcon(), + cover: NewImagePlaceholder(myTheme.TracksIcon, 300), + } + n.ExtendBaseWidget(n) + n.rating.StarSize = theme.IconInlineSize() + theme.InnerPadding()/2 + n.rating.OnRatingChanged = n.onSetRating + n.favorite.OnTapped = n.onToggleFavorite + n.cover.ScaleMode = canvas.ImageScaleFastest + // set up the layout + n.Content = n.cover + n.Caption = container.New(&layouts.VboxCustomPadding{ExtraPad: -13}, + n.trackName, + n.artistName, + n.albumName, + container.NewHBox( + layout.NewSpacer(), + n.favorite, + widget.NewLabel("ยท"), + n.rating, + layout.NewSpacer(), + ), + ) + + n.trackName.Segments[0].(*widget.TextSegment).Style.SizeName = theme.SizeNameSubHeadingText + n.trackName.Truncation = fyne.TextTruncateEllipsis + n.albumName.Truncation = fyne.TextTruncateEllipsis + n.trackName.Truncation = fyne.TextTruncateEllipsis + n.albumName.OnTapped = n.onAlbumNameTapped + n.artistName.OnTapped = n.onArtistNameTapped + + return n +} + +func (n *LargeNowPlayingCard) onAlbumNameTapped() { + if n.OnAlbumNameTapped != nil { + n.OnAlbumNameTapped() + } +} + +func (n *LargeNowPlayingCard) onArtistNameTapped(artistID string) { + if n.OnArtistNameTapped != nil { + n.OnArtistNameTapped(artistID) + } +} + +func (n *LargeNowPlayingCard) onToggleFavorite() { + n.favorite.Favorite = !n.favorite.Favorite + n.favorite.Refresh() + if n.OnSetFavorite != nil { + n.OnSetFavorite(n.favorite.Favorite) + } +} + +func (n *LargeNowPlayingCard) onSetRating(rating int) { + if n.OnSetRating != nil { + n.OnSetRating(rating) + } +} + +func (n *LargeNowPlayingCard) SetDisplayedRating(rating int) { + n.rating.Rating = rating + n.rating.Refresh() +} + +func (n *LargeNowPlayingCard) SetDisplayedFavorite(favorite bool) { + n.favorite.Favorite = favorite + 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 { + n.trackName.Segments[0].(*widget.TextSegment).Text = "" + n.artistName.BuildSegments([]string{}, []string{}) + n.albumName.Text = "" + n.rating.Rating = 0 + n.favorite.Favorite = false + } + + n.Refresh() +} + +func (n *LargeNowPlayingCard) SetCoverImage(im image.Image) { + n.cover.SetImage(im, false) +} diff --git a/ui/widgets/lyricsviewer.go b/ui/widgets/lyricsviewer.go new file mode 100644 index 0000000..683771f --- /dev/null +++ b/ui/widgets/lyricsviewer.go @@ -0,0 +1,15 @@ +package widgets + +import "fyne.io/fyne/v2/widget" + +type LyricsViewer struct { + widget.Label +} + +func NewLyricsViewer() *LyricsViewer { + l := &LyricsViewer{Label: widget.Label{ + Text: "Lyrics not available", + }} + l.ExtendBaseWidget(l) + return l +} diff --git a/ui/widgets/nowplayingcard.go b/ui/widgets/nowplayingcard.go index ec3634d..0ab4368 100644 --- a/ui/widgets/nowplayingcard.go +++ b/ui/widgets/nowplayingcard.go @@ -30,7 +30,7 @@ type NowPlayingCard struct { OnTrackNameTapped func() OnArtistNameTapped func(artistID string) OnAlbumNameTapped func() - OnShowCoverImage func() + OnCoverTapped func() OnSetRating func(rating int) OnSetFavorite func(favorite bool) OnAddToPlaylist func() @@ -52,6 +52,7 @@ func NewNowPlayingCard() *NowPlayingCard { n.trackName.TextStyle.Bold = true n.cover.SetMinSize(fyne.NewSize(85, 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 @@ -84,8 +85,8 @@ func (n *NowPlayingCard) onTrackNameTapped() { } func (n *NowPlayingCard) onShowCoverImage(*fyne.PointEvent) { - if n.OnShowCoverImage != nil { - n.OnShowCoverImage() + if n.OnCoverTapped != nil { + n.OnCoverTapped() } } diff --git a/ui/widgets/playqueuelist.go b/ui/widgets/playqueuelist.go new file mode 100644 index 0000000..c8855d9 --- /dev/null +++ b/ui/widgets/playqueuelist.go @@ -0,0 +1,374 @@ +package widgets + +import ( + "image" + "strconv" + "sync" + + "fyne.io/fyne/v2" + "fyne.io/fyne/v2/canvas" + "fyne.io/fyne/v2/container" + "fyne.io/fyne/v2/driver/desktop" + "fyne.io/fyne/v2/layout" + "fyne.io/fyne/v2/theme" + "fyne.io/fyne/v2/widget" + "github.com/dweymouth/supersonic/backend" + "github.com/dweymouth/supersonic/backend/mediaprovider" + "github.com/dweymouth/supersonic/sharedutil" + "github.com/dweymouth/supersonic/ui/layouts" + "github.com/dweymouth/supersonic/ui/os" + myTheme "github.com/dweymouth/supersonic/ui/theme" + "github.com/dweymouth/supersonic/ui/util" +) + +const thumbnailSize = 52 + +type PlayQueueList struct { + widget.BaseWidget + + DisableRating bool + + // user action callbacks + OnAddToPlaylist func(trackIDs []string) + OnSetFavorite func(trackIDs []string, fav bool) + OnSetRating func(trackIDs []string, rating int) + OnRemoveFromQueue func(trackIDs []string) + OnDownload func(tracks []*mediaprovider.Track, downloadName string) + OnShowArtistPage func(artistID string) + OnPlayTrackAt func(idx int) + OnReorderTracks func(trackIDs []string, op sharedutil.TrackReorderOp) + + list *FocusList + menu *widget.PopUpMenu + ratingSubmenu *fyne.MenuItem + + nowPlayingID string + colLayout *layouts.ColumnsLayout + + tracksMutex sync.RWMutex + tracks []*util.TrackListModel +} + +func NewPlayQueueList(im *backend.ImageManager) *PlayQueueList { + p := &PlayQueueList{} + p.ExtendBaseWidget(p) + + // #, Cover, Title/Artist, Time + coverWidth := NewPlayQueueListRow(p, im, layout.NewSpacer()).cover.MinSize().Width + p.colLayout = layouts.NewColumnsLayout([]float32{40, coverWidth, -1, 60}) + + playIconResource := theme.NewThemedResource(theme.MediaPlayIcon()) + playIconResource.ColorName = theme.ColorNamePrimary + playIconImg := canvas.NewImageFromResource(playIconResource) + playIconImg.FillMode = canvas.ImageFillContain + playIconImg.SetMinSize(fyne.NewSquareSize(theme.IconInlineSize() * 1.5)) + + playingIcon := container.NewCenter(playIconImg) + + p.list = NewFocusList( + p.lenTracks, + func() fyne.CanvasObject { + return NewPlayQueueListRow(p, im, playingIcon) + }, + func(itemID widget.ListItemID, item fyne.CanvasObject) { + p.tracksMutex.RLock() + // 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) { + p.tracksMutex.RUnlock() + return + } + model := p.tracks[itemID] + p.tracksMutex.RUnlock() + + tr := item.(*PlayQueueListRow) + p.list.SetItemForID(itemID, tr) + if tr.trackID != model.Track.ID || tr.ListItemID != itemID { + tr.ListItemID = itemID + } + tr.Update(model, itemID+1) + }, + ) + + return p +} + +func (p *PlayQueueList) SetTracks(trs []*mediaprovider.Track) { + p.tracksMutex.Lock() + p.list.ClearItemForIDMap() + p.tracks = util.ToTrackListModels(trs) + p.tracksMutex.Unlock() + p.Refresh() +} + +// Sets the currently playing track ID and updates the list rendering +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) + p.tracksMutex.RUnlock() + p.nowPlayingID = trackID + if trPrev != nil { + p.list.RefreshItem(idxPrev) + } + if tr != nil { + p.list.RefreshItem(idx) + } +} + +func (p *PlayQueueList) SelectAll() { + p.tracksMutex.RLock() + util.SelectAllTracks(p.tracks) + p.tracksMutex.RUnlock() + p.list.Refresh() +} + +func (p *PlayQueueList) UnselectAll() { + p.tracksMutex.RLock() + util.UnselectAllTracks(p.tracks) + p.tracksMutex.RUnlock() + p.Refresh() +} + +func (p *PlayQueueList) lenTracks() int { + p.tracksMutex.RLock() + defer p.tracksMutex.RUnlock() + return len(p.tracks) +} + +func (t *PlayQueueList) onArtistTapped(artistID string) { + if t.OnShowArtistPage != nil { + t.OnShowArtistPage(artistID) + } +} + +func (p *PlayQueueList) onPlayTrackAt(idx int) { + if p.OnPlayTrackAt != nil { + p.OnPlayTrackAt(idx) + } +} + +func (p *PlayQueueList) onSelectTrack(idx int) { + if d, ok := fyne.CurrentApp().Driver().(desktop.Driver); ok { + mod := d.CurrentKeyModifiers() + if mod&os.ControlModifier != 0 { + p.selectAddOrRemove(idx) + } else if mod&fyne.KeyModifierShift != 0 { + p.selectRange(idx) + } else { + p.selectTrack(idx) + } + } else { + p.selectTrack(idx) + } + p.Refresh() +} + +func (p *PlayQueueList) selectTrack(idx int) { + p.tracksMutex.RLock() + defer p.tracksMutex.RUnlock() + util.SelectTrack(p.tracks, idx) +} + +func (p *PlayQueueList) selectAddOrRemove(idx int) { + p.tracksMutex.RLock() + defer p.tracksMutex.RUnlock() + p.tracks[idx].Selected = !p.tracks[idx].Selected +} + +func (p *PlayQueueList) selectRange(idx int) { + p.tracksMutex.RLock() + defer p.tracksMutex.RUnlock() + util.SelectTrackRange(p.tracks, idx) +} + +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() + 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.OnReorderTracks != nil { + p.OnReorderTracks(p.selectedTrackIDs(), tro) + } + }) + + p.menu = widget.NewPopUpMenu( + fyne.NewMenu("", + playlist, + download, + fyne.NewMenuItemSeparator(), + favorite, + unfavorite, + p.ratingSubmenu, + fyne.NewMenuItemSeparator(), + reorder, + remove, + ), + fyne.CurrentApp().Driver().CanvasForObject(p), + ) + } + p.ratingSubmenu.Disabled = p.DisableRating + p.menu.ShowAtPosition(e.AbsolutePosition) +} + +func (t *PlayQueueList) selectedTracks() []*mediaprovider.Track { + t.tracksMutex.RLock() + defer t.tracksMutex.RUnlock() + return util.SelectedTracks(t.tracks) +} + +func (t *PlayQueueList) selectedTrackIDs() []string { + t.tracksMutex.RLock() + defer t.tracksMutex.RUnlock() + return util.SelectedTrackIDs(t.tracks) +} + +func (p *PlayQueueList) CreateRenderer() fyne.WidgetRenderer { + return widget.NewSimpleRenderer(p.list) +} + +type PlayQueueListRow struct { + FocusListRowBase + + OnTappedSecondary func(e *fyne.PointEvent, trackIdx int) + + imageLoader util.ThumbnailLoader + playQueueList *PlayQueueList + trackID string + isPlaying bool + + playingIcon fyne.CanvasObject + num *widget.Label + cover *ImagePlaceholder + title *widget.Label + artist *MultiHyperlink + time *widget.Label +} + +func NewPlayQueueListRow(playQueueList *PlayQueueList, im *backend.ImageManager, playingIcon fyne.CanvasObject) *PlayQueueListRow { + p := &PlayQueueListRow{ + playingIcon: playingIcon, + playQueueList: playQueueList, + num: widget.NewLabel(""), + cover: NewImagePlaceholder(myTheme.TracksIcon, thumbnailSize), + title: util.NewTruncatingLabel(), + artist: NewMultiHyperlink(), + time: util.NewTrailingAlignLabel(), + } + p.ExtendBaseWidget(p) + + p.cover.ScaleMode = canvas.ImageScaleFastest + p.artist.OnTapped = playQueueList.onArtistTapped + p.OnDoubleTapped = func() { + playQueueList.onPlayTrackAt(p.ItemID()) + } + p.OnTapped = func() { + playQueueList.onSelectTrack(p.ItemID()) + } + p.OnTappedSecondary = playQueueList.onShowContextMenu + p.OnFocusNeighbor = func(up bool) { + playQueueList.list.FocusNeighbor(p.ItemID(), up) + } + + p.imageLoader = util.NewThumbnailLoader(im, func(i image.Image) { + p.cover.SetImage(i, false) + }) + p.imageLoader.OnBeforeLoad = func() { + p.cover.SetImage(nil, false) + } + + p.Content = container.New(playQueueList.colLayout, + container.NewCenter(p.num), + container.NewPadded(p.cover), + container.New(&layouts.VboxCustomPadding{ExtraPad: -15}, + p.title, p.artist), + container.NewCenter(p.time), + ) + return p +} + +func (p *PlayQueueListRow) TappedSecondary(e *fyne.PointEvent) { + if p.OnTappedSecondary != nil { + p.OnTappedSecondary(e, p.ListItemID) + } +} + +func (p *PlayQueueListRow) Update(tm *util.TrackListModel, rowNum int) { + changed := false + if tm.Selected != p.Selected { + p.Selected = tm.Selected + changed = true + } + + if num := strconv.Itoa(rowNum); p.num.Text != num { + p.num.Text = num + changed = true + } + + // 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) + p.EnsureUnfocused() + p.trackID = tr.ID + p.title.Text = tr.Name + p.artist.BuildSegments(tr.ArtistNames, tr.ArtistIDs) + p.time.Text = util.SecondsToTimeString(float64(tr.Duration)) + changed = true + } + + // Render whether track is playing or not + if isPlaying := p.playQueueList.nowPlayingID == tr.ID; isPlaying != p.isPlaying { + p.isPlaying = isPlaying + p.title.TextStyle.Bold = isPlaying + + if isPlaying { + p.Content.(*fyne.Container).Objects[0] = p.playingIcon + } else { + p.Content.(*fyne.Container).Objects[0] = container.NewCenter(p.num) + } + changed = true + } + + if changed { + p.Refresh() + } +} diff --git a/ui/widgets/tracklist.go b/ui/widgets/tracklist.go index f975208..2a78968 100644 --- a/ui/widgets/tracklist.go +++ b/ui/widgets/tracklist.go @@ -99,8 +99,8 @@ type Tracklist struct { sorting TracklistSort tracksMutex sync.RWMutex - tracks []*trackModel - tracksOrigOrder []*trackModel + tracks []*util.TrackListModel + tracksOrigOrder []*util.TrackListModel nowPlayingID string colLayout *layouts.ColumnsLayout @@ -111,11 +111,6 @@ type Tracklist struct { container *fyne.Container } -type trackModel struct { - track *mediaprovider.Track - selected bool -} - func NewTracklist(tracks []*mediaprovider.Track) *Tracklist { t := &Tracklist{visibleColumns: make([]bool, numColumns)} t.ExtendBaseWidget(t) @@ -169,7 +164,7 @@ func NewTracklist(tracks []*mediaprovider.Track) *Tracklist { tr := item.(*TrackRow) t.list.SetItemForID(itemID, tr) - if tr.trackID != model.track.ID || tr.ListItemID != itemID { + if tr.trackID != model.Track.ID || tr.ListItemID != itemID { tr.ListItemID = itemID } i := -1 // signal that we want to display the actual track num. @@ -222,7 +217,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) { @@ -284,8 +279,8 @@ func (t *Tracklist) SetSorting(sorting TracklistSort) { func (t *Tracklist) SetNowPlaying(trackID string) { prevNowPlaying := t.nowPlayingID t.tracksMutex.RLock() - trPrev, idxPrev := t.findTrackByID(prevNowPlaying) - tr, idx := t.findTrackByID(trackID) + trPrev, idxPrev := util.FindTrackByID(t.tracks, prevNowPlaying) + tr, idx := util.FindTrackByID(t.tracks, trackID) t.tracksMutex.RUnlock() t.nowPlayingID = trackID if trPrev != nil { @@ -299,7 +294,7 @@ 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 := t.findTrackByID(trackID) + tr, idx := util.FindTrackByID(t.tracks, trackID) t.tracksMutex.RUnlock() if tr != nil { tr.PlayCount += 1 @@ -328,7 +323,7 @@ func (t *Tracklist) _setTracks(trs []*mediaprovider.Track) { if t.list != nil { t.list.ClearItemForIDMap() } - t.tracksOrigOrder = toTrackModels(trs) + t.tracksOrigOrder = util.ToTrackListModels(trs) t.doSortTracks() } @@ -336,8 +331,8 @@ func (t *Tracklist) _setTracks(trs []*mediaprovider.Track) { func (t *Tracklist) GetTracks() []*mediaprovider.Track { t.tracksMutex.RLock() defer t.tracksMutex.RUnlock() - return sharedutil.MapSlice(t.tracks, func(tm *trackModel) *mediaprovider.Track { - return tm.track + return sharedutil.MapSlice(t.tracks, func(tm *util.TrackListModel) *mediaprovider.Track { + return tm.Track }) } @@ -345,15 +340,13 @@ func (t *Tracklist) GetTracks() []*mediaprovider.Track { func (t *Tracklist) AppendTracks(trs []*mediaprovider.Track) { t.tracksMutex.Lock() defer t.tracksMutex.Unlock() - t.tracksOrigOrder = append(t.tracks, toTrackModels(trs)...) + t.tracksOrigOrder = append(t.tracks, util.ToTrackListModels(trs)...) t.doSortTracks() } func (t *Tracklist) SelectAll() { t.tracksMutex.RLock() - for _, tm := range t.tracks { - tm.selected = true - } + util.SelectAllTracks(t.tracks) t.tracksMutex.RUnlock() t.list.Refresh() } @@ -365,9 +358,7 @@ func (t *Tracklist) UnselectAll() { func (t *Tracklist) unselectAll() { t.tracksMutex.RLock() - for _, tm := range t.tracks { - tm.selected = false - } + util.UnselectAllTracks(t.tracks) t.tracksMutex.RUnlock() } @@ -375,11 +366,11 @@ func (t *Tracklist) SelectAndScrollToTrack(trackID string) { t.tracksMutex.RLock() idx := -1 for i, tr := range t.tracks { - if tr.track.ID == trackID { + if tr.Track.ID == trackID { idx = i - tr.selected = true + tr.Selected = true } else { - tr.selected = false + tr.Selected = false } } t.tracksMutex.RUnlock() @@ -397,18 +388,12 @@ func (t *Tracklist) Refresh() { t.BaseWidget.Refresh() } -func toTrackModels(trs []*mediaprovider.Track) []*trackModel { - return sharedutil.MapSlice(trs, func(tr *mediaprovider.Track) *trackModel { - return &trackModel{track: tr, selected: false} - }) -} - // do nothing Tapped handler so that tapping the separator between rows // doesn't fall through to the page (which calls UnselectAll on tracklist) func (t *Tracklist) Tapped(*fyne.PointEvent) {} -func (t *Tracklist) stringSort(fieldFn func(*trackModel) string) { - new := make([]*trackModel, len(t.tracksOrigOrder)) +func (t *Tracklist) stringSort(fieldFn func(*util.TrackListModel) string) { + new := make([]*util.TrackListModel, len(t.tracksOrigOrder)) copy(new, t.tracksOrigOrder) sort.SliceStable(new, func(i, j int) bool { cmp := strings.Compare(fieldFn(new[i]), fieldFn(new[j])) @@ -420,8 +405,8 @@ func (t *Tracklist) stringSort(fieldFn func(*trackModel) string) { t.tracks = new } -func (t *Tracklist) intSort(fieldFn func(*trackModel) int64) { - new := make([]*trackModel, len(t.tracksOrigOrder)) +func (t *Tracklist) intSort(fieldFn func(*util.TrackListModel) int64) { + new := make([]*util.TrackListModel, len(t.tracksOrigOrder)) copy(new, t.tracksOrigOrder) sort.SliceStable(new, func(i, j int) bool { if t.sorting.SortOrder == SortDescending { @@ -445,30 +430,30 @@ func (t *Tracklist) doSortTracks() { t.tracks = t.tracksOrigOrder } case ColumnTitle: - t.stringSort(func(tr *trackModel) string { return tr.track.Name }) + t.stringSort(func(tr *util.TrackListModel) string { return tr.Track.Name }) case ColumnArtist: - t.stringSort(func(tr *trackModel) 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 *trackModel) string { return tr.track.Album }) + t.stringSort(func(tr *util.TrackListModel) string { return tr.Track.Album }) case ColumnPath: - t.stringSort(func(tr *trackModel) string { return tr.track.FilePath }) + t.stringSort(func(tr *util.TrackListModel) string { return tr.Track.FilePath }) case ColumnRating: - t.intSort(func(tr *trackModel) int64 { return int64(tr.track.Rating) }) + t.intSort(func(tr *util.TrackListModel) int64 { return int64(tr.Track.Rating) }) case ColumnTime: - t.intSort(func(tr *trackModel) int64 { return int64(tr.track.Duration) }) + t.intSort(func(tr *util.TrackListModel) int64 { return int64(tr.Track.Duration) }) case ColumnYear: - t.intSort(func(tr *trackModel) int64 { return int64(tr.track.Year) }) + t.intSort(func(tr *util.TrackListModel) int64 { return int64(tr.Track.Year) }) case ColumnSize: - t.intSort(func(tr *trackModel) int64 { return tr.track.Size }) + t.intSort(func(tr *util.TrackListModel) int64 { return tr.Track.Size }) case ColumnPlays: - t.intSort(func(tr *trackModel) int64 { return int64(tr.track.PlayCount) }) + t.intSort(func(tr *util.TrackListModel) int64 { return int64(tr.Track.PlayCount) }) case ColumnComment: - t.stringSort(func(tr *trackModel) string { return tr.track.Comment }) + t.stringSort(func(tr *util.TrackListModel) string { return tr.Track.Comment }) case ColumnBitrate: - t.intSort(func(tr *trackModel) int64 { return int64(tr.track.BitRate) }) + t.intSort(func(tr *util.TrackListModel) int64 { return int64(tr.Track.BitRate) }) case ColumnFavorite: - t.intSort(func(tr *trackModel) int64 { - if tr.track.Favorite { + t.intSort(func(tr *util.TrackListModel) int64 { + if tr.Track.Favorite { return 1 } return 0 @@ -509,41 +494,19 @@ func (t *Tracklist) onSelectTrack(idx int) { func (t *Tracklist) selectAddOrRemove(idx int) { t.tracksMutex.RLock() defer t.tracksMutex.RUnlock() - t.tracks[idx].selected = !t.tracks[idx].selected + t.tracks[idx].Selected = !t.tracks[idx].Selected } func (t *Tracklist) selectTrack(idx int) { t.tracksMutex.RLock() defer t.tracksMutex.RUnlock() - if t.tracks[idx].selected { - return - } - t.unselectAll() - t.tracks[idx].selected = true + util.SelectTrack(t.tracks, idx) } func (t *Tracklist) selectRange(idx int) { t.tracksMutex.RLock() defer t.tracksMutex.RUnlock() - if t.tracks[idx].selected { - return - } - lastSelected := -1 - for i := len(t.tracks) - 1; i >= 0; i-- { - if t.tracks[i].selected { - lastSelected = i - break - } - } - if lastSelected < 0 { - t.tracks[idx].selected = true - return - } - from := minInt(idx, lastSelected) - to := maxInt(idx, lastSelected) - for i := from; i <= to; i++ { - t.tracks[i].selected = true - } + util.SelectTrackRange(t.tracks, idx) } func (t *Tracklist) onShowContextMenu(e *fyne.PointEvent, trackIdx int) { @@ -609,7 +572,7 @@ func (t *Tracklist) onShowContextMenu(e *fyne.PointEvent, trackIdx int) { func (t *Tracklist) onSetFavorite(trackID string, fav bool) { t.tracksMutex.RLock() - tr, _ := t.findTrackByID(trackID) + tr, _ := util.FindTrackByID(t.tracks, trackID) t.tracksMutex.RUnlock() t.onSetFavorites([]*mediaprovider.Track{tr}, fav, false) } @@ -630,7 +593,7 @@ 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, _ := t.findTrackByID(trackID) + tr, _ := util.FindTrackByID(t.tracks, trackID) t.tracksMutex.RUnlock() t.onSetRatings([]*mediaprovider.Track{tr}, rating, false) } @@ -666,34 +629,16 @@ func (t *Tracklist) onDownload(tracks []*mediaprovider.Track, downloadName strin } } -func (t *Tracklist) findTrackByID(id string) (*mediaprovider.Track, int) { - idx := sharedutil.Find(t.tracks, func(tr *trackModel) bool { - return tr.track.ID == id - }) - if idx >= 0 { - return t.tracks[idx].track, idx - } - return nil, -1 -} - -func (t *Tracklist) selectedTrackModels() []*trackModel { +func (t *Tracklist) selectedTracks() []*mediaprovider.Track { t.tracksMutex.RLock() defer t.tracksMutex.RUnlock() - return sharedutil.FilterSlice(t.tracks, func(tm *trackModel) bool { - return tm.selected - }) -} - -func (t *Tracklist) selectedTracks() []*mediaprovider.Track { - return sharedutil.MapSlice(t.selectedTrackModels(), func(tm *trackModel) *mediaprovider.Track { - return tm.track - }) + return util.SelectedTracks(t.tracks) } func (t *Tracklist) SelectedTrackIDs() []string { - return sharedutil.MapSlice(t.selectedTrackModels(), func(tm *trackModel) string { - return tm.track.ID - }) + t.tracksMutex.RLock() + defer t.tracksMutex.RUnlock() + return util.SelectedTrackIDs(t.tracks) } func (t *Tracklist) lenTracks() int { @@ -752,61 +697,43 @@ type TrackRow struct { func NewTrackRow(tracklist *Tracklist, playingIcon fyne.CanvasObject) *TrackRow { t := &TrackRow{tracklist: tracklist, playingIcon: playingIcon} t.ExtendBaseWidget(t) - t.num = newTrailingAlignLabel() - t.name = newTruncatingRichText() + t.num = util.NewTrailingAlignLabel() + t.name = util.NewTruncatingRichText() t.artist = NewMultiHyperlink() t.artist.OnTapped = tracklist.onArtistTapped t.album = widget.NewHyperlink("", nil) t.album.Truncation = fyne.TextTruncateEllipsis t.album.OnTapped = func() { tracklist.onAlbumTapped(t.albumID) } - t.dur = newTrailingAlignLabel() - t.year = newTrailingAlignLabel() - favorite := NewTappableIcon(myTheme.NotFavoriteIcon) + t.dur = util.NewTrailingAlignLabel() + t.year = util.NewTrailingAlignLabel() + favorite := NewFavoriteIcon() favorite.OnTapped = t.toggleFavorited t.favorite = container.NewCenter(favorite) t.rating = NewStarRating() t.rating.IsDisabled = t.tracklist.Options.DisableRating t.rating.StarSize = 16 t.rating.OnRatingChanged = t.setTrackRating - t.plays = newTrailingAlignLabel() - t.comment = newTruncatingLabel() - t.bitrate = newTrailingAlignLabel() - t.size = newTrailingAlignLabel() - t.path = newTruncatingLabel() + t.plays = util.NewTrailingAlignLabel() + t.comment = util.NewTruncatingLabel() + t.bitrate = util.NewTrailingAlignLabel() + t.size = util.NewTrailingAlignLabel() + t.path = util.NewTruncatingLabel() t.Content = container.New(tracklist.colLayout, t.num, t.name, t.artist, t.album, t.dur, t.year, t.favorite, t.rating, t.plays, t.comment, t.bitrate, t.size, t.path) return t } -func newTruncatingRichText() *widget.RichText { - rt := widget.NewRichTextWithText("") - rt.Truncation = fyne.TextTruncateEllipsis - return rt -} - -func newTruncatingLabel() *widget.Label { - rt := widget.NewLabel("") - rt.Truncation = fyne.TextTruncateEllipsis - return rt -} - -func newTrailingAlignLabel() *widget.Label { - rt := widget.NewLabel("") - rt.Alignment = fyne.TextAlignTrailing - return rt -} - -func (t *TrackRow) Update(tm *trackModel, rowNum int) { +func (t *TrackRow) Update(tm *util.TrackListModel, rowNum int) { changed := false - if tm.selected != t.Selected { - t.Selected = tm.selected + if tm.Selected != t.Selected { + t.Selected = tm.Selected changed = true } // Update info that can change if this row is bound to // a new track (*mediaprovider.Track) - tr := tm.track + tr := tm.Track if tr.ID != t.trackID { t.EnsureUnfocused() t.trackID = tr.ID @@ -859,7 +786,7 @@ func (t *TrackRow) Update(tm *trackModel, rowNum int) { t.name.Segments[0].(*widget.TextSegment).Style.TextStyle.Bold = isPlaying if isPlaying { - t.Content.(*fyne.Container).Objects[0] = container.NewCenter(t.playingIcon) + t.Content.(*fyne.Container).Objects[0] = t.playingIcon } else { t.Content.(*fyne.Container).Objects[0] = t.num } @@ -868,13 +795,8 @@ func (t *TrackRow) Update(tm *trackModel, rowNum int) { // Update favorite column if tr.Favorite != t.isFavorite { - if tr.Favorite { - t.isFavorite = true - t.favorite.Objects[0].(*TappableIcon).Resource = myTheme.FavoriteIcon - } else { - t.isFavorite = false - t.favorite.Objects[0].(*TappableIcon).Resource = myTheme.NotFavoriteIcon - } + t.isFavorite = tr.Favorite + t.favorite.Objects[0].(*FavoriteIcon).Favorite = tr.Favorite changed = true } @@ -914,17 +836,11 @@ func (t *TrackRow) Update(tm *trackModel, rowNum int) { } func (t *TrackRow) toggleFavorited() { - if t.isFavorite { - t.favorite.Objects[0].(*TappableIcon).Resource = myTheme.NotFavoriteIcon - t.favorite.Refresh() - t.isFavorite = false - t.tracklist.onSetFavorite(t.trackID, false) - } else { - t.favorite.Objects[0].(*TappableIcon).Resource = myTheme.FavoriteIcon - t.favorite.Refresh() - t.isFavorite = true - t.tracklist.onSetFavorite(t.trackID, true) - } + t.isFavorite = !t.isFavorite + favIcon := t.favorite.Objects[0].(*FavoriteIcon) + favIcon.Favorite = t.isFavorite + t.favorite.Refresh() + t.tracklist.onSetFavorite(t.trackID, t.isFavorite) } func (t *TrackRow) setTrackRating(rating int) { @@ -936,17 +852,3 @@ func (t *TrackRow) TappedSecondary(e *fyne.PointEvent) { t.OnTappedSecondary(e, t.ListItemID) } } - -func minInt(a, b int) int { - if a < b { - return a - } - return b -} - -func maxInt(a, b int) int { - if a > b { - return a - } - return b -}