diff --git a/ui/browsing/fullscreenpage.go b/ui/browsing/fullscreenpage.go index e992eb6..b652653 100644 --- a/ui/browsing/fullscreenpage.go +++ b/ui/browsing/fullscreenpage.go @@ -25,10 +25,12 @@ type FullscreenPage struct { fullscreenPageState queue []*mediaprovider.Track + queueList *widgets.PlayQueueList imageLoadCancel context.CancelFunc card *widgets.LargeNowPlayingCard nowPlayingID string albumID string + container *fyne.Container } type fullscreenPageState struct { @@ -66,25 +68,28 @@ func NewFullscreenPage( a.contr.SetTrackRatings([]string{a.nowPlayingID}, rating) } + a.queueList = widgets.NewPlayQueueList(a.im) + paddedLayout := &layouts.PercentPadLayout{ + LeftRightObjectPercent: .8, + TopBottomObjectPercent: .8, + } + a.container = 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", layout.NewSpacer()), + ))), + ) + a.Reload() return a } func (a *FullscreenPage) CreateRenderer() fyne.WidgetRenderer { - paddedLayout := &layouts.PercentPadLayout{ - LeftRightObjectPercent: .8, - TopBottomObjectPercent: .8, - } - container := container.NewGridWithColumns(2, - container.New(paddedLayout, a.card), - container.New(paddedLayout, - util.AddHeaderBackground( - container.NewAppTabs( - container.NewTabItem("Up Next", layout.NewSpacer()), - container.NewTabItem("Lyrics", layout.NewSpacer()), - ))), - ) - return widget.NewSimpleRenderer(container) + return widget.NewSimpleRenderer(a.container) } func (a *FullscreenPage) Save() SavedPage { @@ -115,10 +120,12 @@ func (a *FullscreenPage) OnSongChange(song, lastScrobbledIfAny *mediaprovider.Tr a.card.SetCoverImage(img) } }) + a.queueList.SetNowPlaying(song.ID) } func (a *FullscreenPage) Reload() { a.queue = a.pm.GetPlayQueue() + a.queueList.SetTracks(a.queue) } func (s *fullscreenPageState) Restore() Page { diff --git a/ui/util/tracklistutil.go b/ui/util/tracklistutil.go index 7a1b571..675c6e9 100644 --- a/ui/util/tracklistutil.go +++ b/ui/util/tracklistutil.go @@ -58,6 +58,16 @@ func SelectTrackRange(tracks []*TrackListModel, idx int) { } } +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 diff --git a/ui/widgets/playqueuelist.go b/ui/widgets/playqueuelist.go index 179a2ff..062ab57 100644 --- a/ui/widgets/playqueuelist.go +++ b/ui/widgets/playqueuelist.go @@ -1,20 +1,30 @@ 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/theme" "fyne.io/fyne/v2/widget" + "github.com/dweymouth/supersonic/backend" + "github.com/dweymouth/supersonic/backend/mediaprovider" "github.com/dweymouth/supersonic/ui/layouts" "github.com/dweymouth/supersonic/ui/os" - "github.com/dweymouth/supersonic/ui/theme" + myTheme "github.com/dweymouth/supersonic/ui/theme" "github.com/dweymouth/supersonic/ui/util" ) +const thumbnailSize = 60 + type PlayQueueList struct { - FocusList + widget.BaseWidget + + list *FocusList OnShowArtistPage func(artistID string) OnPlayTrackAt func(idx int) @@ -22,10 +32,84 @@ type PlayQueueList struct { nowPlayingID string colLayout *layouts.ColumnsLayout - tracksMutex sync.Mutex + tracksMutex sync.RWMutex tracks []*util.TrackListModel } +func NewPlayQueueList(im *backend.ImageManager) *PlayQueueList { + p := &PlayQueueList{} + p.ExtendBaseWidget(p) + + // #, Cover, Title/Artist, Time + p.colLayout = layouts.NewColumnsLayout([]float32{40, thumbnailSize, -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) 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) @@ -55,26 +139,37 @@ func (p *PlayQueueList) onSelectTrack(idx int) { } func (p *PlayQueueList) selectTrack(idx int) { - p.tracksMutex.Lock() - defer p.tracksMutex.Unlock() + p.tracksMutex.RLock() + defer p.tracksMutex.RUnlock() util.SelectTrack(p.tracks, idx) } func (p *PlayQueueList) selectAddOrRemove(idx int) { - p.tracksMutex.Lock() - defer p.tracksMutex.Unlock() + p.tracksMutex.RLock() + defer p.tracksMutex.RUnlock() p.tracks[idx].Selected = !p.tracks[idx].Selected } func (p *PlayQueueList) selectRange(idx int) { - p.tracksMutex.Lock() - defer p.tracksMutex.Unlock() + p.tracksMutex.RLock() + defer p.tracksMutex.RUnlock() util.SelectTrackRange(p.tracks, idx) } +func (p *PlayQueueList) onShowContextMenu(e *fyne.PointEvent, trackIdx int) { + +} + +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 @@ -87,16 +182,18 @@ type PlayQueueListRow struct { time *widget.Label } -func NewPlayQueueListRow(playQueueList *PlayQueueList, playingIcon fyne.CanvasObject) *PlayQueueListRow { +func NewPlayQueueListRow(playQueueList *PlayQueueList, im *backend.ImageManager, playingIcon fyne.CanvasObject) *PlayQueueListRow { p := &PlayQueueListRow{ playingIcon: playingIcon, playQueueList: playQueueList, num: widget.NewLabel(""), - cover: NewImagePlaceholder(theme.TracksIcon, 64), + cover: NewImagePlaceholder(myTheme.TracksIcon, thumbnailSize), title: util.NewTruncatingLabel(), artist: NewMultiHyperlink(), time: util.NewTrailingAlignLabel(), } + p.ExtendBaseWidget(p) + p.artist.OnTapped = playQueueList.onArtistTapped p.OnDoubleTapped = func() { playQueueList.onPlayTrackAt(p.ItemID()) @@ -104,8 +201,18 @@ func NewPlayQueueListRow(playQueueList *PlayQueueList, playingIcon fyne.CanvasOb p.OnTapped = func() { playQueueList.onSelectTrack(p.ItemID()) } - //p.title.TextStyle.Bold = true - p.ExtendBaseWidget(p) + 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), p.cover, @@ -116,6 +223,12 @@ func NewPlayQueueListRow(playQueueList *PlayQueueList, playingIcon fyne.CanvasOb 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 { @@ -123,12 +236,19 @@ func (p *PlayQueueListRow) Update(tm *util.TrackListModel, rowNum int) { 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 @@ -142,7 +262,7 @@ func (p *PlayQueueListRow) Update(tm *util.TrackListModel, rowNum int) { if isPlaying { p.Content.(*fyne.Container).Objects[0] = p.playingIcon } else { - p.Content.(*fyne.Container).Objects[0] = p.num + p.Content.(*fyne.Container).Objects[0] = container.NewCenter(p.num) } changed = true } diff --git a/ui/widgets/tracklist.go b/ui/widgets/tracklist.go index 1452a3b..4b17503 100644 --- a/ui/widgets/tracklist.go +++ b/ui/widgets/tracklist.go @@ -279,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 { @@ -294,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 @@ -572,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) } @@ -593,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) } @@ -629,16 +629,6 @@ 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 *util.TrackListModel) bool { - return tr.Track.ID == id - }) - if idx >= 0 { - return t.tracks[idx].Track, idx - } - return nil, -1 -} - func (t *Tracklist) selectedTrackModels() []*util.TrackListModel { t.tracksMutex.RLock() defer t.tracksMutex.RUnlock()