mirror of
https://github.com/AynaLivePlayer/AynaLivePlayer.git
synced 2025-12-10 04:08:13 +08:00
413 lines
12 KiB
Go
413 lines
12 KiB
Go
package lyrics
|
|
|
|
import (
|
|
"time"
|
|
|
|
"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 ActiveLyricPosition int
|
|
|
|
const (
|
|
// ActiveLyricPositionMiddle positions the active lyric line in the middle of the widget
|
|
ActiveLyricPositionMiddle ActiveLyricPosition = iota
|
|
|
|
// ActiveLyricPositionUpperMiddle positions the active lyric line
|
|
// in the upper-middle of the widget, roughly 1/3 of the way down
|
|
ActiveLyricPositionUpperMiddle
|
|
)
|
|
|
|
// LyricsViewer is a widget for displaying song lyrics.
|
|
// It supports synced and unsynced mode. In synced mode, the active line
|
|
// is highlighted and the widget can advance to the next line
|
|
// with an animated scroll. In unsynced mode all lyrics are shown
|
|
// in the active color and the user is allowed to scroll freely.
|
|
type LyricsViewer struct {
|
|
widget.BaseWidget
|
|
|
|
// Alignment controls the text alignment of the lyric lines
|
|
Alignment fyne.TextAlign
|
|
|
|
// TextSizeName is the theme size name that controls the size of the lyric lines.
|
|
// Defaults to theme.SizeNameSubHeadingText.
|
|
TextSizeName fyne.ThemeSizeName
|
|
|
|
// ActiveLyricColorName is the theme color name that the currently active
|
|
// lyric line will be drawn in synced mode, or all lyrics in non-synced mode.
|
|
// Defaults to theme.ColorNameForeground.
|
|
ActiveLyricColorName fyne.ThemeColorName
|
|
|
|
// InactiveLyricColorName is the theme color name that the inactive lyric lines
|
|
// will be drawn in synced mode. Defaults to theme.ColorNameDisabled.
|
|
InactiveLyricColorName fyne.ThemeColorName
|
|
|
|
// HoveredLyricColorName is the theme color name that hovered lyric lines
|
|
// will be drawn in synced mode when an OnLyricTapped callback is set.
|
|
// Defaults to theme.ColorNameHover.
|
|
HoveredLyricColorName fyne.ThemeColorName
|
|
|
|
// ActiveLyricPosition sets the vertical positioning of the active lyric line
|
|
// in synced mode.
|
|
ActiveLyricPosition ActiveLyricPosition
|
|
|
|
// OnLyricTapped sets a callback function that is invoked when a
|
|
// synced lyric line is tapped. The line number is *one-indexed*.
|
|
// Typically used to seek to the timecode of the given lyric.
|
|
// When showing unsynced lyrics, or if this callback is unset,
|
|
// the visual styling of the widget will not indicate interactivity.
|
|
OnLyricTapped func(lineNum int)
|
|
|
|
lines []string
|
|
synced bool
|
|
|
|
// one-indexed - 0 means before the first line
|
|
// during an animation, currentLine is the line
|
|
// that will be scrolled when the animation is finished
|
|
currentLine int
|
|
|
|
prototypeLyricLineSize fyne.Size
|
|
|
|
scroll *container.Scroll
|
|
vbox *fyne.Container
|
|
|
|
// nil when an animation is not currently running
|
|
anim *fyne.Animation
|
|
animStartOffset float32
|
|
}
|
|
|
|
// NewLyricsViewer returns a new lyrics viewer.
|
|
func NewLyricsViewer() *LyricsViewer {
|
|
s := &LyricsViewer{}
|
|
s.ExtendBaseWidget(s)
|
|
s.prototypeLyricLineSize = s.newLyricLine("Hello...", 0, false).MinSize()
|
|
return s
|
|
}
|
|
|
|
// SetLyrics sets the lyrics and also resets the current line to 0 if synced.
|
|
func (l *LyricsViewer) SetLyrics(lines []string, synced bool) {
|
|
l.lines = lines
|
|
l.synced = synced
|
|
l.currentLine = 0
|
|
if l.scroll != nil {
|
|
if synced {
|
|
l.scroll.Direction = container.ScrollNone
|
|
} else {
|
|
l.scroll.Direction = container.ScrollVerticalOnly
|
|
}
|
|
}
|
|
l.updateContent()
|
|
}
|
|
|
|
// SetCurrentLine sets the current line that the lyric viewer is scrolled to.
|
|
// Argument is *one-indexed* - SetCurrentLine(0) means setting the scroll to be
|
|
// before the first line. In unsynced mode this is a no-op. This function is
|
|
// typically called when the user has seeked the playing song to a new position.
|
|
func (l *LyricsViewer) SetCurrentLine(line int) {
|
|
if line < 0 || line > len(l.lines) {
|
|
// do not panic, just ignore invalid input
|
|
return
|
|
}
|
|
if l.vbox == nil || !l.synced {
|
|
l.currentLine = line
|
|
return // renderer not created yet or unsynced mode
|
|
}
|
|
inactiveColor := l.inactiveLyricColor()
|
|
if l.checkStopAnimation() && l.currentLine > 1 {
|
|
// we were in the middle of animation
|
|
// make sure prev line is right color
|
|
l.setLineColor(l.vbox.Objects[l.currentLine-1].(*lyricLine), inactiveColor, true)
|
|
}
|
|
if l.currentLine != 0 {
|
|
l.setLineColor(l.vbox.Objects[l.currentLine].(*lyricLine), inactiveColor, true)
|
|
}
|
|
l.currentLine = line
|
|
if l.currentLine != 0 {
|
|
l.setLineColor(l.vbox.Objects[l.currentLine].(*lyricLine), l.activeLyricColor(), true)
|
|
}
|
|
l.scroll.Offset.Y = l.offsetForLine(l.currentLine)
|
|
l.scroll.Refresh()
|
|
}
|
|
|
|
// NextLine advances the lyric viewer to the next line with an animated scroll.
|
|
// In unsynced mode this is a no-op.
|
|
func (l *LyricsViewer) NextLine() {
|
|
if l.vbox == nil || !l.synced {
|
|
return // no renderer yet, or unsynced lyrics (no-op)
|
|
}
|
|
|
|
if l.currentLine == len(l.lines) {
|
|
return // already at last line
|
|
}
|
|
if l.checkStopAnimation() {
|
|
// we were in the middle of animation - short-circuit it to completed
|
|
// make sure prev and current lines are right color and scrolled to the end
|
|
if l.currentLine > 1 {
|
|
l.setLineColor(l.vbox.Objects[l.currentLine-1].(*lyricLine), l.inactiveLyricColor(), true)
|
|
}
|
|
l.setLineColor(l.vbox.Objects[l.currentLine].(*lyricLine), l.activeLyricColor(), true)
|
|
l.scroll.Offset.Y = l.offsetForLine(l.currentLine)
|
|
}
|
|
l.currentLine++
|
|
|
|
var prevLine, nextLine *lyricLine
|
|
if l.currentLine > 1 {
|
|
prevLine = l.vbox.Objects[l.currentLine-1].(*lyricLine)
|
|
}
|
|
if l.currentLine <= len(l.lines) {
|
|
nextLine = l.vbox.Objects[l.currentLine].(*lyricLine)
|
|
}
|
|
|
|
l.setupScrollAnimation(prevLine, nextLine)
|
|
l.anim.Start()
|
|
}
|
|
|
|
func (l *LyricsViewer) Refresh() {
|
|
l.updateContent()
|
|
}
|
|
|
|
func (l *LyricsViewer) MinSize() fyne.Size {
|
|
// overridden because NoScroll will have minSize encompass the full lyrics
|
|
// note also that leaving this to the renderer MinSize, based on the
|
|
// VBox with RichText lines inside Scroll, may lead to race conditions
|
|
// (https://github.com/fyne-io/fyne/issues/4890)
|
|
minHeight := l.prototypeLyricLineSize.Height*3 + theme.Padding()*2
|
|
return fyne.NewSize(l.prototypeLyricLineSize.Width, minHeight)
|
|
}
|
|
|
|
func (l *LyricsViewer) Resize(size fyne.Size) {
|
|
l.updateSpacerSize(size)
|
|
l.BaseWidget.Resize(size)
|
|
if l.vbox == nil {
|
|
return // renderer not created yet
|
|
}
|
|
if l.anim == nil {
|
|
l.scroll.Offset = fyne.NewPos(0, l.offsetForLine(l.currentLine))
|
|
l.scroll.Refresh()
|
|
} else {
|
|
// animation is running - update its reference scroll pos
|
|
l.animStartOffset = l.offsetForLine(l.currentLine - 1)
|
|
}
|
|
}
|
|
|
|
func (l *LyricsViewer) updateSpacerSize(size fyne.Size) {
|
|
if l.vbox == nil {
|
|
return // renderer not created yet
|
|
}
|
|
|
|
ht := size.Height / 2
|
|
if l.ActiveLyricPosition == ActiveLyricPositionUpperMiddle {
|
|
ht = size.Height / 3
|
|
}
|
|
|
|
var topSpaceHeight, bottomSpaceHeight float32
|
|
if l.synced {
|
|
topSpaceHeight = ht + l.prototypeLyricLineSize.Height/2
|
|
// end spacer only needs to be big enough - can't be too big
|
|
// so use a very simple height calculation
|
|
bottomSpaceHeight = size.Height
|
|
}
|
|
l.vbox.Objects[0].(*vSpace).Height = topSpaceHeight
|
|
l.vbox.Objects[len(l.vbox.Objects)-1].(*vSpace).Height = bottomSpaceHeight
|
|
}
|
|
|
|
func (l *LyricsViewer) updateContent() {
|
|
if l.vbox == nil {
|
|
return // renderer not created yet
|
|
}
|
|
l.checkStopAnimation()
|
|
|
|
lnObj := len(l.vbox.Objects)
|
|
if lnObj == 0 {
|
|
l.vbox.Objects = append(l.vbox.Objects, NewVSpace(0), NewVSpace(0))
|
|
lnObj = 2
|
|
}
|
|
l.updateSpacerSize(l.Size())
|
|
endSpacer := l.vbox.Objects[lnObj-1]
|
|
for i, line := range l.lines {
|
|
lineNum := i + 1 // one-indexed
|
|
useActiveColor := !l.synced || l.currentLine == lineNum
|
|
if lineNum < lnObj-1 {
|
|
rt := l.vbox.Objects[lineNum].(*lyricLine)
|
|
if useActiveColor {
|
|
l.setLineColor(rt, l.activeLyricColor(), false)
|
|
} else {
|
|
l.setLineColor(rt, l.inactiveLyricColor(), false)
|
|
}
|
|
l.setLineTextAndProperties(rt, line, lineNum, true)
|
|
} else if lineNum < lnObj {
|
|
// replacing end spacer (last element in Objects) with a new richtext
|
|
l.vbox.Objects[lineNum] = l.newLyricLine(line, lineNum, useActiveColor)
|
|
} else {
|
|
// extending the Objects slice
|
|
l.vbox.Objects = append(l.vbox.Objects, l.newLyricLine(line, lineNum, useActiveColor))
|
|
}
|
|
}
|
|
for i := len(l.lines) + 1; i < lnObj; i++ {
|
|
l.vbox.Objects[i] = nil
|
|
}
|
|
l.vbox.Objects = l.vbox.Objects[:len(l.lines)+1]
|
|
l.vbox.Objects = append(l.vbox.Objects, endSpacer)
|
|
l.vbox.Refresh()
|
|
l.scroll.Offset.Y = l.offsetForLine(l.currentLine)
|
|
l.scroll.Refresh()
|
|
}
|
|
|
|
func (l *LyricsViewer) setupScrollAnimation(currentLine, nextLine *lyricLine) {
|
|
// calculate total scroll distance for the animation
|
|
scrollDist := theme.Padding()
|
|
if currentLine != nil {
|
|
scrollDist += currentLine.Size().Height / 2
|
|
} else {
|
|
scrollDist += l.prototypeLyricLineSize.Height / 2
|
|
}
|
|
if nextLine != nil {
|
|
scrollDist += nextLine.Size().Height / 2
|
|
} else {
|
|
scrollDist += l.prototypeLyricLineSize.Height / 2
|
|
}
|
|
|
|
l.animStartOffset = l.scroll.Offset.Y
|
|
var alreadyUpdated bool
|
|
l.anim = fyne.NewAnimation(140*time.Millisecond, func(f float32) {
|
|
l.scroll.Offset.Y = l.animStartOffset + f*scrollDist
|
|
l.scroll.Refresh()
|
|
if !alreadyUpdated && f >= 0.5 {
|
|
if nextLine != nil {
|
|
l.setLineColor(nextLine, l.activeLyricColor(), true)
|
|
}
|
|
if currentLine != nil {
|
|
l.setLineColor(currentLine, l.inactiveLyricColor(), true)
|
|
}
|
|
alreadyUpdated = true
|
|
}
|
|
if f == 1 /*end of animation*/ {
|
|
l.anim = nil
|
|
}
|
|
})
|
|
l.anim.Curve = fyne.AnimationEaseInOut
|
|
}
|
|
|
|
func (l *LyricsViewer) offsetForLine(lineNum int /*one-indexed*/) float32 {
|
|
if lineNum == 0 {
|
|
return 0
|
|
}
|
|
pad := theme.Padding()
|
|
offset := pad + l.prototypeLyricLineSize.Height/2
|
|
for i := 1; i <= lineNum; i++ {
|
|
if i > 1 {
|
|
offset += l.vbox.Objects[i-1].MinSize().Height/2 + pad
|
|
}
|
|
offset += l.vbox.Objects[i].MinSize().Height / 2
|
|
}
|
|
return offset
|
|
}
|
|
|
|
func (l *LyricsViewer) newLyricLine(text string, lineNum int, useActiveColor bool) *lyricLine {
|
|
ll := newLyricLine(text, nil)
|
|
l.setLineTextAndProperties(ll, text, lineNum, false)
|
|
ll.HoveredColorName = l.hoveredLyricColor()
|
|
if useActiveColor {
|
|
ll.ColorName = l.activeLyricColor()
|
|
} else {
|
|
ll.ColorName = l.inactiveLyricColor()
|
|
}
|
|
|
|
return ll
|
|
}
|
|
|
|
func (l *LyricsViewer) setLineTextAndProperties(ll *lyricLine, text string, lineNum int, refresh bool) {
|
|
ll.Text = text
|
|
ll.SizeName = l.textSizeName()
|
|
ll.Alignment = l.Alignment
|
|
ll.Tappable = l.synced && l.OnLyricTapped != nil
|
|
ll.onTapped = func() {
|
|
if l.OnLyricTapped != nil {
|
|
l.OnLyricTapped(lineNum)
|
|
}
|
|
}
|
|
if refresh {
|
|
ll.Refresh()
|
|
}
|
|
}
|
|
|
|
func (l *LyricsViewer) setLineColor(ll *lyricLine, colorName fyne.ThemeColorName, refresh bool) {
|
|
ll.ColorName = colorName
|
|
ll.HoveredColorName = l.hoveredLyricColor()
|
|
if refresh {
|
|
ll.Refresh()
|
|
}
|
|
}
|
|
|
|
func (l *LyricsViewer) activeLyricColor() fyne.ThemeColorName {
|
|
if l.ActiveLyricColorName != "" {
|
|
return l.ActiveLyricColorName
|
|
}
|
|
return theme.ColorNameForeground
|
|
}
|
|
|
|
func (l *LyricsViewer) inactiveLyricColor() fyne.ThemeColorName {
|
|
if l.InactiveLyricColorName != "" {
|
|
return l.InactiveLyricColorName
|
|
}
|
|
return theme.ColorNameDisabled
|
|
}
|
|
|
|
func (l *LyricsViewer) hoveredLyricColor() fyne.ThemeColorName {
|
|
if l.HoveredLyricColorName != "" {
|
|
return l.HoveredLyricColorName
|
|
}
|
|
return theme.ColorNameHyperlink
|
|
}
|
|
|
|
func (l *LyricsViewer) textSizeName() fyne.ThemeSizeName {
|
|
if l.TextSizeName != "" {
|
|
return l.TextSizeName
|
|
}
|
|
return theme.SizeNameSubHeadingText
|
|
}
|
|
|
|
func (l *LyricsViewer) checkStopAnimation() bool {
|
|
if l.anim != nil {
|
|
l.anim.Stop()
|
|
l.anim = nil
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
|
|
func (l *LyricsViewer) CreateRenderer() fyne.WidgetRenderer {
|
|
l.vbox = container.NewVBox()
|
|
l.scroll = container.NewScroll(l.vbox)
|
|
if l.synced {
|
|
l.scroll.Direction = container.ScrollNone
|
|
} else {
|
|
l.scroll.Direction = container.ScrollVerticalOnly
|
|
}
|
|
l.updateContent()
|
|
return widget.NewSimpleRenderer(l.scroll)
|
|
}
|
|
|
|
type vSpace struct {
|
|
widget.BaseWidget
|
|
|
|
Height float32
|
|
}
|
|
|
|
func NewVSpace(height float32) *vSpace {
|
|
v := &vSpace{Height: height}
|
|
v.ExtendBaseWidget(v)
|
|
return v
|
|
}
|
|
|
|
func (v *vSpace) MinSize() fyne.Size {
|
|
return fyne.NewSize(0, v.Height)
|
|
}
|
|
|
|
func (v *vSpace) CreateRenderer() fyne.WidgetRenderer {
|
|
return widget.NewSimpleRenderer(layout.NewSpacer())
|
|
}
|