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:
+14
-22
@@ -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
@@ -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)
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -8,6 +8,7 @@ const (
|
||||
Albums
|
||||
Artist
|
||||
Artists
|
||||
Fullscreen
|
||||
Genre
|
||||
Genres
|
||||
Favorites
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -15,6 +15,7 @@ const (
|
||||
WidgetTypeGridView
|
||||
WidgetTypePlaylistPageHeader
|
||||
WidgetTypeTracklist
|
||||
WidgetTypeNowPlayingPage
|
||||
|
||||
// keep at bottom
|
||||
numWidgetTypes
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user