|
|
@@ -1,11 +1,11 @@
|
|
|
-import { duration2text, printD, printW } from '../extensions/Extension';
|
|
|
+import { duration2text, printW } from '../extensions/Extension';
|
|
|
import { LyricController } from '../LyricController';
|
|
|
import { Lyric } from '../bean/Lyric';
|
|
|
import { ListAdapter } from '../extensions/ListAdapter';
|
|
|
import { LyricLine } from '../bean/LyricLine';
|
|
|
import { LyricWord } from '../bean/LyricWord';
|
|
|
import { transverter, TransverterType, TransverterLanguage } from "@nutpi/chinese_transverter"
|
|
|
-import { curves, LengthMetrics } from '@kit.ArkUI';
|
|
|
+import { LengthMetrics } from '@kit.ArkUI';
|
|
|
|
|
|
/**
|
|
|
* A component to display the lyric with scroll animation.
|
|
|
@@ -73,6 +73,11 @@ export struct LyricView2 {
|
|
|
@State isHightLightCenter: boolean = true
|
|
|
@State currentMediaPosition: number = 0
|
|
|
@State transverterType: number = 0
|
|
|
+ private lastPositionUpdateTs: number = 0
|
|
|
+ private lastPositionForRender: number = -1
|
|
|
+ private readonly positionUpdateThrottleMs: number = 33
|
|
|
+ private readonly minPositionDeltaMs: number = 8
|
|
|
+ private readonly karaokeTransitionWidth: number = 0.12
|
|
|
|
|
|
private onDataChangedListener = (lyric: Lyric | null) => {
|
|
|
clearTimeout(this.loadTimeout)
|
|
|
@@ -88,6 +93,13 @@ export struct LyricView2 {
|
|
|
this.currentMediaPosition = mediaPosition
|
|
|
return
|
|
|
}
|
|
|
+ const now = Date.now()
|
|
|
+ if (now - this.lastPositionUpdateTs < this.positionUpdateThrottleMs &&
|
|
|
+ Math.abs(mediaPosition - this.lastPositionForRender) < this.minPositionDeltaMs) {
|
|
|
+ return
|
|
|
+ }
|
|
|
+ this.lastPositionUpdateTs = now
|
|
|
+ this.lastPositionForRender = mediaPosition
|
|
|
this.currentMediaPosition = mediaPosition
|
|
|
this.onPositionChanged(mediaPosition)
|
|
|
}
|
|
|
@@ -244,9 +256,6 @@ export struct LyricView2 {
|
|
|
this.seekIndex = center;
|
|
|
let targetPosition = this.listAdapter.getData(center).beginTime;
|
|
|
this.scrollDurationText = duration2text(targetPosition);
|
|
|
- } else {
|
|
|
- // 处理 center 不在范围内的情况
|
|
|
- console.error(`Index out of range: ${center}`);
|
|
|
}
|
|
|
// this.seekIndex = center
|
|
|
// let targetPosition = this.listAdapter.getData(center).beginTime
|
|
|
@@ -286,7 +295,8 @@ export struct LyricView2 {
|
|
|
NormalLyricLine(item: LyricLine, index: number) {
|
|
|
Column(){
|
|
|
Text(item.text)
|
|
|
- .fontSize(index == this.currentIndex && !(this.currentLyric && this.currentLyric.isPlainText) ?this.textSize*this.controller.getHighlightScale():this.textSize)
|
|
|
+ .fontSize(index == this.currentIndex && !(this.currentLyric && this.currentLyric.isPlainText) ?
|
|
|
+ this.textHighlightSize : this.textSize)
|
|
|
.textAlign(this.alignMode == 'center' ? TextAlign.Center : TextAlign.Start)
|
|
|
.fontColor(index == this.currentIndex && !(this.currentLyric && this.currentLyric.isPlainText) ? this.textHighlightColor : this.textColor)
|
|
|
.fontWeight(index == this.currentIndex && !(this.currentLyric && this.currentLyric.isPlainText) && this.isHighlightBold ? FontWeight.Bold : this.textWeight)
|
|
|
@@ -295,10 +305,6 @@ export struct LyricView2 {
|
|
|
(index >= this.currentIndex && index <= this.currentIndex + 1 ? Visibility.Visible : Visibility.None)
|
|
|
: Visibility.Visible)
|
|
|
.width(this.alignMode == 'center' ? '100%' :index == this.currentIndex && !(this.currentLyric && this.currentLyric.isPlainText) ?'95%': '80%')
|
|
|
- .animation({
|
|
|
- duration: 150,
|
|
|
- curve: Curve.Linear
|
|
|
- })
|
|
|
.blendMode(
|
|
|
index == this.currentIndex && !(this.currentLyric && this.currentLyric.isPlainText) ? BlendMode.DST_IN : undefined,
|
|
|
index == this.currentIndex && !(this.currentLyric && this.currentLyric.isPlainText) ? BlendApplyType.OFFSCREEN : undefined
|
|
|
@@ -308,7 +314,8 @@ export struct LyricView2 {
|
|
|
if (item.translation) {
|
|
|
|
|
|
Text(item.translation)
|
|
|
- .fontSize(index == this.currentIndex && !(this.currentLyric && this.currentLyric.isPlainText) ?this.textSize*this.controller.getHighlightScale():this.textSize)
|
|
|
+ .fontSize(index == this.currentIndex && !(this.currentLyric && this.currentLyric.isPlainText) ?
|
|
|
+ this.textHighlightSize : this.textSize)
|
|
|
.fontColor(this.currentMediaPosition >= item.beginTime ?
|
|
|
index == this.currentIndex && !(this.currentLyric && this.currentLyric.isPlainText) ? this.textHighlightColor : this.textColor : this.textColor)
|
|
|
.fontWeight(index == this.currentIndex && !(this.currentLyric && this.currentLyric.isPlainText) && this.isHighlightBold ? FontWeight.Bold : this.textWeight)
|
|
|
@@ -322,10 +329,6 @@ export struct LyricView2 {
|
|
|
index == this.currentIndex && !(this.currentLyric && this.currentLyric.isPlainText) ? BlendApplyType.OFFSCREEN : undefined
|
|
|
)
|
|
|
.width(this.alignMode == 'center' ? '100%' :index == this.currentIndex && !(this.currentLyric && this.currentLyric.isPlainText) ?'95%': '80%')
|
|
|
- .animation({
|
|
|
- duration: 150,
|
|
|
- curve: Curve.Linear
|
|
|
- })
|
|
|
|
|
|
}
|
|
|
}
|
|
|
@@ -346,6 +349,41 @@ export struct LyricView2 {
|
|
|
/**
|
|
|
* 计算卡拉OK渐变 - 同色系从浅到深的平滑过渡
|
|
|
*/
|
|
|
+ private clamp01(value: number): number {
|
|
|
+ return Math.max(0, Math.min(1, value))
|
|
|
+ }
|
|
|
+
|
|
|
+ private smoothStep01(value: number): number {
|
|
|
+ const x = this.clamp01(value)
|
|
|
+ return x * x * (3 - 2 * x)
|
|
|
+ }
|
|
|
+
|
|
|
+ private createKaraokeGradient(progress: number, transitionWidth: number = this.karaokeTransitionWidth): [ResourceColor, number][] {
|
|
|
+ const p = this.clamp01(progress)
|
|
|
+ if (p <= 0) {
|
|
|
+ return [[this.textColor, 0.0], [this.textColor, 1.0]]
|
|
|
+ }
|
|
|
+ if (p >= 1) {
|
|
|
+ return [[this.textHighlightColor, 0.0], [this.textHighlightColor, 1.0]]
|
|
|
+ }
|
|
|
+
|
|
|
+ const width = Math.max(0.02, Math.min(0.3, transitionWidth))
|
|
|
+ const half = width / 2
|
|
|
+ const transitionStart = this.clamp01(p - half)
|
|
|
+ const transitionEnd = this.clamp01(p + half)
|
|
|
+
|
|
|
+ return [[this.textHighlightColor, 0.0],
|
|
|
+ [this.textHighlightColor, transitionStart],
|
|
|
+ [this.textColor, transitionEnd],
|
|
|
+ [this.textColor, 1.0]]
|
|
|
+ }
|
|
|
+
|
|
|
+ private getAdaptiveWordTransitionWidth(wordDuration: number): number {
|
|
|
+ const safeDuration = Math.max(wordDuration, 80)
|
|
|
+ const shortWordBoost = this.clamp01((260 - safeDuration) / 260)
|
|
|
+ return Math.max(0.08, Math.min(0.24, this.karaokeTransitionWidth + shortWordBoost * 0.08))
|
|
|
+ }
|
|
|
+
|
|
|
getLyricItemLinearGradient(item: LyricLine, index: number): [ResourceColor, number][] {
|
|
|
// 只对当前播放行且包含逐字数据的行应用卡拉OK效果
|
|
|
if (index !== this.currentIndex || item.words.length === 0) {
|
|
|
@@ -366,7 +404,6 @@ export struct LyricView2 {
|
|
|
//console.info('heanup', `getLyricItemLinearGradient - index=${index}, lyricDuration=${lyricDuration}, currentMediaPosition=${this.currentMediaPosition}, itemBeginTime=${item.beginTime}`)
|
|
|
|
|
|
if (lyricDuration <= 0) {
|
|
|
- console.info('heanup', `getLyricItemLinearGradient - 歌词时长<=0, 返回透明`)
|
|
|
return [[Color.Transparent, 0.0], [Color.Transparent, 1.0]]
|
|
|
}
|
|
|
|
|
|
@@ -374,12 +411,9 @@ export struct LyricView2 {
|
|
|
let diff = this.currentMediaPosition - item.beginTime
|
|
|
let value = diff / lyricDuration
|
|
|
|
|
|
- value = Math.max(0, Math.min(1, value))
|
|
|
+ value = this.smoothStep01(value)
|
|
|
|
|
|
- return [[this.textHighlightColor, 0.0],
|
|
|
- [this.textHighlightColor, value],
|
|
|
- [this.textColor, value],
|
|
|
- [this.textColor, 1.0]]
|
|
|
+ return this.createKaraokeGradient(value, 0.1)
|
|
|
}
|
|
|
|
|
|
/**
|
|
|
@@ -397,47 +431,39 @@ export struct LyricView2 {
|
|
|
return [[Color.White, 0.0], [Color.White, 1.0]]
|
|
|
}
|
|
|
|
|
|
- // 计算该字的播放进度
|
|
|
- const wordEndTime = word.startTime + word.duration
|
|
|
- const wordDuration = word.duration
|
|
|
+ const rawDuration = Math.max(word.duration, 0)
|
|
|
+ const effectiveDuration = rawDuration > 0 ? rawDuration : 180
|
|
|
+ const preRoll = Math.min(90, Math.max(20, effectiveDuration * 0.16))
|
|
|
+ const postRoll = Math.min(110, Math.max(26, effectiveDuration * 0.24))
|
|
|
+ const smoothStart = word.startTime - preRoll
|
|
|
+ const smoothEnd = word.startTime + effectiveDuration + postRoll
|
|
|
+ const width = this.getAdaptiveWordTransitionWidth(effectiveDuration)
|
|
|
|
|
|
- // 异常情况处理
|
|
|
- if (wordDuration <= 0) {
|
|
|
- //console.info('heanup', `getWordByWordLyricLyricItemLinearGradient - word时长<=0: startTime=${word.startTime}, duration=${word.duration}`)
|
|
|
- // 如果时长无效,检查当前播放位置是否已到达开始时间
|
|
|
- if (this.currentMediaPosition >= word.startTime) {
|
|
|
- // 已开始播放,全部高亮
|
|
|
- return [[this.textHighlightColor, 0.0], [this.textHighlightColor, 1.0]]
|
|
|
- } else {
|
|
|
- // 未开始播放,全部普通颜色
|
|
|
- return [[this.textColor, 0.0], [this.textColor, 1.0]]
|
|
|
- }
|
|
|
+ if (this.currentMediaPosition <= smoothStart) {
|
|
|
+ return this.createKaraokeGradient(0, width)
|
|
|
}
|
|
|
-
|
|
|
- // 计算当前在该字内的播放进度(0-1之间)
|
|
|
- let progress = 0
|
|
|
- if (this.currentMediaPosition < word.startTime) {
|
|
|
- // 还没播放到这个字
|
|
|
- progress = 0
|
|
|
- } else if (this.currentMediaPosition >= wordEndTime) {
|
|
|
- // 这个字已经播放完
|
|
|
- progress = 1
|
|
|
- } else {
|
|
|
- // 正在播放这个字,计算进度
|
|
|
- const diff = this.currentMediaPosition - word.startTime
|
|
|
- progress = diff / wordDuration
|
|
|
- progress = Math.max(0, Math.min(1, progress))
|
|
|
+ if (this.currentMediaPosition >= smoothEnd) {
|
|
|
+ return this.createKaraokeGradient(1, width)
|
|
|
}
|
|
|
|
|
|
- console.info('heanup', `getWordByWordLyricLyricItemLinearGradient - word=${word.word}, progress=${progress}, currentPos=${this.currentMediaPosition}, wordStart=${word.startTime}, wordEnd=${wordEndTime}`)
|
|
|
+ const linearProgress = this.clamp01((this.currentMediaPosition - smoothStart) / (smoothEnd - smoothStart))
|
|
|
+ const easedProgress = this.smoothStep01(linearProgress)
|
|
|
+ const blendedProgress = this.clamp01(easedProgress * 0.88 + linearProgress * 0.12)
|
|
|
+ return this.createKaraokeGradient(blendedProgress, width)
|
|
|
+ }
|
|
|
|
|
|
- // 返回卡拉OK渐变效果
|
|
|
- // 0.0 到 progress:高亮色(已播放部分)
|
|
|
- // progress 到 1.0:普通色(未播放部分)
|
|
|
- return [[this.textHighlightColor, 0.0],
|
|
|
- [this.textHighlightColor, progress],
|
|
|
- [this.textColor, progress],
|
|
|
- [this.textColor, 1.0]]
|
|
|
+ private getWordPlaybackState(word: LyricWord): number {
|
|
|
+ const duration = Math.max(word.duration, 0)
|
|
|
+ const effectiveDuration = duration > 0 ? duration : 180
|
|
|
+ const endTime = word.startTime + effectiveDuration
|
|
|
+ const tailWindow = Math.min(90, Math.max(24, effectiveDuration * 0.2))
|
|
|
+ if (this.currentMediaPosition <= word.startTime) {
|
|
|
+ return 0
|
|
|
+ }
|
|
|
+ if (this.currentMediaPosition >= endTime + tailWindow) {
|
|
|
+ return 2
|
|
|
+ }
|
|
|
+ return 1
|
|
|
}
|
|
|
|
|
|
@Builder
|
|
|
@@ -445,26 +471,23 @@ export struct LyricView2 {
|
|
|
Column() {
|
|
|
Row({ space: 0 }) {
|
|
|
ForEach(item.words, (word: LyricWord, wordIndex: number) => {
|
|
|
-
|
|
|
-
|
|
|
Text(word.word)
|
|
|
- .fontSize(index == this.currentIndex && !(this.currentLyric && this.currentLyric.isPlainText) ?this.textSize*this.controller.getHighlightScale():this.textSize)
|
|
|
- .fontColor(this.currentMediaPosition >= word.startTime ?
|
|
|
- index == this.currentIndex && !(this.currentLyric && this.currentLyric.isPlainText) ? this.textHighlightColor : this.textColor : this.textColor)
|
|
|
- .fontWeight(index == this.currentIndex && !(this.currentLyric && this.currentLyric.isPlainText)? FontWeight.Bold : this.textWeight)
|
|
|
+ .fontSize(index == this.currentIndex && !(this.currentLyric && this.currentLyric.isPlainText) ?
|
|
|
+ this.textHighlightSize : this.textSize)
|
|
|
+ .fontColor(index == this.currentIndex && !(this.currentLyric && this.currentLyric.isPlainText) &&
|
|
|
+ this.getWordPlaybackState(word) == 2 ? this.textHighlightColor : this.textColor)
|
|
|
+ .fontWeight(index == this.currentIndex && !(this.currentLyric && this.currentLyric.isPlainText) ?
|
|
|
+ FontWeight.Bold : this.textWeight)
|
|
|
.margin(isEnglish(word.word) ?{ right:4 }:{})
|
|
|
.visibility(this.isSingleLine?
|
|
|
(index >= this.currentIndex && index <= this.currentIndex + 1 ? Visibility.Visible : Visibility.None)
|
|
|
: Visibility.Visible)
|
|
|
.padding(this.isSingleLine?0:{ top:5,bottom:5 })
|
|
|
- .shaderStyle(index == this.currentIndex ?{
|
|
|
+ .shaderStyle(index == this.currentIndex && !(this.currentLyric && this.currentLyric.isPlainText) &&
|
|
|
+ this.getWordPlaybackState(word) == 1 ?{
|
|
|
direction: GradientDirection.Right,
|
|
|
colors: this.getWordByWordLyricLyricItemLinearGradient(item, word, index)
|
|
|
}:undefined)
|
|
|
- .animation({
|
|
|
- duration: 150,
|
|
|
- curve: Curve.Linear
|
|
|
- })
|
|
|
})
|
|
|
}
|
|
|
.justifyContent(this.alignMode === 'center' ? FlexAlign.Center : FlexAlign.Start)
|
|
|
@@ -474,7 +497,8 @@ export struct LyricView2 {
|
|
|
if (item.translation) {
|
|
|
Row({ space: 0 }) {
|
|
|
Text(item.translation)
|
|
|
- .fontSize(index == this.currentIndex && !(this.currentLyric && this.currentLyric.isPlainText) ?this.textSize*this.controller.getHighlightScale():this.textSize)
|
|
|
+ .fontSize(index == this.currentIndex && !(this.currentLyric && this.currentLyric.isPlainText) ?
|
|
|
+ this.textHighlightSize : this.textSize)
|
|
|
.fontColor(this.currentMediaPosition >= item.beginTime ?
|
|
|
index == this.currentIndex && !(this.currentLyric && this.currentLyric.isPlainText) ? this.textHighlightColor : this.textColor : this.textColor)
|
|
|
.fontWeight(index == this.currentIndex && !(this.currentLyric && this.currentLyric.isPlainText)? FontWeight.Bold : this.textWeight)
|
|
|
@@ -483,10 +507,6 @@ export struct LyricView2 {
|
|
|
.visibility(this.isSingleLine?
|
|
|
(index >= this.currentIndex && index <= this.currentIndex + 1 ? Visibility.Visible : Visibility.None)
|
|
|
: Visibility.Visible)
|
|
|
- .animation({
|
|
|
- duration: 150,
|
|
|
- curve: Curve.Linear
|
|
|
- })
|
|
|
}
|
|
|
.justifyContent(this.alignMode === 'center' ? FlexAlign.Center : FlexAlign.Start)
|
|
|
.width(this.alignMode == 'center' ? '100%' :index == this.currentIndex && !(this.currentLyric && this.currentLyric.isPlainText) ?'95%': '85%')
|
|
|
@@ -638,7 +658,6 @@ export struct LyricView2 {
|
|
|
}
|
|
|
|
|
|
private getIndex(position: number): number {
|
|
|
-
|
|
|
let size = this.listAdapter.totalCount()
|
|
|
if (size === 0) return 0 // 空列表保护
|
|
|
|
|
|
@@ -647,25 +666,33 @@ export struct LyricView2 {
|
|
|
return 0
|
|
|
}
|
|
|
|
|
|
- let first = this.listAdapter.getData(0).beginTime
|
|
|
+ const first = this.listAdapter.getData(0).beginTime
|
|
|
if (position < first) {
|
|
|
return 0
|
|
|
}
|
|
|
- let last = this.listAdapter.getData(size - 1).beginTime
|
|
|
- if (position > last) {
|
|
|
- return size - 1
|
|
|
+ const lastIndex = size - 1
|
|
|
+ const last = this.listAdapter.getData(lastIndex).beginTime
|
|
|
+ if (position >= last) {
|
|
|
+ return lastIndex
|
|
|
}
|
|
|
- for (let i = 0; i < size - 1; i++) {
|
|
|
- let line = this.listAdapter.getData(i)
|
|
|
- if (position >= line.beginTime && position < line.nextTime) {
|
|
|
- return i
|
|
|
+
|
|
|
+ let left = 0
|
|
|
+ let right = lastIndex
|
|
|
+ while (left <= right) {
|
|
|
+ const mid = (left + right) >> 1
|
|
|
+ const beginTime = this.listAdapter.getData(mid).beginTime
|
|
|
+ if (beginTime <= position) {
|
|
|
+ left = mid + 1
|
|
|
+ } else {
|
|
|
+ right = mid - 1
|
|
|
}
|
|
|
}
|
|
|
- return this.currentIndex
|
|
|
+
|
|
|
+ return Math.max(0, Math.min(lastIndex, right))
|
|
|
}
|
|
|
|
|
|
private animateToIndex(index: number) {
|
|
|
- printD('animate to index= ' + index)
|
|
|
+ // printD('animate to index= ' + index)
|
|
|
this.currentIndex = index
|
|
|
if (this.isUserTouching) {
|
|
|
return
|
|
|
@@ -695,6 +722,14 @@ export struct LyricView2 {
|
|
|
if (this.currentLyric && this.currentLyric.isPlainText) {
|
|
|
return
|
|
|
}
|
|
|
+
|
|
|
+ if (this.currentIndex >= 0 && this.currentIndex < this.listAdapter.totalCount()) {
|
|
|
+ const currentLine = this.listAdapter.getData(this.currentIndex)
|
|
|
+ if (mediaPosition >= currentLine.beginTime && mediaPosition < currentLine.nextTime) {
|
|
|
+ return
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
let index = this.getIndex(mediaPosition)
|
|
|
if (index != this.currentIndex) {
|
|
|
this.animateToIndex(index)
|
|
|
@@ -718,4 +753,4 @@ function isEnglish(text: string, mode: string = 'strict', threshold: number = 0
|
|
|
function checkByPercentage(text: string, threshold: number): boolean {
|
|
|
const validChars = text.match(/[a-zA-Z\s.,!?'"-]/g) || [];
|
|
|
return (validChars.length / text.length) >= threshold;
|
|
|
-}
|
|
|
+}
|