mirror of
https://github.com/navidrome/navidrome.git
synced 2026-05-03 06:51:16 +00:00
fix: align songLyrics v2 with spec, add Enhanced LRC parser and bg role UI styling
- Fix LyricCue.Value XML tag: chardata instead of attribute - Fix Kind field leaking to non-enhanced (v1) responses - Guard against nil cue.Start values - Add Enhanced LRC parser for word-level inline timing markers - Add role-based UI styling: bg tokens render italic at 72% opacity - Add integration test for Enhanced LRC file reading - Add unit tests for Enhanced LRC parser
This commit is contained in:
parent
1f9fb113f7
commit
ff40c030d9
@ -88,6 +88,43 @@ var _ = Describe("sources", func() {
|
|||||||
}))
|
}))
|
||||||
})
|
})
|
||||||
|
|
||||||
|
It("should return Enhanced LRC lyrics with word-level cues from a file", func() {
|
||||||
|
mf := model.MediaFile{Path: "tests/fixtures/test-enhanced.mp3"}
|
||||||
|
lyrics, err := fromExternalFile(ctx, &mf, ".lrc")
|
||||||
|
|
||||||
|
Expect(err).To(BeNil())
|
||||||
|
Expect(lyrics).To(HaveLen(1))
|
||||||
|
Expect(lyrics[0].DisplayArtist).To(Equal("Test Artist"))
|
||||||
|
Expect(lyrics[0].DisplayTitle).To(Equal("Enhanced Test"))
|
||||||
|
Expect(lyrics[0].Lang).To(Equal("eng"))
|
||||||
|
Expect(lyrics[0].Synced).To(BeTrue())
|
||||||
|
Expect(lyrics[0].Line).To(HaveLen(3))
|
||||||
|
|
||||||
|
// Line 1: has inline markers → Cue array populated
|
||||||
|
Expect(lyrics[0].Line[0].Start).To(Equal(gg.P(int64(1000))))
|
||||||
|
Expect(lyrics[0].Line[0].Value).To(Equal("Some lyrics here"))
|
||||||
|
Expect(lyrics[0].Line[0].Cue).To(HaveLen(3))
|
||||||
|
Expect(*lyrics[0].Line[0].Cue[0].Start).To(Equal(int64(1000)))
|
||||||
|
Expect(lyrics[0].Line[0].Cue[0].Value).To(Equal("Some "))
|
||||||
|
Expect(*lyrics[0].Line[0].Cue[0].End).To(Equal(int64(1500)))
|
||||||
|
Expect(*lyrics[0].Line[0].Cue[1].Start).To(Equal(int64(1500)))
|
||||||
|
Expect(lyrics[0].Line[0].Cue[1].Value).To(Equal("lyrics "))
|
||||||
|
Expect(*lyrics[0].Line[0].Cue[1].End).To(Equal(int64(2000)))
|
||||||
|
Expect(*lyrics[0].Line[0].Cue[2].Start).To(Equal(int64(2000)))
|
||||||
|
Expect(lyrics[0].Line[0].Cue[2].Value).To(Equal("here"))
|
||||||
|
Expect(lyrics[0].Line[0].Cue[2].End).To(BeNil())
|
||||||
|
|
||||||
|
// Line 2: has inline markers
|
||||||
|
Expect(lyrics[0].Line[1].Start).To(Equal(gg.P(int64(3000))))
|
||||||
|
Expect(lyrics[0].Line[1].Value).To(Equal("More words"))
|
||||||
|
Expect(lyrics[0].Line[1].Cue).To(HaveLen(2))
|
||||||
|
|
||||||
|
// Line 3: plain line, no cues
|
||||||
|
Expect(lyrics[0].Line[2].Start).To(Equal(gg.P(int64(5000))))
|
||||||
|
Expect(lyrics[0].Line[2].Value).To(Equal("Plain line without inline markers"))
|
||||||
|
Expect(lyrics[0].Line[2].Cue).To(BeNil())
|
||||||
|
})
|
||||||
|
|
||||||
It("should return unsynchronized lyrics from a file", func() {
|
It("should return unsynchronized lyrics from a file", func() {
|
||||||
mf := model.MediaFile{Path: "tests/fixtures/test.mp3"}
|
mf := model.MediaFile{Path: "tests/fixtures/test.mp3"}
|
||||||
lyrics, err := fromExternalFile(ctx, &mf, ".txt")
|
lyrics, err := fromExternalFile(ctx, &mf, ".txt")
|
||||||
|
|||||||
105
model/lyrics.go
105
model/lyrics.go
@ -43,6 +43,10 @@ var (
|
|||||||
syncRegex = regexp.MustCompile(`(^|\n)\s*` + timeRegexString)
|
syncRegex = regexp.MustCompile(`(^|\n)\s*` + timeRegexString)
|
||||||
timeRegex = regexp.MustCompile(timeRegexString)
|
timeRegex = regexp.MustCompile(timeRegexString)
|
||||||
lrcIdRegex = regexp.MustCompile(`\[(ar|ti|offset|lang):([^]]+)]`)
|
lrcIdRegex = regexp.MustCompile(`\[(ar|ti|offset|lang):([^]]+)]`)
|
||||||
|
|
||||||
|
// Enhanced LRC: inline word-level timing markers like <00:12.34>
|
||||||
|
enhancedLRCTimeString = `<([0-9]{1,2}:)?([0-9]{1,2}):([0-9]{1,2})(.[0-9]{1,3})?>`
|
||||||
|
enhancedLRCRegex = regexp.MustCompile(enhancedLRCTimeString)
|
||||||
)
|
)
|
||||||
|
|
||||||
func (l Lyrics) IsEmpty() bool {
|
func (l Lyrics) IsEmpty() bool {
|
||||||
@ -116,9 +120,15 @@ func ToLyrics(language, text string) (*Lyrics, error) {
|
|||||||
|
|
||||||
if validLine {
|
if validLine {
|
||||||
for idx := range timestamps {
|
for idx := range timestamps {
|
||||||
|
cues := parseEnhancedCues(priorLine)
|
||||||
|
value := priorLine
|
||||||
|
if cues != nil {
|
||||||
|
value = stripEnhancedMarkers(value)
|
||||||
|
}
|
||||||
structuredLines = append(structuredLines, Line{
|
structuredLines = append(structuredLines, Line{
|
||||||
Start: ×tamps[idx],
|
Start: ×tamps[idx],
|
||||||
Value: strings.TrimSpace(priorLine),
|
Value: strings.TrimSpace(value),
|
||||||
|
Cue: cues,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
timestamps = nil
|
timestamps = nil
|
||||||
@ -164,9 +174,15 @@ func ToLyrics(language, text string) (*Lyrics, error) {
|
|||||||
|
|
||||||
if validLine {
|
if validLine {
|
||||||
for idx := range timestamps {
|
for idx := range timestamps {
|
||||||
|
cues := parseEnhancedCues(priorLine)
|
||||||
|
value := priorLine
|
||||||
|
if cues != nil {
|
||||||
|
value = stripEnhancedMarkers(value)
|
||||||
|
}
|
||||||
structuredLines = append(structuredLines, Line{
|
structuredLines = append(structuredLines, Line{
|
||||||
Start: ×tamps[idx],
|
Start: ×tamps[idx],
|
||||||
Value: strings.TrimSpace(priorLine),
|
Value: strings.TrimSpace(value),
|
||||||
|
Cue: cues,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -190,6 +206,91 @@ func ToLyrics(language, text string) (*Lyrics, error) {
|
|||||||
return &lyrics, nil
|
return &lyrics, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// parseEnhancedCues extracts word-level timing cues from Enhanced LRC inline markers.
|
||||||
|
// Format: <mm:ss.mm>word <mm:ss.mm>word ...
|
||||||
|
// Returns nil if no inline markers are found.
|
||||||
|
func parseEnhancedCues(text string) []Cue {
|
||||||
|
matches := enhancedLRCRegex.FindAllStringSubmatchIndex(text, -1)
|
||||||
|
if len(matches) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type segment struct {
|
||||||
|
start int64
|
||||||
|
text string
|
||||||
|
}
|
||||||
|
|
||||||
|
segments := make([]segment, 0, len(matches))
|
||||||
|
for i, match := range matches {
|
||||||
|
timeMs, err := parseTime(
|
||||||
|
// Rewrite <...> as [...] so parseTime can handle it with the same logic
|
||||||
|
"["+text[match[0]+1:match[1]-1]+"]",
|
||||||
|
// Adjust match indices to point into our rewritten string (need start/end pairs for each group)
|
||||||
|
[]int{
|
||||||
|
0, match[1] - match[0],
|
||||||
|
adjustGroup(match, 2), adjustGroup(match, 3),
|
||||||
|
adjustGroup(match, 4), adjustGroup(match, 5),
|
||||||
|
adjustGroup(match, 6), adjustGroup(match, 7),
|
||||||
|
adjustGroup(match, 8), adjustGroup(match, 9),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Text runs from after this marker to the start of the next marker (or end of string)
|
||||||
|
textStart := match[1]
|
||||||
|
var textEnd int
|
||||||
|
if i+1 < len(matches) {
|
||||||
|
textEnd = matches[i+1][0]
|
||||||
|
} else {
|
||||||
|
textEnd = len(text)
|
||||||
|
}
|
||||||
|
|
||||||
|
word := text[textStart:textEnd]
|
||||||
|
if word == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
segments = append(segments, segment{start: timeMs, text: word})
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(segments) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
cues := make([]Cue, len(segments))
|
||||||
|
for i, seg := range segments {
|
||||||
|
start := seg.start
|
||||||
|
cues[i] = Cue{
|
||||||
|
Start: &start,
|
||||||
|
Value: seg.text,
|
||||||
|
}
|
||||||
|
// Derive End from the next cue's Start
|
||||||
|
if i+1 < len(segments) {
|
||||||
|
end := segments[i+1].start
|
||||||
|
cues[i].End = &end
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return cues
|
||||||
|
}
|
||||||
|
|
||||||
|
// adjustGroup remaps a capture group index from the original match to our rewritten "[...]" string.
|
||||||
|
// The rewrite shifts by -1 (removed '<', added '[') so positions within the brackets stay the same.
|
||||||
|
func adjustGroup(match []int, groupIdx int) int {
|
||||||
|
orig := match[groupIdx]
|
||||||
|
if orig == -1 {
|
||||||
|
return -1
|
||||||
|
}
|
||||||
|
// Offset is: original position minus the position of '<' in the original, plus 1 for '['
|
||||||
|
return orig - match[0]
|
||||||
|
}
|
||||||
|
|
||||||
|
// stripEnhancedMarkers removes all <mm:ss.mm> inline markers from text,
|
||||||
|
// returning the plain lyric text.
|
||||||
|
func stripEnhancedMarkers(text string) string {
|
||||||
|
return enhancedLRCRegex.ReplaceAllString(text, "")
|
||||||
|
}
|
||||||
|
|
||||||
func parseTime(line string, match []int) (int64, error) {
|
func parseTime(line string, match []int) (int64, error) {
|
||||||
var hours, millis int64
|
var hours, millis int64
|
||||||
var err error
|
var err error
|
||||||
|
|||||||
@ -116,4 +116,63 @@ var _ = Describe("ToLyrics", func() {
|
|||||||
{Start: &e, Value: "Test"},
|
{Start: &e, Value: "Test"},
|
||||||
}))
|
}))
|
||||||
})
|
})
|
||||||
|
|
||||||
|
It("should parse Enhanced LRC with word-level timing", func() {
|
||||||
|
lyrics, err := ToLyrics("xxx", "[00:01.00]<00:01.00>Some <00:01.50>lyrics <00:02.00>here\n[00:03.00]<00:03.00>More <00:03.50>words")
|
||||||
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
Expect(lyrics.Synced).To(BeTrue())
|
||||||
|
Expect(lyrics.Line).To(HaveLen(2))
|
||||||
|
|
||||||
|
t1000, t1500, t2000, t3000, t3500 := int64(1000), int64(1500), int64(2000), int64(3000), int64(3500)
|
||||||
|
|
||||||
|
line0 := lyrics.Line[0]
|
||||||
|
Expect(line0.Start).To(Equal(&t1000))
|
||||||
|
Expect(line0.Value).To(Equal("Some lyrics here"))
|
||||||
|
Expect(line0.Cue).To(Equal([]Cue{
|
||||||
|
{Start: &t1000, End: &t1500, Value: "Some "},
|
||||||
|
{Start: &t1500, End: &t2000, Value: "lyrics "},
|
||||||
|
{Start: &t2000, Value: "here"},
|
||||||
|
}))
|
||||||
|
|
||||||
|
line1 := lyrics.Line[1]
|
||||||
|
Expect(line1.Start).To(Equal(&t3000))
|
||||||
|
Expect(line1.Value).To(Equal("More words"))
|
||||||
|
Expect(line1.Cue).To(Equal([]Cue{
|
||||||
|
{Start: &t3000, End: &t3500, Value: "More "},
|
||||||
|
{Start: &t3500, Value: "words"},
|
||||||
|
}))
|
||||||
|
})
|
||||||
|
|
||||||
|
It("should ignore Enhanced LRC markers and return plain lines when no markers present", func() {
|
||||||
|
a, b := int64(1000), int64(3000)
|
||||||
|
lyrics, err := ToLyrics("xxx", "[00:01.00]Plain line\n[00:03.00]Another plain line")
|
||||||
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
Expect(lyrics.Line).To(Equal([]Line{
|
||||||
|
{Start: &a, Value: "Plain line"},
|
||||||
|
{Start: &b, Value: "Another plain line"},
|
||||||
|
}))
|
||||||
|
})
|
||||||
|
|
||||||
|
It("should handle mixed Enhanced and plain LRC lines", func() {
|
||||||
|
lyrics, err := ToLyrics("xxx", "[00:01.00]<00:01.00>Some <00:01.50>lyrics\n[00:03.00]Plain line\n[00:05.00]<00:05.00>More <00:05.50>words")
|
||||||
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
Expect(lyrics.Line).To(HaveLen(3))
|
||||||
|
|
||||||
|
t1000, t1500, t5000, t5500 := int64(1000), int64(1500), int64(5000), int64(5500)
|
||||||
|
|
||||||
|
Expect(lyrics.Line[0].Cue).To(Equal([]Cue{
|
||||||
|
{Start: &t1000, End: &t1500, Value: "Some "},
|
||||||
|
{Start: &t1500, Value: "lyrics"},
|
||||||
|
}))
|
||||||
|
Expect(lyrics.Line[0].Value).To(Equal("Some lyrics"))
|
||||||
|
|
||||||
|
Expect(lyrics.Line[1].Cue).To(BeNil())
|
||||||
|
Expect(lyrics.Line[1].Value).To(Equal("Plain line"))
|
||||||
|
|
||||||
|
Expect(lyrics.Line[2].Cue).To(Equal([]Cue{
|
||||||
|
{Start: &t5000, End: &t5500, Value: "More "},
|
||||||
|
{Start: &t5500, Value: "words"},
|
||||||
|
}))
|
||||||
|
Expect(lyrics.Line[2].Value).To(Equal("More words"))
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@ -498,16 +498,15 @@ func buildStructuredLyric(mf *model.MediaFile, lyrics model.Lyrics, enhanced boo
|
|||||||
roleOrder := make([]string, 0, 2)
|
roleOrder := make([]string, 0, 2)
|
||||||
cuesByRole := make(map[string][]responses.LyricCue)
|
cuesByRole := make(map[string][]responses.LyricCue)
|
||||||
for _, cue := range line.Cue {
|
for _, cue := range line.Cue {
|
||||||
|
if cue.Start == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
role := sanitizeRole(cue.Role)
|
role := sanitizeRole(cue.Role)
|
||||||
if _, exists := cuesByRole[role]; !exists {
|
if _, exists := cuesByRole[role]; !exists {
|
||||||
roleOrder = append(roleOrder, role)
|
roleOrder = append(roleOrder, role)
|
||||||
}
|
}
|
||||||
var start int64
|
|
||||||
if cue.Start != nil {
|
|
||||||
start = *cue.Start
|
|
||||||
}
|
|
||||||
cuesByRole[role] = append(cuesByRole[role], responses.LyricCue{
|
cuesByRole[role] = append(cuesByRole[role], responses.LyricCue{
|
||||||
Start: start,
|
Start: *cue.Start,
|
||||||
End: cue.End,
|
End: cue.End,
|
||||||
Value: cue.Value,
|
Value: cue.Value,
|
||||||
})
|
})
|
||||||
@ -535,15 +534,9 @@ func buildStructuredLyric(mf *model.MediaFile, lyrics model.Lyrics, enhanced boo
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
kind := strings.TrimSpace(lyrics.Kind)
|
|
||||||
if kind == "" {
|
|
||||||
kind = "main"
|
|
||||||
}
|
|
||||||
|
|
||||||
structured := responses.StructuredLyric{
|
structured := responses.StructuredLyric{
|
||||||
DisplayArtist: lyrics.DisplayArtist,
|
DisplayArtist: lyrics.DisplayArtist,
|
||||||
DisplayTitle: lyrics.DisplayTitle,
|
DisplayTitle: lyrics.DisplayTitle,
|
||||||
Kind: kind,
|
|
||||||
Lang: lyrics.Lang,
|
Lang: lyrics.Lang,
|
||||||
Line: lines,
|
Line: lines,
|
||||||
CueLine: cueLines,
|
CueLine: cueLines,
|
||||||
@ -551,6 +544,14 @@ func buildStructuredLyric(mf *model.MediaFile, lyrics model.Lyrics, enhanced boo
|
|||||||
Synced: lyrics.Synced,
|
Synced: lyrics.Synced,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if enhanced {
|
||||||
|
kind := strings.TrimSpace(lyrics.Kind)
|
||||||
|
if kind == "" {
|
||||||
|
kind = "main"
|
||||||
|
}
|
||||||
|
structured.Kind = kind
|
||||||
|
}
|
||||||
|
|
||||||
if structured.DisplayArtist == "" {
|
if structured.DisplayArtist == "" {
|
||||||
structured.DisplayArtist = mf.Artist
|
structured.DisplayArtist = mf.Artist
|
||||||
}
|
}
|
||||||
|
|||||||
@ -232,11 +232,7 @@ var _ = Describe("MediaRetrievalController", func() {
|
|||||||
|
|
||||||
Expect(realLyric.DisplayArtist).To(Equal(expectedLyric.DisplayArtist))
|
Expect(realLyric.DisplayArtist).To(Equal(expectedLyric.DisplayArtist))
|
||||||
Expect(realLyric.DisplayTitle).To(Equal(expectedLyric.DisplayTitle))
|
Expect(realLyric.DisplayTitle).To(Equal(expectedLyric.DisplayTitle))
|
||||||
expectedKind := expectedLyric.Kind
|
Expect(realLyric.Kind).To(Equal(expectedLyric.Kind))
|
||||||
if expectedKind == "" {
|
|
||||||
expectedKind = "main"
|
|
||||||
}
|
|
||||||
Expect(realLyric.Kind).To(Equal(expectedKind))
|
|
||||||
Expect(realLyric.Lang).To(Equal(expectedLyric.Lang))
|
Expect(realLyric.Lang).To(Equal(expectedLyric.Lang))
|
||||||
Expect(realLyric.Synced).To(Equal(expectedLyric.Synced))
|
Expect(realLyric.Synced).To(Equal(expectedLyric.Synced))
|
||||||
|
|
||||||
@ -587,6 +583,7 @@ var _ = Describe("MediaRetrievalController", func() {
|
|||||||
{
|
{
|
||||||
DisplayArtist: "Rick Astley",
|
DisplayArtist: "Rick Astley",
|
||||||
DisplayTitle: "Never Gonna Give You Up",
|
DisplayTitle: "Never Gonna Give You Up",
|
||||||
|
Kind: "main",
|
||||||
Lang: "eng",
|
Lang: "eng",
|
||||||
Synced: true,
|
Synced: true,
|
||||||
Line: []responses.Line{
|
Line: []responses.Line{
|
||||||
|
|||||||
@ -540,7 +540,7 @@ type Line struct {
|
|||||||
type LyricCue struct {
|
type LyricCue struct {
|
||||||
Start int64 `xml:"start,attr" json:"start"`
|
Start int64 `xml:"start,attr" json:"start"`
|
||||||
End *int64 `xml:"end,attr,omitempty" json:"end,omitempty"`
|
End *int64 `xml:"end,attr,omitempty" json:"end,omitempty"`
|
||||||
Value string `xml:"value,attr" json:"value"`
|
Value string `xml:",chardata" json:"value"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type CueLine struct {
|
type CueLine struct {
|
||||||
|
|||||||
6
tests/fixtures/test-enhanced.lrc
vendored
Normal file
6
tests/fixtures/test-enhanced.lrc
vendored
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
[ar:Test Artist]
|
||||||
|
[ti:Enhanced Test]
|
||||||
|
[lang:eng]
|
||||||
|
[00:01.00]<00:01.00>Some <00:01.50>lyrics <00:02.00>here
|
||||||
|
[00:03.00]<00:03.00>More <00:03.50>words
|
||||||
|
[00:05.00]Plain line without inline markers
|
||||||
@ -1,3 +1,12 @@
|
|||||||
|
import Button from '@material-ui/core/Button'
|
||||||
|
import IconButton from '@material-ui/core/IconButton'
|
||||||
|
import Popover from '@material-ui/core/Popover'
|
||||||
|
import Slider from '@material-ui/core/Slider'
|
||||||
|
import { makeStyles } from '@material-ui/core/styles'
|
||||||
|
import Typography from '@material-ui/core/Typography'
|
||||||
|
import CloseIcon from '@material-ui/icons/Close'
|
||||||
|
import TuneIcon from '@material-ui/icons/Tune'
|
||||||
|
import clsx from 'clsx'
|
||||||
import React, {
|
import React, {
|
||||||
memo,
|
memo,
|
||||||
useCallback,
|
useCallback,
|
||||||
@ -6,21 +15,12 @@ import React, {
|
|||||||
useRef,
|
useRef,
|
||||||
useState,
|
useState,
|
||||||
} from 'react'
|
} from 'react'
|
||||||
import clsx from 'clsx'
|
|
||||||
import Button from '@material-ui/core/Button'
|
|
||||||
import IconButton from '@material-ui/core/IconButton'
|
|
||||||
import Popover from '@material-ui/core/Popover'
|
|
||||||
import Slider from '@material-ui/core/Slider'
|
|
||||||
import Typography from '@material-ui/core/Typography'
|
|
||||||
import CloseIcon from '@material-ui/icons/Close'
|
|
||||||
import TuneIcon from '@material-ui/icons/Tune'
|
|
||||||
import { makeStyles } from '@material-ui/core/styles'
|
|
||||||
import {
|
import {
|
||||||
buildKaraokeLines,
|
buildKaraokeLines,
|
||||||
getActiveKaraokeState,
|
getActiveKaraokeState,
|
||||||
hasStructuredLyricContent,
|
hasStructuredLyricContent,
|
||||||
resolveLayerLineForMain,
|
|
||||||
resolveKaraokeTokenWindow,
|
resolveKaraokeTokenWindow,
|
||||||
|
resolveLayerLineForMain,
|
||||||
} from './lyrics'
|
} from './lyrics'
|
||||||
|
|
||||||
const KARAOKE_RENDER_LEAD_MS = 24
|
const KARAOKE_RENDER_LEAD_MS = 24
|
||||||
@ -421,9 +421,7 @@ const LyricsSettingsPopover = ({ settings, onChange }) => {
|
|||||||
|
|
||||||
const easeInOut = (v) => {
|
const easeInOut = (v) => {
|
||||||
const clamped = clamp(v, 0, 1)
|
const clamped = clamp(v, 0, 1)
|
||||||
return clamped < 0.5
|
return clamped < 0.5 ? 2 * clamped * clamped : 1 - (-2 * clamped + 2) ** 2 / 2
|
||||||
? 2 * clamped * clamped
|
|
||||||
: 1 - Math.pow(-2 * clamped + 2, 2) / 2
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const getMaxHeightPx = () => {
|
const getMaxHeightPx = () => {
|
||||||
@ -716,17 +714,23 @@ const KaraokeLineRow = memo(
|
|||||||
}
|
}
|
||||||
alpha = clamp(alpha, TOKEN_FUTURE_ALPHA, TOKEN_ACTIVE_ALPHA)
|
alpha = clamp(alpha, TOKEN_FUTURE_ALPHA, TOKEN_ACTIVE_ALPHA)
|
||||||
const fillProgress = isDone ? 1 : isActive ? progress : 0
|
const fillProgress = isDone ? 1 : isActive ? progress : 0
|
||||||
|
const isBgRole = segment.token?.role === 'bg'
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<span
|
<span
|
||||||
key={`token-${idx}-${tokenStart ?? 'na'}`}
|
key={`token-${idx}-${tokenStart ?? 'na'}`}
|
||||||
className={tokenClassName}
|
className={tokenClassName}
|
||||||
style={buildTokenWipeStyle({
|
style={{
|
||||||
|
...buildTokenWipeStyle({
|
||||||
fillProgress,
|
fillProgress,
|
||||||
highlightAlpha: alpha,
|
highlightAlpha: isBgRole ? alpha * 0.72 : alpha,
|
||||||
futureAlpha: TOKEN_FUTURE_ALPHA,
|
futureAlpha: isBgRole
|
||||||
|
? TOKEN_FUTURE_ALPHA * 0.72
|
||||||
|
: TOKEN_FUTURE_ALPHA,
|
||||||
rgb: tokenRGB,
|
rgb: tokenRGB,
|
||||||
})}
|
}),
|
||||||
|
...(isBgRole ? { fontStyle: 'italic' } : undefined),
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{segment.text}
|
{segment.text}
|
||||||
</span>
|
</span>
|
||||||
@ -1066,7 +1070,7 @@ const KaraokeLyricsOverlay = ({
|
|||||||
const isActive = delta === 0
|
const isActive = delta === 0
|
||||||
let opacity = isActive ? 1 : delta < 0 ? 0.6 : 0.72
|
let opacity = isActive ? 1 : delta < 0 ? 0.6 : 0.72
|
||||||
const [r, g, b] = parseColorRGB(getColorValue(lyricsSettings.main.colorKey))
|
const [r, g, b] = parseColorRGB(getColorValue(lyricsSettings.main.colorKey))
|
||||||
let color = isActive
|
const color = isActive
|
||||||
? `rgba(${r}, ${g}, ${b}, 0.98)`
|
? `rgba(${r}, ${g}, ${b}, 0.98)`
|
||||||
: delta < 0
|
: delta < 0
|
||||||
? `rgba(${r}, ${g}, ${b}, 0.4)`
|
? `rgba(${r}, ${g}, ${b}, 0.4)`
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user