Files
supersonic/ui/browsing/radiospage.go
T
2025-09-17 22:30:49 +02:00

367 lines
9.6 KiB
Go

package browsing
import (
"log"
"net/url"
"strings"
"github.com/dweymouth/supersonic/backend"
"github.com/dweymouth/supersonic/backend/mediaprovider"
"github.com/dweymouth/supersonic/sharedutil"
"github.com/dweymouth/supersonic/ui/controller"
"github.com/dweymouth/supersonic/ui/layouts"
myTheme "github.com/dweymouth/supersonic/ui/theme"
"github.com/dweymouth/supersonic/ui/widgets"
"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"
)
var _ fyne.Widget = (*ArtistPage)(nil)
type RadiosPage struct {
widget.BaseWidget
contr *controller.Controller
rp mediaprovider.RadioProvider
pm *backend.PlaybackManager
radios []*mediaprovider.RadioStation
list *RadioList
nowPlayingID string
titleDisp *widget.RichText
noRadiosMsg fyne.CanvasObject
container *fyne.Container
searcher *widgets.SearchEntry
}
func NewRadiosPage(contr *controller.Controller, rp mediaprovider.RadioProvider, pm *backend.PlaybackManager) *RadiosPage {
return newRadiosPage(contr, rp, pm, "", 0)
}
func newRadiosPage(contr *controller.Controller, rp mediaprovider.RadioProvider, pm *backend.PlaybackManager, searchText string, scrollPos float32) *RadiosPage {
a := &RadiosPage{
contr: contr,
rp: rp,
pm: pm,
titleDisp: widget.NewRichTextWithText(lang.L("Internet Radio Stations")),
}
a.ExtendBaseWidget(a)
a.titleDisp.Segments[0].(*widget.TextSegment).Style.SizeName = theme.SizeNameHeadingText
a.list = NewRadioList(&a.nowPlayingID)
a.list.OnPlay = a.onPlay
a.list.OnQueue = a.onQueue
a.searcher = widgets.NewSearchEntry()
a.searcher.PlaceHolder = lang.L("Search page")
a.searcher.OnSearched = a.onSearched
a.searcher.Entry.Text = searchText
a.noRadiosMsg = container.NewCenter(widgets.NewInfoMessage(
lang.L("No radio stations available"),
lang.L("Configure your music server to add radio stations"),
))
a.noRadiosMsg.Hide()
a.buildContainer()
go a.load(searchText != "", scrollPos)
return a
}
// should be called asynchronously
func (a *RadiosPage) load(searchOnLoad bool, scrollPos float32) {
radios, err := a.rp.GetRadioStations()
if err != nil {
log.Printf("error loading radios: %v", err.Error())
}
fyne.Do(func() {
if len(radios) == 0 {
a.noRadiosMsg.Show()
} else {
a.noRadiosMsg.Hide()
}
a.radios = radios
if searchOnLoad {
a.onSearched(a.searcher.Entry.Text)
if scrollPos != 0 {
a.list.list.ScrollToOffset(scrollPos)
}
} else {
a.list.SetRadios(a.radios)
if scrollPos != 0 {
a.list.list.ScrollToOffset(scrollPos)
return
}
a.list.Refresh()
}
})
}
func (a *RadiosPage) onPlay(station *mediaprovider.RadioStation) {
a.pm.PlayRadioStation(station)
}
func (a *RadiosPage) onQueue(station *mediaprovider.RadioStation, next bool) {
queueMode := backend.Append
if next {
queueMode = backend.InsertNext
}
a.pm.LoadRadioStation(station, queueMode)
}
func (a *RadiosPage) onSearched(query string) {
// since the radios list is returned in full non-paginated, we will do our own
// simple search based on the radio name, rather than calling a server API
if query == "" {
a.list.SetRadios(a.radios)
} else {
query = strings.ToLower(query)
result := sharedutil.FilterSlice(a.radios, func(x *mediaprovider.RadioStation) bool {
return strings.Contains(strings.ToLower(x.Name), query)
})
a.list.SetRadios(result)
}
a.list.list.ScrollTo(0)
}
var _ Searchable = (*RadiosPage)(nil)
func (a *RadiosPage) SearchWidget() fyne.Focusable {
return a.searcher
}
var _ Scrollable = (*RadiosPage)(nil)
func (a *RadiosPage) Scroll(amount float32) {
a.list.list.ScrollToOffset(a.list.list.GetScrollOffset() + amount)
}
var _ CanShowNowPlaying = (*RadiosPage)(nil)
func (a *RadiosPage) OnSongChange(playing mediaprovider.MediaItem, _ *mediaprovider.Track) {
if playing != nil {
a.nowPlayingID = playing.Metadata().ID
} else {
a.nowPlayingID = ""
}
a.list.Refresh()
}
func (a *RadiosPage) Route() controller.Route {
return controller.RadiosRoute()
}
func (a *RadiosPage) Reload() {
go a.load(false, 0)
}
func (a *RadiosPage) Save() SavedPage {
return &savedrRadiosPage{
contr: a.contr,
rp: a.rp,
pm: a.pm,
searchText: a.searcher.Entry.Text,
scrollPos: a.list.list.GetScrollOffset(),
}
}
type savedrRadiosPage struct {
contr *controller.Controller
rp mediaprovider.RadioProvider
pm *backend.PlaybackManager
searchText string
scrollPos float32
}
func (s *savedrRadiosPage) Restore() Page {
return newRadiosPage(s.contr, s.rp, s.pm, s.searchText, s.scrollPos)
}
func (a *RadiosPage) buildContainer() {
searchVbox := container.NewVBox(layout.NewSpacer(), a.searcher, layout.NewSpacer())
a.container = container.New(&layout.CustomPaddedLayout{LeftPadding: 15, RightPadding: 15, TopPadding: 5, BottomPadding: 15},
container.NewBorder(
container.New(&layout.CustomPaddedLayout{LeftPadding: -5},
container.NewHBox(a.titleDisp, layout.NewSpacer(), searchVbox)),
nil, nil, nil,
container.NewStack(a.noRadiosMsg, a.list)),
)
}
func (a *RadiosPage) CreateRenderer() fyne.WidgetRenderer {
return widget.NewSimpleRenderer(a.container)
}
type RadioList struct {
widget.BaseWidget
OnPlay func(*mediaprovider.RadioStation)
OnQueue func(r *mediaprovider.RadioStation, next bool)
radios []*mediaprovider.RadioStation
selected *RadioListRow
columnsLayout *layouts.ColumnsLayout
hdr *widgets.ListHeader
list *widgets.FocusList
container *fyne.Container
playingIcon fyne.CanvasObject
menu *widget.PopUpMenu
}
type RadioListRow struct {
widgets.FocusListRowBase
Item *mediaprovider.RadioStation
IsPlaying bool
OnTappedSecondary func(*fyne.PointEvent)
nameLabel *widget.RichText
homePageLink *widget.Hyperlink
}
func NewRadioListRow(layout *layouts.ColumnsLayout) *RadioListRow {
a := &RadioListRow{
nameLabel: widget.NewRichTextWithText(""),
homePageLink: widget.NewHyperlink("", nil),
}
a.ExtendBaseWidget(a)
a.nameLabel.Truncation = fyne.TextTruncateEllipsis
a.homePageLink.Truncation = fyne.TextTruncateEllipsis
a.Content = container.New(layout, a.nameLabel, a.homePageLink)
return a
}
func (a *RadioListRow) TappedSecondary(e *fyne.PointEvent) {
if a.OnTappedSecondary != nil {
a.OnTappedSecondary(e)
}
}
func NewRadioList(nowPlayingIDPtr *string) *RadioList {
a := &RadioList{
columnsLayout: layouts.NewColumnsLayout([]float32{-1, -1}),
}
playIcon := theme.NewThemedResource(theme.MediaPlayIcon())
playIcon.ColorName = theme.ColorNamePrimary
a.playingIcon = container.NewCenter(widget.NewIcon(playIcon))
a.ExtendBaseWidget(a)
a.hdr = widgets.NewListHeader([]widgets.ListColumn{
{Text: lang.L("Name"), Alignment: fyne.TextAlignLeading, CanToggleVisible: false},
{Text: lang.L("Home Page"), Alignment: fyne.TextAlignLeading, CanToggleVisible: false},
},
a.columnsLayout)
a.hdr.DisableSorting = true
a.list = widgets.NewFocusList(
func() int { return len(a.radios) },
func() fyne.CanvasObject {
r := NewRadioListRow(a.columnsLayout)
r.OnTapped = func() {
r.Selected = true
if a.selected != nil {
// unselect old row
a.selected.Selected = false
a.selected.Refresh()
}
a.selected = r
r.Refresh()
}
r.OnDoubleTapped = func() { a.onPlayRadio(r.Item) }
r.OnTappedSecondary = func(e *fyne.PointEvent) {
r.OnTapped() // handle selection
a.showMenu(e.AbsolutePosition)
}
r.OnFocusNeighbor = func(up bool) {
a.list.FocusNeighbor(r.ItemID(), up)
}
return r
},
func(id widget.ListItemID, item fyne.CanvasObject) {
row := item.(*RadioListRow)
changed := false
if row.Item != a.radios[id] {
row.EnsureUnfocused()
row.ListItemID = id
row.Item = a.radios[id]
row.nameLabel.Segments[0].(*widget.TextSegment).Text = row.Item.Name
row.homePageLink.Text = row.Item.HomePageURL
if u, err := url.Parse(row.Item.HomePageURL); err == nil {
row.homePageLink.URL = u
}
changed = true
}
isPlaying := *nowPlayingIDPtr == row.Item.ID
if row.IsPlaying != isPlaying {
row.IsPlaying = isPlaying
row.nameLabel.Segments[0].(*widget.TextSegment).Style.TextStyle.Bold = isPlaying
if isPlaying {
row.Content.(*fyne.Container).Objects[0] = container.NewBorder(nil, nil, a.playingIcon, nil,
container.New(layout.NewCustomPaddedLayout(0, 0, -5, 0), row.nameLabel))
} else {
row.Content.(*fyne.Container).Objects[0] = row.nameLabel
}
changed = true
}
if changed {
row.Refresh()
}
},
)
a.container = container.NewBorder(a.hdr, nil, nil, nil, a.list)
return a
}
func (a *RadioList) showMenu(pos fyne.Position) {
if a.menu == nil {
play := fyne.NewMenuItem(lang.L("Play"), func() {
if a.OnPlay != nil {
a.OnPlay(a.selected.Item)
}
})
play.Icon = theme.MediaPlayIcon()
playNext := fyne.NewMenuItem(lang.L("Play next"), func() {
if a.OnQueue != nil {
a.OnQueue(a.selected.Item, true)
}
})
playNext.Icon = myTheme.PlayNextIcon
append := fyne.NewMenuItem(lang.L("Add to queue"), func() {
if a.OnQueue != nil {
a.OnQueue(a.selected.Item, false)
}
})
append.Icon = theme.ContentAddIcon()
a.menu = widget.NewPopUpMenu(fyne.NewMenu("",
play,
playNext,
append,
),
fyne.CurrentApp().Driver().CanvasForObject(a),
)
}
a.menu.ShowAtPosition(pos)
}
func (g *RadioList) SetRadios(radios []*mediaprovider.RadioStation) {
g.radios = radios
g.Refresh()
}
func (a *RadioList) onPlayRadio(item *mediaprovider.RadioStation) {
if a.OnPlay != nil {
a.OnPlay(item)
}
}
func (a *RadioList) CreateRenderer() fyne.WidgetRenderer {
return widget.NewSimpleRenderer(a.container)
}