diff --git a/backend/albumiterator.go b/backend/albumiterator.go index 0d3bc66..3708766 100644 --- a/backend/albumiterator.go +++ b/backend/albumiterator.go @@ -14,15 +14,15 @@ type AlbumIterator interface { } type LibraryManager struct { - s *ServerManager - albumCache gcache.Cache + s *ServerManager + albumDetailCache gcache.Cache } func NewLibraryManager(s *ServerManager) *LibraryManager { cache := gcache.New(250).LRU().Build() return &LibraryManager{ - s: s, - albumCache: cache, + s: s, + albumDetailCache: cache, } } @@ -77,12 +77,12 @@ func (l *LibraryManager) SearchIter(query string) AlbumIterator { } func (l *LibraryManager) CacheAlbum(a *subsonic.AlbumID3) { - l.albumCache.Set(a.ID, a) + l.albumDetailCache.Set(a.ID, a) } func (l *LibraryManager) GetAlbum(id string) (*subsonic.AlbumID3, error) { - if l.albumCache.Has(id) { - if a, err := l.albumCache.Get(id); err == nil { + if l.albumDetailCache.Has(id) { + if a, err := l.albumDetailCache.Get(id); err == nil { return a.(*subsonic.AlbumID3), nil } } @@ -90,7 +90,7 @@ func (l *LibraryManager) GetAlbum(id string) (*subsonic.AlbumID3, error) { if err != nil { return nil, err } - l.albumCache.Set(a.ID, a) + l.albumDetailCache.Set(a.ID, a) return a, nil } @@ -126,7 +126,6 @@ func (r *baseIter) Next() *subsonic.AlbumID3 { } r.pos++ - r.l.CacheAlbum(a) return a } albums, err := r.s.GetAlbumList2(r.listType, map[string]string{"size": "20", "offset": strconv.Itoa(r.pos)}) @@ -138,14 +137,12 @@ func (r *baseIter) Next() *subsonic.AlbumID3 { r.done = true return nil } else if len(albums) == 1 { - r.l.CacheAlbum(albums[0]) r.done = true return albums[0] } r.prefetched = albums r.prefetchedPos = 1 - r.l.CacheAlbum(r.prefetched[0]) return r.prefetched[0] } @@ -239,7 +236,6 @@ func (s *searchIter) Next() *subsonic.AlbumID3 { s.prefetchedPos = 0 } - s.l.CacheAlbum(a) return a } @@ -349,7 +345,6 @@ func (r *randomIter) Next() *subsonic.AlbumID3 { r.prefetchedPos = 0 } - r.l.CacheAlbum(a) return a } diff --git a/backend/playbackmanager.go b/backend/playbackmanager.go index d0faffa..e63546e 100644 --- a/backend/playbackmanager.go +++ b/backend/playbackmanager.go @@ -124,11 +124,14 @@ func (p *PlaybackManager) LoadAlbum(albumID string, appendToQueue bool) error { return nil } -func (p *PlaybackManager) PlayAlbum(albumID string) error { +func (p *PlaybackManager) PlayAlbum(albumID string, firstTrack int) error { if err := p.LoadAlbum(albumID, false); err != nil { return err } - return p.player.PlayFromBeginning() + if firstTrack <= 0 { + return p.player.PlayFromBeginning() + } + return p.player.PlayTrackAt(firstTrack) } func (p *PlaybackManager) checkScrobble(playDur time.Duration) { diff --git a/player/player.go b/player/player.go index 04f8066..26e5108 100644 --- a/player/player.go +++ b/player/player.go @@ -4,6 +4,7 @@ import ( "context" "errors" "fmt" + "strconv" "github.com/wildeyedskies/go-mpv/mpv" ) @@ -226,6 +227,15 @@ func (p *Player) PlayFromBeginning() error { return err } +// Start playback from the specified track index in the play queue. +func (p *Player) PlayTrackAt(idx int) error { + err := p.mpv.Command([]string{"playlist-play-index", strconv.Itoa(idx)}) + if err == nil { + p.setState(Playing) + } + return err +} + // Begins playback if there is anything in the play queue and player is stopped or paused. // If player is playing, pauses playback. func (p *Player) PlayPause() error { diff --git a/ui/albumgrid.go b/ui/albumgrid.go index 6740c26..2f7507e 100644 --- a/ui/albumgrid.go +++ b/ui/albumgrid.go @@ -26,6 +26,7 @@ type AlbumGrid struct { imageFetcher ImageFetcher OnPlayAlbum func(string) + OnShowAlbumPage func(string) OnShowArtistPage func(string) } @@ -88,6 +89,11 @@ func (ag *AlbumGrid) createGridWrapList() { ag.OnShowArtistPage(ac.ArtistID()) } } + ac.OnShowAlbumPage = func() { + if ag.OnShowAlbumPage != nil { + ag.OnShowAlbumPage(ac.AlbumID()) + } + } return ac }, // update func diff --git a/ui/albumpage.go b/ui/albumpage.go new file mode 100644 index 0000000..bbea135 --- /dev/null +++ b/ui/albumpage.go @@ -0,0 +1,68 @@ +package ui + +import ( + "log" + "supersonic/backend" + "supersonic/ui/widgets" + + "fyne.io/fyne/v2" + "fyne.io/fyne/v2/container" + "fyne.io/fyne/v2/layout" + "fyne.io/fyne/v2/theme" + "fyne.io/fyne/v2/widget" +) + +type AlbumPage struct { + widget.BaseWidget + + albumID string + im *backend.ImageManager + lm *backend.LibraryManager + titleDisp *widget.RichText + tracklist *widgets.Tracklist + container *fyne.Container + + OnPlayAlbum func(string, int) +} + +func NewAlbumPage(albumID string, lm *backend.LibraryManager) *AlbumPage { + a := &AlbumPage{albumID: albumID, lm: lm} + a.ExtendBaseWidget(a) + a.titleDisp = widget.NewRichTextWithText("Artist") + a.titleDisp.Segments[0].(*widget.TextSegment).Style = widget.RichTextStyle{ + SizeName: theme.SizeNameHeadingText, + } + a.container = container.NewBorder(a.titleDisp, nil, nil, nil, layout.NewSpacer()) + a.loadAsync() + return a +} + +func (a *AlbumPage) CreateRenderer() fyne.WidgetRenderer { + return widget.NewSimpleRenderer(a.container) +} + +func (a *AlbumPage) SetPlayAlbumCallback(cb func(string, int)) { + a.OnPlayAlbum = cb +} + +func (a *AlbumPage) onPlayTrackAt(tracknum int) { + if a.OnPlayAlbum != nil { + a.OnPlayAlbum(a.albumID, tracknum) + } +} + +func (a *AlbumPage) loadAsync() { + go func() { + album, err := a.lm.GetAlbum(a.albumID) + if err != nil { + log.Printf("Failed to get album: %s", err.Error()) + return + } + a.titleDisp.Segments[0].(*widget.TextSegment).Text = album.Name + a.titleDisp.Refresh() + tl := widgets.NewTracklist(album.Song) + tl.OnPlayTrackAt = a.onPlayTrackAt + a.container.Objects[0] = tl + a.container.Refresh() + }() +} diff --git a/ui/albumspage.go b/ui/albumspage.go index 3d4f3ae..abdedae 100644 --- a/ui/albumspage.go +++ b/ui/albumspage.go @@ -25,7 +25,7 @@ type AlbumsPage struct { titleDisp *widget.RichText sortOrder *selectWidget container *fyne.Container - OnPlayAlbum func(string) + OnPlayAlbum func(string, int) } type selectWidget struct { @@ -69,6 +69,7 @@ func NewAlbumsPage(title string, sortOrder string, lm *backend.LibraryManager, i a.grid = NewAlbumGrid(iter, im.GetAlbumThumbnail, false /*showYear*/) a.grid.OnPlayAlbum = a.onPlayAlbum a.grid.OnShowArtistPage = a.onShowArtistPage + a.grid.OnShowAlbumPage = a.onShowAlbumPage a.container = container.NewBorder( container.NewHBox(widgets.NewHSpace(15), a.titleDisp, sortVbox), nil, @@ -100,13 +101,13 @@ func (a *AlbumsPage) OnSearched(query string) { a.Refresh() } -func (a *AlbumsPage) SetPlayAlbumCallback(cb func(string)) { +func (a *AlbumsPage) SetPlayAlbumCallback(cb func(string, int)) { a.OnPlayAlbum = cb } func (a *AlbumsPage) onPlayAlbum(albumID string) { if a.OnPlayAlbum != nil { - a.OnPlayAlbum(albumID) + a.OnPlayAlbum(albumID, 0) } } @@ -114,6 +115,10 @@ func (a *AlbumsPage) onShowArtistPage(artistID string) { a.nav(ArtistRoute(artistID)) } +func (a *AlbumsPage) onShowAlbumPage(albumID string) { + a.nav(AlbumRoute(albumID)) +} + func (a *AlbumsPage) onSortOrderChanged(order string) { a.grid.Reset(a.lm.AlbumsIter(backend.AlbumSortOrder(order))) if a.searchText == "" { diff --git a/ui/artistpage.go b/ui/artistpage.go index 494b8ff..32eef21 100644 --- a/ui/artistpage.go +++ b/ui/artistpage.go @@ -16,20 +16,23 @@ var _ fyne.Widget = (*ArtistPage)(nil) type ArtistPage struct { widget.BaseWidget - artistID string - im *backend.ImageManager - sm *backend.ServerManager - grid *AlbumGrid - titleDisp *widget.RichText - container *fyne.Container - OnPlayAlbum func(string) + artistID string + im *backend.ImageManager + sm *backend.ServerManager + nav func(Route) + grid *AlbumGrid + titleDisp *widget.RichText + container *fyne.Container + + OnPlayAlbum func(string, int) } -func NewArtistPage(artistID string, sm *backend.ServerManager, im *backend.ImageManager) *ArtistPage { +func NewArtistPage(artistID string, sm *backend.ServerManager, im *backend.ImageManager, nav func(Route)) *ArtistPage { a := &ArtistPage{ artistID: artistID, sm: sm, im: im, + nav: nav, } a.ExtendBaseWidget(a) a.titleDisp = widget.NewRichTextWithText("Artist") @@ -41,16 +44,20 @@ func NewArtistPage(artistID string, sm *backend.ServerManager, im *backend.Image return a } -func (a *ArtistPage) SetPlayAlbumCallback(cb func(string)) { +func (a *ArtistPage) SetPlayAlbumCallback(cb func(string, int)) { a.OnPlayAlbum = cb } func (a *ArtistPage) onPlayAlbum(albumID string) { if a.OnPlayAlbum != nil { - a.OnPlayAlbum(albumID) + a.OnPlayAlbum(albumID, 0) } } +func (a *ArtistPage) onShowAlbumPage(albumID string) { + a.nav(AlbumRoute(albumID)) +} + func (a *ArtistPage) loadAsync() { go func() { artist, err := a.sm.Server.GetArtist(a.artistID) @@ -62,6 +69,7 @@ func (a *ArtistPage) loadAsync() { a.titleDisp.Refresh() ag := NewFixedAlbumGrid(artist.Album, a.im.GetAlbumThumbnail, true /*showYear*/) ag.OnPlayAlbum = a.onPlayAlbum + ag.OnShowAlbumPage = a.onShowAlbumPage a.container.Objects[0] = ag a.container.Refresh() }() diff --git a/ui/browsingpane.go b/ui/browsingpane.go index f97883c..8a2b040 100644 --- a/ui/browsingpane.go +++ b/ui/browsingpane.go @@ -21,7 +21,7 @@ type Searchable interface { } type CanPlayAlbum interface { - SetPlayAlbumCallback(func(albumID string)) + SetPlayAlbumCallback(func(albumID string, startingTrack int)) } type BrowsingPane struct { @@ -70,8 +70,8 @@ func (b *BrowsingPane) SetPage(p Page) { func (b *BrowsingPane) doSetPage(p Page) { b.curPage = p if pa, ok := p.(CanPlayAlbum); ok { - pa.SetPlayAlbumCallback(func(albumID string) { - _ = b.app.PlaybackManager.PlayAlbum(albumID) + pa.SetPlayAlbumCallback(func(albumID string, firstTrack int) { + _ = b.app.PlaybackManager.PlayAlbum(albumID, firstTrack) }) } _, s := p.(Searchable) diff --git a/ui/layout/columnslayout.go b/ui/layout/columnslayout.go new file mode 100644 index 0000000..fdb715b --- /dev/null +++ b/ui/layout/columnslayout.go @@ -0,0 +1,66 @@ +package layout + +import "fyne.io/fyne/v2" + +type ColumnsLayout struct { + ColumnWidths []float32 +} + +func NewColumnsLayout(widths []float32) *ColumnsLayout { + return &ColumnsLayout{ColumnWidths: widths} +} + +func (c *ColumnsLayout) MinSize(objects []fyne.CanvasObject) fyne.Size { + var width float32 + var height float32 + for i := 0; i < len(objects); i++ { + if !objects[i].Visible() { + continue + } + height = fyne.Max(height, objects[i].MinSize().Height) + w := objects[i].MinSize().Width + if i < len(c.ColumnWidths) && c.ColumnWidths[i] > w { + w = c.ColumnWidths[i] + } + width += w + } + return fyne.NewSize(width, height) +} + +func (c *ColumnsLayout) Layout(objects []fyne.CanvasObject, size fyne.Size) { + var fixedW float32 + var expandObjCount int + for i := 0; i < min(len(objects), len(c.ColumnWidths)); i++ { + if !objects[i].Visible() { + continue + } + if c.ColumnWidths[i] < 0 { + expandObjCount++ + } else { + itemW := objects[i].MinSize().Width + fixedW += fyne.Max(itemW, c.ColumnWidths[i]) + } + } + extraW := size.Width - fixedW + expandObjW := extraW / float32(expandObjCount) + + var x float32 + for i := 0; i < len(objects); i++ { + w := objects[i].MinSize().Width + if i < len(c.ColumnWidths) && c.ColumnWidths[i] > w { + w = c.ColumnWidths[i] + } else if c.ColumnWidths[i] < 0 && expandObjW > w { + w = expandObjW + } + objects[i].Resize(fyne.NewSize(w, size.Height)) + objects[i].Move(fyne.NewPos(x, 0)) + x += w + } +} + +func min(x, y int) int { + if x < y { + return x + } + return y +} diff --git a/ui/router.go b/ui/router.go index 0ea6280..587f1b0 100644 --- a/ui/router.go +++ b/ui/router.go @@ -27,6 +27,10 @@ func ArtistRoute(artistID string) Route { return Route{Page: Artist, Arg: artistID} } +func AlbumRoute(albumID string) Route { + return Route{Page: Album, Arg: albumID} +} + type NavigationHandler interface { SetPage(Page) } @@ -45,10 +49,12 @@ func NewRouter(app *backend.App, nav NavigationHandler) Router { func (r Router) CreatePage(rte Route) Page { switch rte.Page { + case Album: + return NewAlbumPage(rte.Arg, r.App.LibraryManager) case Albums: return NewAlbumsPage("Albums", rte.Arg, r.App.LibraryManager, r.App.ImageManager, r.OpenRoute) case Artist: - return NewArtistPage(rte.Arg, r.App.ServerManager, r.App.ImageManager) + return NewArtistPage(rte.Arg, r.App.ServerManager, r.App.ImageManager, r.OpenRoute) } return nil } diff --git a/ui/widgets/albumcard.go b/ui/widgets/albumcard.go index 8502422..cd687de 100644 --- a/ui/widgets/albumcard.go +++ b/ui/widgets/albumcard.go @@ -64,7 +64,7 @@ type AlbumCard struct { albumID string artistID string - title *widget.Label + title *CustomHyperlink artist *CustomHyperlink year *widget.Label container *fyne.Container @@ -91,7 +91,7 @@ func (a *AlbumCard) MouseMoved(*desktop.MouseEvent) {} func NewAlbumCard(showYear bool) *AlbumCard { a := &AlbumCard{ - title: widget.NewLabel(""), + title: NewCustomHyperlink(), artist: NewCustomHyperlink(), year: widget.NewLabel(""), Cover: newAlbumCover(), @@ -103,13 +103,16 @@ func NewAlbumCard(showYear bool) *AlbumCard { a.OnPlay() } } + a.title.OnTapped = func() { + if a.OnShowAlbumPage != nil { + a.OnShowAlbumPage() + } + } a.artist.OnTapped = func() { if a.OnShowArtistPage != nil { a.OnShowArtistPage() } } - a.title.Wrapping = fyne.TextTruncate - a.title.TextStyle = fyne.TextStyle{Bold: true} a.createContainer() return a diff --git a/ui/widgets/tracklist.go b/ui/widgets/tracklist.go new file mode 100644 index 0000000..51fe8cf --- /dev/null +++ b/ui/widgets/tracklist.go @@ -0,0 +1,102 @@ +package widgets + +import ( + "strconv" + "supersonic/ui/layout" + "supersonic/ui/util" + + "fyne.io/fyne/v2" + "fyne.io/fyne/v2/container" + "fyne.io/fyne/v2/widget" + "github.com/dweymouth/go-subsonic" +) + +type TrackRow struct { + widget.BaseWidget + + trackID string + prevTrackID string + + num *widget.RichText + name *widget.RichText + artist *widget.RichText + dur *widget.RichText + + OnDoubleTapped func() + + container *fyne.Container +} + +var _ fyne.DoubleTappable = (*TrackRow)(nil) + +func NewTrackRow() *TrackRow { + t := &TrackRow{} + t.ExtendBaseWidget(t) + t.num = widget.NewRichTextWithText("") + t.name = widget.NewRichTextWithText("") + t.name.Wrapping = fyne.TextTruncate + t.artist = widget.NewRichTextWithText("") + t.artist.Wrapping = fyne.TextTruncate + t.dur = widget.NewRichTextWithText("") + + t.container = container.New(layout.NewColumnsLayout([]float32{30, -1, -1, 50}), + t.num, t.name, t.artist, t.dur) + return t +} + +func (t *TrackRow) Update(tr *subsonic.Child) { + if tr.ID == t.prevTrackID { + return + } + t.prevTrackID = t.trackID + t.trackID = tr.ID + t.num.Segments[0].(*widget.TextSegment).Text = strconv.Itoa(tr.Track) + t.name.Segments[0].(*widget.TextSegment).Text = tr.Title + t.artist.Segments[0].(*widget.TextSegment).Text = tr.Artist + t.dur.Segments[0].(*widget.TextSegment).Text = util.SecondsToTimeString(float64(tr.Duration)) + + t.Refresh() +} + +func (t *TrackRow) CreateRenderer() fyne.WidgetRenderer { + return widget.NewSimpleRenderer(t.container) +} + +func (t *TrackRow) DoubleTapped(*fyne.PointEvent) { + if t.OnDoubleTapped != nil { + t.OnDoubleTapped() + } +} + +type Tracklist struct { + widget.BaseWidget + + tracks []*subsonic.Child + list *widget.List + + OnPlayTrackAt func(int) +} + +func NewTracklist(tracks []*subsonic.Child) *Tracklist { + t := &Tracklist{tracks: tracks} + t.ExtendBaseWidget(t) + t.list = widget.NewList( + func() int { return len(t.tracks) }, + func() fyne.CanvasObject { return NewTrackRow() }, + func(itemID widget.ListItemID, item fyne.CanvasObject) { + tr := item.(*TrackRow) + tr.OnDoubleTapped = func() { t.onPlayTrackAt(itemID) } + tr.Update(t.tracks[itemID]) + }) + return t +} + +func (t *Tracklist) CreateRenderer() fyne.WidgetRenderer { + return widget.NewSimpleRenderer(t.list) +} + +func (t *Tracklist) onPlayTrackAt(idx int) { + if t.OnPlayTrackAt != nil { + t.OnPlayTrackAt(idx) + } +}