249 lines
9.2 KiB
Go
249 lines
9.2 KiB
Go
package browsing
|
|
|
|
import (
|
|
"fmt"
|
|
"html"
|
|
"log"
|
|
"strings"
|
|
|
|
"fyne.io/fyne/v2"
|
|
"fyne.io/fyne/v2/container"
|
|
"fyne.io/fyne/v2/lang"
|
|
"fyne.io/fyne/v2/layout"
|
|
"fyne.io/fyne/v2/theme"
|
|
"fyne.io/fyne/v2/widget"
|
|
"github.com/deluan/sanitize"
|
|
"github.com/dweymouth/supersonic/backend"
|
|
"github.com/dweymouth/supersonic/backend/mediaprovider"
|
|
"github.com/dweymouth/supersonic/ui/controller"
|
|
myTheme "github.com/dweymouth/supersonic/ui/theme"
|
|
"github.com/dweymouth/supersonic/ui/util"
|
|
appWidgets "github.com/dweymouth/supersonic/ui/widgets"
|
|
)
|
|
|
|
type PodcastsPage struct {
|
|
widget.BaseWidget
|
|
contr *controller.Controller
|
|
provider mediaprovider.PodcastProvider
|
|
pm *backend.PlaybackManager
|
|
channels []*mediaprovider.PodcastChannel
|
|
episodes []*mediaprovider.PodcastEpisode
|
|
channelList, episodeList *widget.List
|
|
channelTitle *widget.Label
|
|
content *fyne.Container
|
|
}
|
|
|
|
func NewPodcastsPage(c *controller.Controller, p mediaprovider.PodcastProvider, pm *backend.PlaybackManager) *PodcastsPage {
|
|
x := &PodcastsPage{contr: c, provider: p, pm: pm, channelTitle: widget.NewLabel(lang.L("Newest episodes"))}
|
|
x.ExtendBaseWidget(x)
|
|
x.channelTitle.TextStyle.Bold = true
|
|
x.channelList = widget.NewList(func() int { return len(x.channels) }, func() fyne.CanvasObject {
|
|
title := widget.NewButton("", nil)
|
|
more := appWidgets.NewIconButton(theme.MoreVerticalIcon(), nil)
|
|
more.IconSize = appWidgets.IconButtonSizeSmaller
|
|
more.SetToolTip(lang.L("More"))
|
|
return container.NewBorder(nil, nil, nil, more, title)
|
|
}, func(id widget.ListItemID, o fyne.CanvasObject) {
|
|
ch := x.channels[id]
|
|
row := o.(*fyne.Container)
|
|
b := row.Objects[0].(*widget.Button)
|
|
more := row.Objects[1].(*appWidgets.IconButton)
|
|
b.SetText(ch.Title)
|
|
b.OnTapped = func() { go x.loadChannel(ch.ID) }
|
|
more.OnTapped = func() {
|
|
pos := fyne.CurrentApp().Driver().AbsolutePositionForObject(more)
|
|
x.showChannelMenu(ch, pos)
|
|
}
|
|
})
|
|
x.episodeList = widget.NewList(func() int { return len(x.episodes) }, x.newEpisodeRow, x.updateEpisodeRow)
|
|
header := container.NewHBox(widget.NewLabelWithStyle(lang.L("Podcasts"), fyne.TextAlignLeading, fyne.TextStyle{Bold: true}), layout.NewSpacer())
|
|
left := container.NewBorder(widget.NewLabel(lang.L("Channels")), nil, nil, nil, x.channelList)
|
|
right := container.NewBorder(x.channelTitle, nil, nil, nil, x.episodeList)
|
|
split := container.NewHSplit(left, right)
|
|
split.Offset = .27
|
|
x.content = container.NewBorder(header, nil, nil, nil, split)
|
|
go x.load()
|
|
return x
|
|
}
|
|
|
|
func (p *PodcastsPage) showChannelMenu(ch *mediaprovider.PodcastChannel, pos fyne.Position) {
|
|
share := fyne.NewMenuItem(lang.L("Share")+"...", func() {
|
|
p.contr.ShowShareDialog(ch.ID)
|
|
})
|
|
share.Icon = myTheme.ShareIcon
|
|
_, canShare := p.provider.(mediaprovider.SupportsSharing)
|
|
share.Disabled = ch.ID == "" || !canShare
|
|
menu := fyne.NewMenu("", share)
|
|
widget.ShowPopUpMenuAtPosition(menu, fyne.CurrentApp().Driver().CanvasForObject(p), pos)
|
|
}
|
|
|
|
func (p *PodcastsPage) newEpisodeRow() fyne.CanvasObject {
|
|
title := widget.NewLabel("")
|
|
title.TextStyle.Bold = true
|
|
desc := widget.NewLabel("")
|
|
// List rows are virtualized and have a fixed template height. Letting an
|
|
// arbitrary feed description wrap makes it paint over following rows.
|
|
desc.Wrapping = fyne.TextWrapOff
|
|
desc.Truncation = fyne.TextTruncateEllipsis
|
|
play := appWidgets.NewIconButton(theme.MediaPlayIcon(), nil)
|
|
play.IconSize = appWidgets.IconButtonSizeSlightlyBigger
|
|
play.SetToolTip(lang.L("Play"))
|
|
playArea := container.NewPadded(container.NewCenter(play))
|
|
more := appWidgets.NewIconButton(theme.MoreVerticalIcon(), nil)
|
|
more.IconSize = appWidgets.IconButtonSizeSmaller
|
|
more.SetToolTip(lang.L("More"))
|
|
moreArea := container.NewPadded(container.NewCenter(more))
|
|
texts := container.New(layout.NewCustomPaddedVBoxLayout(0), title, desc)
|
|
return container.NewBorder(nil, nil, playArea, moreArea, texts)
|
|
}
|
|
|
|
func (p *PodcastsPage) updateEpisodeRow(id widget.ListItemID, obj fyne.CanvasObject) {
|
|
e := p.episodes[id]
|
|
border := obj.(*fyne.Container)
|
|
texts := border.Objects[0].(*fyne.Container)
|
|
playArea := border.Objects[1].(*fyne.Container)
|
|
playCenter := playArea.Objects[0].(*fyne.Container)
|
|
play := playCenter.Objects[0].(*appWidgets.IconButton)
|
|
moreArea := border.Objects[2].(*fyne.Container)
|
|
moreCenter := moreArea.Objects[0].(*fyne.Container)
|
|
more := moreCenter.Objects[0].(*appWidgets.IconButton)
|
|
date := ""
|
|
if !e.PublishDate.IsZero() {
|
|
date = e.PublishDate.Local().Format("Jan 2, 2006")
|
|
}
|
|
title := e.Title
|
|
if date != "" {
|
|
title = fmt.Sprintf("%s (%s)", title, date)
|
|
}
|
|
texts.Objects[0].(*widget.Label).SetText(title)
|
|
texts.Objects[1].(*widget.Label).SetText(podcastDescriptionText(e.Description))
|
|
play.OnTapped = func() { p.pm.LoadItems([]mediaprovider.MediaItem{e}, backend.Replace, false); p.pm.PlayTrackAt(0) }
|
|
more.OnTapped = func() {
|
|
pos := fyne.CurrentApp().Driver().AbsolutePositionForObject(more)
|
|
p.showEpisodeMenu(e, pos)
|
|
}
|
|
if e.Playable() {
|
|
play.Enable()
|
|
} else {
|
|
play.Disable()
|
|
}
|
|
}
|
|
|
|
func (p *PodcastsPage) showEpisodeMenu(e *mediaprovider.PodcastEpisode, pos fyne.Position) {
|
|
play := fyne.NewMenuItem(lang.L("Play"), func() {
|
|
p.pm.LoadItems([]mediaprovider.MediaItem{e}, backend.Replace, false)
|
|
p.pm.PlayTrackAt(0)
|
|
})
|
|
play.Icon = theme.MediaPlayIcon()
|
|
playNext := fyne.NewMenuItem(lang.L("Play next"), func() {
|
|
p.pm.LoadItems([]mediaprovider.MediaItem{e}, backend.InsertNext, false)
|
|
})
|
|
playNext.Icon = myTheme.PlayNextIcon
|
|
addToQueue := fyne.NewMenuItem(lang.L("Add to queue"), func() {
|
|
p.pm.LoadItems([]mediaprovider.MediaItem{e}, backend.Append, false)
|
|
})
|
|
addToQueue.Icon = theme.ContentAddIcon()
|
|
addToPlaylist := fyne.NewMenuItem(lang.L("Add to playlist")+"...", func() {
|
|
p.contr.DoAddTracksToPlaylistWorkflow([]string{e.StreamID})
|
|
})
|
|
addToPlaylist.Icon = myTheme.PlaylistIcon
|
|
share := fyne.NewMenuItem(lang.L("Share")+"...", func() {
|
|
p.contr.ShowShareDialog(e.StreamID)
|
|
})
|
|
share.Icon = myTheme.ShareIcon
|
|
favorite := fyne.NewMenuItem(lang.L("Set favorite"), func() {
|
|
p.contr.SetTrackFavorites([]string{e.StreamID}, true)
|
|
})
|
|
favorite.Icon = myTheme.FavoriteIcon
|
|
unfavorite := fyne.NewMenuItem(lang.L("Unset favorite"), func() {
|
|
p.contr.SetTrackFavorites([]string{e.StreamID}, false)
|
|
})
|
|
unfavorite.Icon = myTheme.NotFavoriteIcon
|
|
rating := util.NewRatingSubmenu(func(value int) {
|
|
p.contr.SetTrackRatings([]string{e.StreamID}, value)
|
|
})
|
|
|
|
play.Disabled = !e.Playable()
|
|
playNext.Disabled = !e.Playable()
|
|
addToQueue.Disabled = !e.Playable()
|
|
mediaActionsDisabled := e.StreamID == ""
|
|
addToPlaylist.Disabled = mediaActionsDisabled
|
|
favorite.Disabled = mediaActionsDisabled
|
|
unfavorite.Disabled = mediaActionsDisabled
|
|
_, canRate := p.provider.(mediaprovider.SupportsRating)
|
|
rating.Disabled = mediaActionsDisabled || !canRate
|
|
_, canShare := p.provider.(mediaprovider.SupportsSharing)
|
|
share.Disabled = mediaActionsDisabled || !canShare
|
|
|
|
menu := fyne.NewMenu("", play, playNext, addToQueue, fyne.NewMenuItemSeparator(),
|
|
addToPlaylist, share, fyne.NewMenuItemSeparator(), favorite, unfavorite, rating)
|
|
widget.ShowPopUpMenuAtPosition(menu, fyne.CurrentApp().Driver().CanvasForObject(p), pos)
|
|
}
|
|
|
|
func podcastDescriptionText(description string) string {
|
|
// Some servers return RSS descriptions still wrapped in CDATA and HTML.
|
|
// Labels display plain text, so normalize it before rendering the preview.
|
|
description = strings.TrimSpace(description)
|
|
description = strings.TrimPrefix(description, "<![CDATA[")
|
|
description = strings.TrimSuffix(description, "]]>")
|
|
description = html.UnescapeString(sanitize.HTML(description))
|
|
return strings.Join(strings.Fields(description), " ")
|
|
}
|
|
|
|
func (p *PodcastsPage) load() {
|
|
if p.provider == nil {
|
|
return
|
|
}
|
|
channels, err := p.provider.GetPodcastChannels()
|
|
if err != nil {
|
|
log.Printf("load podcast channels: %v", err)
|
|
return
|
|
}
|
|
episodes, err := p.provider.GetNewestPodcastEpisodes(20)
|
|
if err != nil {
|
|
log.Printf("load newest podcasts: %v", err)
|
|
}
|
|
byID := make(map[string]*mediaprovider.PodcastChannel, len(channels))
|
|
for _, ch := range channels {
|
|
byID[ch.ID] = ch
|
|
}
|
|
for _, ep := range episodes {
|
|
if ch := byID[ep.ChannelID]; ch != nil {
|
|
ep.ChannelTitle = ch.Title
|
|
if ep.CoverArtID == "" {
|
|
ep.CoverArtID = ch.CoverArtID
|
|
}
|
|
ep.OriginalImageURL = ch.OriginalImageURL
|
|
}
|
|
}
|
|
fyne.Do(func() {
|
|
p.channels = channels
|
|
p.episodes = episodes
|
|
p.channelTitle.SetText(lang.L("Newest episodes"))
|
|
p.channelList.Refresh()
|
|
p.episodeList.Refresh()
|
|
})
|
|
}
|
|
func (p *PodcastsPage) loadChannel(id string) {
|
|
ch, err := p.provider.GetPodcastChannel(id)
|
|
if err != nil {
|
|
log.Printf("load podcast channel: %v", err)
|
|
return
|
|
}
|
|
fyne.Do(func() { p.episodes = ch.Episodes; p.channelTitle.SetText(ch.Title); p.episodeList.Refresh() })
|
|
}
|
|
func (p *PodcastsPage) Reload() { go p.load() }
|
|
func (p *PodcastsPage) Route() controller.Route { return controller.PodcastsRoute() }
|
|
func (p *PodcastsPage) Save() SavedPage { return &savedPodcastsPage{p.contr, p.provider, p.pm} }
|
|
func (p *PodcastsPage) CreateRenderer() fyne.WidgetRenderer {
|
|
return widget.NewSimpleRenderer(p.content)
|
|
}
|
|
|
|
type savedPodcastsPage struct {
|
|
c *controller.Controller
|
|
p mediaprovider.PodcastProvider
|
|
pm *backend.PlaybackManager
|
|
}
|
|
|
|
func (s *savedPodcastsPage) Restore() Page { return NewPodcastsPage(s.c, s.p, s.pm) }
|