Files
AynaLivePlayer/gui/component/lyrics/lyricsviewer.go
2025-10-06 23:52:10 +08:00

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())
}