Files
supersonic/ui/util/util.go
T

423 lines
10 KiB
Go

package util
import (
"fmt"
"image"
"image/color"
"math"
"strconv"
"strings"
"sync"
"time"
"fyne.io/fyne/v2"
"fyne.io/fyne/v2/container"
"fyne.io/fyne/v2/driver/desktop"
"fyne.io/fyne/v2/lang"
"fyne.io/fyne/v2/layout"
"fyne.io/fyne/v2/theme"
"fyne.io/fyne/v2/widget"
ttwidget "github.com/dweymouth/fyne-tooltip/widget"
"github.com/dweymouth/supersonic/backend/mediaprovider"
"github.com/dweymouth/supersonic/res"
"github.com/dweymouth/supersonic/sharedutil"
myTheme "github.com/dweymouth/supersonic/ui/theme"
"golang.org/x/net/html"
)
type DateFormat int
const (
DateFormatDMY DateFormat = iota
DateFormatMDY
DateFormatYMD
)
func FyneDoFunc(f func()) func() {
return func() {
fyne.Do(f)
}
}
func dateFormatForLocale(locale string) DateFormat {
var region string
if i := strings.Index(locale, "-"); i > 0 && len(locale) >= 5 {
region = locale[i+1 : i+3]
}
switch strings.ToUpper(region) {
case "US":
return DateFormatMDY
case "CN", "JP", "KR", "HU", "MN", "LT":
return DateFormatYMD
default:
return DateFormatDMY
}
}
func shortMonthName(month int) string {
months := [12]string{"Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sept", "Oct", "Nov", "Dec"}
if month >= 1 && month <= 12 {
return lang.L(months[month-1])
}
return ""
}
func FormatItemDate(date mediaprovider.ItemDate) string {
var sb strings.Builder
df := dateFormatForLocale(fyne.CurrentDevice().Locale().String())
switch df {
case DateFormatDMY:
if d := date.Day; d != nil {
sb.WriteString(fmt.Sprintf("%d ", *d))
}
if m := date.Month; m != nil {
sb.WriteString(shortMonthName(*m) + " ")
}
if y := date.Year; y != nil {
sb.WriteString(fmt.Sprintf("%d", *y))
}
case DateFormatMDY:
if m := date.Month; m != nil {
sb.WriteString(shortMonthName(*m) + " ")
}
if d := date.Day; d != nil {
sb.WriteString(fmt.Sprintf("%d, ", *d))
}
if y := date.Year; y != nil {
sb.WriteString(fmt.Sprintf("%d", *y))
}
case DateFormatYMD:
if y := date.Year; y != nil {
sb.WriteString(fmt.Sprintf("%d ", *y))
}
if m := date.Month; m != nil {
sb.WriteString(shortMonthName(*m) + " ")
}
if d := date.Day; d != nil {
sb.WriteString(fmt.Sprintf("%d", *d))
}
}
return sb.String()
}
func LastPlayedDisplayString(t time.Time) string {
if t.IsZero() {
return lang.L("never")
}
switch d := time.Since(t); {
case d.Hours() < 1:
mins := int(d.Minutes())
return lang.LocalizePluralKey("x_minutes_ago",
fmt.Sprintf("%d minutes ago", mins), mins,
map[string]string{"minutes": strconv.Itoa(mins)})
case d.Hours() < 24:
hrs := int(d.Hours())
return lang.LocalizePluralKey("x_hours_ago",
fmt.Sprintf("%d hours ago", hrs), hrs,
map[string]string{"hours": strconv.Itoa(hrs)})
case d.Hours() < 24*31:
days := int(d.Hours() / 24)
return lang.LocalizePluralKey("x_days_ago",
fmt.Sprintf("%d days ago", days), days,
map[string]string{"days": strconv.Itoa(days)})
case d.Hours() < 24*365:
months := int(d.Hours() / (24 * 31))
return lang.LocalizePluralKey("x_months_ago",
fmt.Sprintf("%d months ago", months), months,
map[string]string{"months": strconv.Itoa(months)})
default:
years := int(d.Hours() / (24 * 365))
return lang.LocalizePluralKey("x_years_ago",
fmt.Sprintf("%d years ago", years), years,
map[string]string{"years": strconv.Itoa(years)})
}
}
var BoldRichTextStyle = widget.RichTextStyle{TextStyle: fyne.TextStyle{Bold: true}, Inline: true}
func MakeOpaque(c color.Color) color.Color {
if nrgba, ok := c.(color.NRGBA); ok {
nrgba.A = 255
return nrgba
}
return c
}
func SecondsToMMSS(s float64) string {
if s < 0 {
s = 0
}
sec := int(math.Round(s))
min := sec / 60
sec -= min * 60
return fmt.Sprintf("%d:%02d", min, sec)
}
func SecondsToTimeString(s float64) string {
if s < 3600 /*1 hour*/ {
return SecondsToMMSS(s)
}
sec := int64(s)
days := sec / 86400
sec -= days * 86400
hr := sec / 3600
sec -= hr * 3600
min := sec / 60
sec -= min * 60
var str string
if days > 0 {
daysStr := lang.L("days")
if days == 1 {
daysStr = lang.L("day")
}
str = fmt.Sprintf("%d %s ", days, daysStr)
}
if hr > 0 {
hrStr := lang.L("hrs")
if hr == 1 {
hrStr = lang.L("hr")
}
str += fmt.Sprintf("%d %s ", hr, hrStr)
}
if min > 0 {
str += fmt.Sprintf("%d %s ", min, lang.L("min"))
}
if sec > 0 {
str += fmt.Sprintf("%d %s ", sec, lang.L("sec"))
}
return str[:len(str)-1]
}
func BytesToSizeString(bytes int64) string {
var num float64
var suffix string
switch b := float64(bytes); {
case b > 1000000000:
suffix = "GB"
num = b / 1000000000
case b > 1000000:
suffix = "MB"
num = b / 1000000
case b > 1000:
suffix = "KB"
num = b / 1000
default:
suffix = "B"
num = b
}
return fmt.Sprintf(fmtStringForThreeSigFigs(num)+" %s", num, suffix)
}
func fmtStringForThreeSigFigs(num float64) string {
switch {
case num >= 100:
return "%0.0f"
case num >= 10:
return "%0.1f"
default:
return "%0.2f"
}
}
func ImageAspect(im image.Image) float32 {
b := im.Bounds()
return float32(b.Max.X-b.Min.X) / float32(b.Max.Y-b.Min.Y)
}
// Debouncer returns a function that will call callOnDone when
// it has not been invoked since the last dur interval.
func NewDebouncer(dur time.Duration, callOnDone func()) func() {
var mu sync.Mutex
var timer *time.Timer
return func() {
mu.Lock()
defer mu.Unlock()
if timer != nil {
timer.Stop()
}
timer = time.AfterFunc(dur, func() {
fyne.Do(callOnDone)
})
}
}
func PlaintextFromHTMLString(s string) string {
tokr := html.NewTokenizer(strings.NewReader(s))
var text string
var isLink bool
var done bool
for !done {
tt := tokr.Next()
switch {
case tt == html.ErrorToken:
done = true
case tt == html.StartTagToken:
t := tokr.Token()
isLink = t.Data == "a"
case tt == html.EndTagToken:
isLink = false
case tt == html.TextToken:
t := tokr.Token()
// for now, skip displaying Navidrome's "Read more on Last.FM" link
if !isLink {
text = text + t.Data
}
}
}
return text
}
func DisplayReleaseType(releaseTypes mediaprovider.ReleaseTypes) string {
baseType := lang.L("Album")
switch {
case releaseTypes&mediaprovider.ReleaseTypeAudiobook > 0:
baseType = lang.L("Audiobook")
case releaseTypes&mediaprovider.ReleaseTypeAudioDrama > 0:
baseType = lang.L("Audio Drama")
case releaseTypes&mediaprovider.ReleaseTypeBroadcast > 0:
baseType = lang.L("Broadcast")
case releaseTypes&mediaprovider.ReleaseTypeDJMix > 0:
baseType = lang.L("DJ-Mix")
case releaseTypes&mediaprovider.ReleaseTypeEP > 0:
baseType = lang.L("EP")
case releaseTypes&mediaprovider.ReleaseTypeFieldRecording > 0:
baseType = lang.L("Field Recording")
case releaseTypes&mediaprovider.ReleaseTypeInterview > 0:
baseType = lang.L("Interview")
case releaseTypes&mediaprovider.ReleaseTypeMixtape > 0:
baseType = lang.L("Mixtape")
case releaseTypes&mediaprovider.ReleaseTypeSingle > 0:
baseType = lang.L("Single")
case releaseTypes&mediaprovider.ReleaseTypeSoundtrack > 0:
baseType = lang.L("Soundtrack")
}
var modifiers []string
if releaseTypes&mediaprovider.ReleaseTypeLive > 0 {
modifiers = append(modifiers, lang.L("Live"))
}
if releaseTypes&mediaprovider.ReleaseTypeDemo > 0 {
modifiers = append(modifiers, lang.L("Demo"))
}
if releaseTypes&mediaprovider.ReleaseTypeRemix > 0 {
modifiers = append(modifiers, lang.L("Remix"))
}
if releaseTypes&mediaprovider.ReleaseTypeSpokenWord > 0 {
modifiers = append(modifiers, lang.L("Spoken Word"))
}
if releaseTypes&mediaprovider.ReleaseTypeCompilation > 0 {
modifiers = append(modifiers, lang.L("Compilation"))
}
modifiers = append(modifiers, baseType)
return strings.Join(modifiers, " ")
}
func NewRatingSubmenu(onSetRating func(int)) *fyne.MenuItem {
newRatingMenuItem := func(rating int) *fyne.MenuItem {
label := fmt.Sprintf("(%s)", lang.L("none"))
if rating > 0 {
label = strconv.Itoa(rating)
}
return fyne.NewMenuItem(label, func() {
onSetRating(rating)
})
}
ratingMenu := fyne.NewMenuItem(lang.L("Set rating"), nil)
ratingMenu.Icon = theme.NewThemedResource(res.ResStarOutlineSvg)
ratingMenu.ChildMenu = fyne.NewMenu("", []*fyne.MenuItem{
newRatingMenuItem(0),
newRatingMenuItem(1),
newRatingMenuItem(2),
newRatingMenuItem(3),
newRatingMenuItem(4),
newRatingMenuItem(5),
}...)
return ratingMenu
}
func AddHeaderBackground(obj fyne.CanvasObject) *fyne.Container {
return AddHeaderBackgroundWithColorName(obj, myTheme.ColorNamePageHeader)
}
func AddHeaderBackgroundWithColorName(obj fyne.CanvasObject, colorName fyne.ThemeColorName) *fyne.Container {
bgrnd := myTheme.NewThemedRectangle(colorName)
bgrnd.CornerRadiusName = theme.SizeNameInputRadius
return container.NewStack(bgrnd,
container.New(&layout.CustomPaddedLayout{LeftPadding: 10, RightPadding: 10, TopPadding: 10, BottomPadding: 10},
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 NewTruncatingTooltipRichText() *ttwidget.RichText {
rt := ttwidget.NewRichTextWithText("")
rt.Truncation = fyne.TextTruncateEllipsis
return rt
}
func NewTruncatingTooltipLabel() *ttwidget.Label {
rt := ttwidget.NewLabel("")
rt.Truncation = fyne.TextTruncateEllipsis
return rt
}
func NewTrailingAlignLabel() *widget.Label {
rt := widget.NewLabel("")
rt.Alignment = fyne.TextAlignTrailing
return rt
}
func SaveWindowSize(w fyne.Window, wPtr, hPtr *int) {
// round sizes to even to avoid Wayland issues with 2x scaling factor
// https://github.com/dweymouth/supersonic/issues/212
*wPtr = int(math.RoundToEven(float64(w.Canvas().Size().Width)))
*hPtr = int(math.RoundToEven(float64(w.Canvas().Size().Height)))
}
func LocalizeSlice(s []string) []string {
return sharedutil.MapSlice(s, func(s string) string { return lang.L(s) })
}
type ToolTipRichText struct {
ttwidget.RichText
OnMouseIn func(e *desktop.MouseEvent)
OnMouseOut func()
OnTapped func(e *fyne.PointEvent)
}
type HSpace struct {
widget.BaseWidget
Width float32
}
func NewHSpace(w float32) *HSpace {
h := &HSpace{Width: w}
h.ExtendBaseWidget(h)
return h
}
func (h *HSpace) MinSize() fyne.Size {
return fyne.NewSize(h.Width, 0)
}
func (h *HSpace) CreateRenderer() fyne.WidgetRenderer {
return widget.NewSimpleRenderer(layout.NewSpacer())
}