Merge pull request #338 from dweymouth/feature/fullscreenpage

Add new Now Playing page with large cover image and new queue list
This commit is contained in:
Drew Weymouth
2024-03-03 14:12:32 -08:00
committed by GitHub
22 changed files with 1153 additions and 324 deletions
+14 -22
View File
@@ -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"},
},
+46 -13
View File
@@ -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)
+10
View File
@@ -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) {
+5
View File
@@ -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)
+7
View File
@@ -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
+2 -8
View File
@@ -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)
+14
View File
@@ -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
+149 -113
View File
@@ -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)
}
+4 -2
View File
@@ -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:
+1
View File
@@ -8,6 +8,7 @@ const (
Albums
Artist
Artists
Fullscreen
Genre
Genres
Favorites
+44
View File
@@ -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)
}
}
+101
View File
@@ -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
}
+18
View File
@@ -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
+1
View File
@@ -15,6 +15,7 @@ const (
WidgetTypeGridView
WidgetTypePlaylistPageHeader
WidgetTypeTracklist
WidgetTypeNowPlayingPage
// keep at bottom
numWidgetTypes
+113
View File
@@ -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())
}
+27
View File
@@ -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()
}
+7
View File
@@ -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
}
+132
View File
@@ -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)
}
+15
View File
@@ -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
}
+4 -3
View File
@@ -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()
}
}
+374
View File
@@ -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()
}
}
+65 -163
View File
@@ -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
}