import { duration2text } 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 { LengthMetrics } from '@kit.ArkUI'; /** * A component to display the lyric with scroll animation. * This component only support api 10+. * * The custom setter include the lyric text style, fade style of edge, the line space, scroll animation duration, * the cache size to draw out of screen. */ @Component export struct LyricView2 { /** * The lyricConfig for LyricView. */ controller: LyricController = new LyricController() /** * Enable the lyric scroll to do seek action or not. * If false, the onSeekAction callback will not invoke anymore. */ enableSeek: boolean = true /** * The color of seek button and duration text. */ seekUIColor: ResourceColor = '#000000' /** * The color of the seek location line. */ seekLineColor: ResourceColor = '#0d000000' /** * The seek ui style. */ seekUIStyle: 'seekLine' | 'listItem' = 'listItem' /** * The seek callback for scroll the lyric. * If return true, you must handle this action to do seek action of media player. * If return false, the lyric view will scroll to current index of the media playing position. */ onSeekAction: (position: number) => boolean = () => false private currentLyric: Lyric | null = null private scroller = new Scroller() private listAdapter = new ListAdapter() private w = 0 // the width of this view. private h = 0 // the height of this view. @State currentIndex: number = 0 // the focused index of the lyric line list. @State isLyricEmpty: boolean = true // is no lyric. @State textSize: number = 16 @State isSingleLine: boolean = false @State lineSpace: number = 16 @State textWeight: number = FontWeight.Medium @State textHighlightSize: number = 18 @State textColor: string = '#000000' @State textHighlightColor: string = '#000000' @State isHighlightBold: boolean = false @State alignMode: 'left' | 'center' = 'center' @State emptyHint: string = '' @State cacheSize: number = 3 @State animDuration: number = 300 @State isUserTouching: boolean = false @State seekPosition: number = -1 @State scrollDurationText: string = '00:00' @State seekIndex: number = -1 private seekUiHideTimeout = -1 private autoHideSeekUIDuration = 2000 @State isLoadingData: boolean = false private loadTimeout = -1 @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 @State highlightFontProgress: number = 1 @State highlightFontActiveIndex: number = -1 private highlightFontToken: number = 0 private highlightFontStartTimeout = -1 private readonly highlightFontStartDelayMs: number = 100 private readonly highlightFontDurationMs: number = 180 private readonly areaReloadThresholdPx: number = 1 private onDataChangedListener = (lyric: Lyric | null) => { clearTimeout(this.loadTimeout) this.isLoadingData = true this.loadData(lyric) this.loadTimeout = setTimeout(() => { this.isLoadingData = false }, 300) } private onPositionChangedListener = (mediaPosition: number) => { // 如果是纯文本歌词,不进行位置同步 if (this.currentLyric && this.currentLyric.isPlainText) { 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) } private onInvalidatedListener = (reLayout: boolean) => { this.getAttrFromController() if (reLayout && this.currentIndex > 0) { this.animateToIndex(this.currentIndex) } } private loadData(lyric: Lyric | null) { this.currentLyric = lyric; clearTimeout(this.highlightFontStartTimeout) this.highlightFontToken += 1 this.highlightFontActiveIndex = -1 this.highlightFontProgress = 1 if (this.w > 0 && this.h > 0) { if (this.currentLyric) { this.listAdapter.clear(false); let lyricLines = this.currentLyric.lyricList; if (lyricLines!==undefined&&lyricLines.length > 0) { let first = lyricLines[0].beginTime; // fill the top empty gap 注释掉修复 歌曲不播放的时候,可以显示完整的歌词,而不是显示一两行 // for (let i = 0; i < this.centerOffsetSize; i++) { // this.listAdapter.addData(new LyricLine(' ', 0, first), false); // } lyricLines.forEach((line) => { this.listAdapter.addData(line, false); }); // fill the bottom empty gap // let last = lyricLines[lyricLines.length - 1].nextTime; // for (let i = 0; i < this.centerOffsetSize; i++) { // this.listAdapter.addData(new LyricLine(' ', last + i, last + i), false); // } } this.listAdapter.notifyDataReload(); } else { this.listAdapter.clear(true); } } this.isLyricEmpty = this.listAdapter.isEmpty(); this.lastPositionUpdateTs = 0 this.lastPositionForRender = -1 if (this.isLyricEmpty) { this.currentIndex = 0 this.scrollToIndexImmediately(0) return } const initialIndex = this.resolveInitialIndex() this.currentIndex = initialIndex this.scrollToIndexImmediately(initialIndex) } private resolveInitialIndex(): number { const count = this.listAdapter.totalCount() if (count <= 0) { return 0 } if (this.currentLyric && this.currentLyric.isPlainText) { return 0 } const firstLine = this.listAdapter.getData(0) const lastLine = this.listAdapter.getData(count - 1) const lastEndTime = lastLine.nextTime > lastLine.beginTime ? lastLine.nextTime : lastLine.beginTime if (this.currentMediaPosition <= firstLine.beginTime - 2000 || this.currentMediaPosition > lastEndTime + 2000) { return 0 } return this.getIndex(this.currentMediaPosition) } private scrollToIndexImmediately(index: number): void { const count = this.listAdapter.totalCount() if (count <= 0) { return } const safeIndex = Math.max(0, Math.min(count - 1, index)) if(this.isHightLightCenter){ this.scroller.scrollToIndex(safeIndex, false, ScrollAlign.CENTER) }else{ const targetIndex = Math.max(0, safeIndex - 2) this.scroller.scrollToIndex(targetIndex, false, ScrollAlign.START) } } private getAttrFromController() { this.currentLyric = this.controller.getLyric() this.textSize = this.controller.getTextSize() this.transverterType = this.controller.getTransverterType() this.isSingleLine = this.controller.getSingleLine() this.blurDegree = this.controller.getBlurDegree() this.isHightLightCenter = this.controller.getHightLightCenter() this.lineSpace = this.controller.getLineSpace() this.textColor = this.controller.getTextColor() this.textHighlightColor = this.controller.getHighlightColor() this.textHighlightSize = this.controller.getHighlightScale() * this.textSize this.isHighlightBold = this.controller.isHighlightBold() this.animDuration = this.controller.getAnimationDuration() this.cacheSize = this.controller.getCacheSize() this.emptyHint = this.controller.getEmptyHint() this.alignMode = this.controller.getAlignMode() this.textWeight = this.controller.getTextWeight() } aboutToAppear() { if (this.controller == null) { throw new Error('The lyric lyricConfig is not set!') } this.controller.onDataChangedListener = this.onDataChangedListener this.controller.onPositionChangedListener = this.onPositionChangedListener this.controller.onInvalidated = this.onInvalidatedListener this.getAttrFromController() // 初始化时将滚动位置设置为顶部 this.scroller.scrollToIndex(0, true, ScrollAlign.START); } @Builder EmptyView() { Text(this.emptyHint) .fontSize(this.textSize) .fontColor(this.textColor) } @State blurDegree: number = 3 // 优化建议代码示例:增加滚动节流 private lastScrollTime: number = 0 private scrollThrottle: number = 100 // 100ms节流 @Builder LyricListView() { List({ space: this.lineSpace - 16, scroller: this.scroller }) { LazyForEach(this.listAdapter, (item: LyricLine, index: number) => { ListItem() { Stack() { // 逐字歌词渲染 if (item.hasWords() && item.words.length > 0) { this.WordByWordLyric(item, index) } else { // 普通歌词渲染(原有逻辑) this.NormalLyricLine(item, index) } if (this.seekUIStyle == 'listItem' && index == this.seekIndex && item.text.length > 0 && this.enableSeek && this.isUserTouching){ this.JumpProgress() } } .align(Alignment.End) } .padding(8) .border({ radius: 12 }) .onClick(() => { if (this.seekUIStyle == 'listItem' && index == this.seekIndex && item.text.length > 0) { if (this.seekIndex != -1 && this.seekIndex != this.currentIndex) { this.handleSeekAction(); } } }) }, (item: LyricLine, index: number) => { return item.text + '_' + index + '_' + item.beginTime + '_' + item.nextTime }) } .width('100%') .height('100%') .layoutWeight(1) .scrollBar(BarState.Off) .fadingEdge(true,{fadingEdgeLength:LengthMetrics.percent(20)}) .edgeEffect(EdgeEffect.Spring) .contentEndOffset(this.h / 3) .contentStartOffset(this.isUserTouching?this.h / 3:0) .cachedCount(this.cacheSize) // .chainAnimation(true) // .animation({ // curve: curves.springCurve(100, 10, 80, 10), // duration: 500 // }) .visibility(this.isLoadingData ? Visibility.Hidden : Visibility.Visible) .onScrollIndex((_, __, center) => { const now = Date.now() if (now - this.lastScrollTime < this.scrollThrottle) return this.lastScrollTime = now // 纯文本歌词不支持 seek 操作 if (this.isUserTouching && !(this.currentLyric && this.currentLyric.isPlainText)) { //修复The value of "index" is out of range. It must be >= 0 && <= -1. Received value is: -1 if (center >= 0 && center < this.listAdapter.totalCount()) { this.seekIndex = center; let targetPosition = this.listAdapter.getData(center).beginTime; this.scrollDurationText = duration2text(targetPosition); } // this.seekIndex = center // let targetPosition = this.listAdapter.getData(center).beginTime // this.scrollDurationText = duration2text(targetPosition) } }) .onTouch((event) => { let motion = event.touches[0] switch (motion.type) { case TouchType.Down: clearTimeout(this.seekUiHideTimeout) break case TouchType.Move: this.isUserTouching = true break case TouchType.Up: case TouchType.Cancel: // 纯文本歌词不需要自动滚动回顶部 if (this.currentLyric && this.currentLyric.isPlainText) { this.seekUiHideTimeout = setTimeout(() => { this.seekIndex = -1 this.isUserTouching = false // 纯文本歌词不调用 animateToIndex,保持在当前位置 }, this.autoHideSeekUIDuration) } else { this.seekUiHideTimeout = setTimeout(() => { this.seekIndex = -1 this.isUserTouching = false this.animateToIndex(this.currentIndex) }, this.autoHideSeekUIDuration) } } }) } // 普通歌词渲染(原有逻辑) @Builder NormalLyricLine(item: LyricLine, index: number) { Column(){ Text(item.text) .fontSize(this.getAnimatedLyricFontSize(index)) .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) .padding(this.isSingleLine?0:{ top:5,bottom:5 }) .visibility(this.isSingleLine? (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%') .blendMode( index == this.currentIndex && !(this.currentLyric && this.currentLyric.isPlainText) ? BlendMode.DST_IN : undefined, index == this.currentIndex && !(this.currentLyric && this.currentLyric.isPlainText) ? BlendApplyType.OFFSCREEN : undefined ) // 中文翻译(整行显示) if (item.translation) { Text(item.translation) .fontSize(this.getAnimatedLyricFontSize(index)) .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) .padding(this.isSingleLine?{ bottom:2 }:{ bottom:5 }) .textAlign(this.alignMode == 'center' ? TextAlign.Center : TextAlign.Start) .visibility(this.isSingleLine? (index >= this.currentIndex && index <= this.currentIndex + 1 ? Visibility.Visible : Visibility.None) : Visibility.Visible) .blendMode( index == this.currentIndex && !(this.currentLyric && this.currentLyric.isPlainText) ? BlendMode.DST_IN : undefined, 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%') } } // 在 Row 上应用渐变 .linearGradient(index == this.currentIndex && !(this.currentLyric && this.currentLyric.isPlainText) ? { direction: GradientDirection.Right, colors: this.getLyricItemLinearGradient(item, index) } : undefined) .blendMode( index == this.currentIndex && !(this.currentLyric && this.currentLyric.isPlainText) ? BlendMode.SRC_OVER : undefined, index == this.currentIndex && !(this.currentLyric && this.currentLyric.isPlainText) ? BlendApplyType.OFFSCREEN : undefined ) } /** * 计算卡拉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) { //console.info('heanup', `getLyricItemLinearGradient - 非高亮行或无逐字数据: index=${index}, currentIndex=${this.currentIndex}, hasWords=${item.hasWords()}, wordsCount=${item.words.length}`) return [[Color.White, 0.0], [Color.White, 1.0]] } // 计算该行歌词的总时长 let lyricDuration: number if (index < this.listAdapter.totalCount() - 1) { const nextLine = this.listAdapter.getData(index + 1) lyricDuration = nextLine.beginTime - item.beginTime } else { // 最后一行,使用 nextTime(如果有)或者估计时长 lyricDuration = item.nextTime > item.beginTime ? item.nextTime - item.beginTime : 5000 } //console.info('heanup', `getLyricItemLinearGradient - index=${index}, lyricDuration=${lyricDuration}, currentMediaPosition=${this.currentMediaPosition}, itemBeginTime=${item.beginTime}`) if (lyricDuration <= 0) { return [[Color.Transparent, 0.0], [Color.Transparent, 1.0]] } // 计算当前播放进度(0-1之间) let diff = this.currentMediaPosition - item.beginTime let value = diff / lyricDuration value = this.smoothStep01(value) return this.createKaraokeGradient(value, 0.1) } /** * 计算逐字歌词的卡拉OK渐变效果 * 该方法针对逐字歌词格式,根据当前播放进度和每个字的时间信息计算渐变 * @param item 当前歌词行 * @param word 当前字的信息 * @param index 当前行索引 * @returns 渐变颜色数组 */ getWordByWordLyricLyricItemLinearGradient(item: LyricLine, word: LyricWord, index: number): [ResourceColor, number][] { // 非高亮行或无效数据,返回透明 if (index !== this.currentIndex || !word || !word.word) { //console.info('heanup', `getWordByWordLyricLyricItemLinearGradient - 非高亮行或无效word: index=${index}, currentIndex=${this.currentIndex}`) return [[Color.White, 0.0], [Color.White, 1.0]] } 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 (this.currentMediaPosition <= smoothStart) { return this.createKaraokeGradient(0, width) } if (this.currentMediaPosition >= smoothEnd) { return this.createKaraokeGradient(1, width) } 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) } 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 WordByWordLyric(item: LyricLine, index: number) { Column() { Flex({ direction: FlexDirection.Row, wrap: FlexWrap.Wrap, justifyContent: this.alignMode === 'center' ? FlexAlign.Center : FlexAlign.Start }) { ForEach(item.words, (word: LyricWord, wordIndex: number) => { Text(word.word) .fontSize(this.getAnimatedLyricFontSize(index)) .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 && !(this.currentLyric && this.currentLyric.isPlainText) && this.getWordPlaybackState(word) == 1 ?{ direction: GradientDirection.Right, colors: this.getWordByWordLyricLyricItemLinearGradient(item, word, index) }:undefined) }) } .width(this.alignMode == 'center' ? '100%' :index == this.currentIndex && !(this.currentLyric && this.currentLyric.isPlainText) ?'95%': '85%') // 中文翻译(整行显示) if (item.translation) { Row({ space: 0 }) { Text(item.translation) .fontSize(this.getAnimatedLyricFontSize(index)) .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) .padding({ bottom:5 }) .padding(this.isSingleLine?{ bottom:2 }:{ bottom:5 }) .textAlign(this.alignMode == 'center' ? TextAlign.Center : TextAlign.Start) .width('100%') .visibility(this.isSingleLine? (index >= this.currentIndex && index <= this.currentIndex + 1 ? Visibility.Visible : Visibility.None) : Visibility.Visible) } .justifyContent(this.alignMode === 'center' ? FlexAlign.Center : FlexAlign.Start) .width(this.alignMode == 'center' ? '100%' :index == this.currentIndex && !(this.currentLyric && this.currentLyric.isPlainText) ?'95%': '85%') } } .height('auto') } //修复get Property index out of bounds private handleSeekAction() { clearTimeout(this.seekUiHideTimeout); let itemCount = this.listAdapter.totalCount(); // 获取数据项的总数 // 确保 seekIndex 在有效范围内 if (this.seekIndex >= 0 && this.seekIndex < itemCount) { let targetPosition = this.listAdapter.getData(this.seekIndex).beginTime; let isPlayerHandled = this.onSeekAction(targetPosition); if (!isPlayerHandled) { this.animateToIndex(this.currentIndex); } } else { console.error("Seek index out of bounds:", this.seekIndex); // 处理超出范围的情况,比如设定默认值或抛出错误 } this.isUserTouching = false; } getTransverterText(message: string):string{ if(this.transverterType==1){ return transverter({ type: TransverterType.TRADITIONAL, str: message, language: TransverterLanguage.ZH_TW }); }else if(this.transverterType==2){ return transverter({ type: TransverterType.SIMPLIFIED, str: message, language: TransverterLanguage.ZH_CN }); }else{ return message; } } @Builder JumpProgress(){ Row(){ Row(){ Row({space: 10}){ Text(this.scrollDurationText) .fontSize(12) .fontWeight(FontWeight.Medium) .fontColor(Color.White) SymbolGlyph($r('sys.symbol.play')) .fontSize(13) .fontColor([Color.White]) .alignSelf(ItemAlign.Center) } .borderRadius(10) .alignItems(VerticalAlign.Center) .height(32) .padding(10) .backgroundColor(Color.Transparent) .backgroundBlurStyle(BlurStyle.BACKGROUND_REGULAR, { colorMode: ThemeColorMode.LIGHT, adaptiveColor: AdaptiveColor.DEFAULT, scale: 1.0 }) } .transition( TransitionEffect .scale({ x: 0.7, y: 0.7 }) .combine(TransitionEffect .opacity(0.1) ) .animation({ duration: 150, curve: Curve.EaseInOut, }) ) } .hitTestBehavior(HitTestMode.Transparent) .width(110) .justifyContent(FlexAlign.End) .backgroundColor(Color.Transparent) } @Builder SeekLine() { Row() { Image($r('app.media.cclyric_play')) .width(24) .height(24) .fillColor(this.seekUIColor) .objectFit(ImageFit.Fill) .clickEffect({ level: ClickEffectLevel.MIDDLE }) .onClick(() => { if (this.seekIndex != -1 && this.seekIndex != this.currentIndex) { this.handleSeekAction() } }) Stack() .height(1) .layoutWeight(1) .backgroundColor(this.seekLineColor) .margin({ left: 8, right: 8 }) Text(this.scrollDurationText) .fontSize(this.textSize) .fontColor(this.seekUIColor) } .visibility(this.enableSeek && this.isUserTouching ? Visibility.Visible : Visibility.Hidden) .width('100%') .height('100%') .hitTestBehavior(HitTestMode.Transparent) .transition(TransitionEffect.OPACITY.animation({ duration: this.animDuration })) } build() { Stack() { if (this.isLyricEmpty) { this.EmptyView() } else { this.LyricListView() } } .width('100%') .height('100%') .onAreaChange((_, newSize) => { const nextHeight = Number(newSize.height) || 0 const nextWidth = Number(newSize.width) || 0 const isFirstMeasure = this.h <= 0 || this.w <= 0 const isSizeChanged = Math.abs(nextHeight - this.h) >= this.areaReloadThresholdPx || Math.abs(nextWidth - this.w) >= this.areaReloadThresholdPx if (!isFirstMeasure && !isSizeChanged) { return } this.h = nextHeight this.w = nextWidth if (this.currentLyric) { if (this.listAdapter.isEmpty()) { this.loadData(this.currentLyric) } else { this.scrollToIndexImmediately(this.currentIndex) } } }) } aboutToDisappear() { clearTimeout(this.loadTimeout) clearTimeout(this.seekUiHideTimeout) clearTimeout(this.highlightFontStartTimeout) } private getIndex(position: number): number { let size = this.listAdapter.totalCount() if (size === 0) return 0 // 空列表保护 // 如果是纯文本歌词,始终返回 0(不滚动) if (this.currentLyric && this.currentLyric.isPlainText) { return 0 } const first = this.listAdapter.getData(0).beginTime if (position < first) { return 0 } const lastIndex = size - 1 const last = this.listAdapter.getData(lastIndex).beginTime if (position >= last) { return lastIndex } 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 Math.max(0, Math.min(lastIndex, right)) } private animateToIndex(index: number) { // printD('animate to index= ' + index) const count = this.listAdapter.totalCount() if (count <= 0) { this.currentIndex = 0 return } const safeIndex = Math.max(0, Math.min(count - 1, index)) const previousIndex = this.currentIndex this.currentIndex = safeIndex if (this.isUserTouching) { return } this.playHighlightFontAnimation(previousIndex, safeIndex) if(this.isHightLightCenter){ this.scroller.scrollToIndex(safeIndex, true, ScrollAlign.CENTER) }else{ // 计算目标索引,使其在居中位置上方有两条歌词 const targetIndex = Math.max(0, safeIndex - 2); this.scroller.scrollToIndex(targetIndex, true, ScrollAlign.START); } } private shouldApplyHighlightFontAnimation(index: number): boolean { return index === this.currentIndex && index === this.highlightFontActiveIndex && !(this.currentLyric && this.currentLyric.isPlainText) && !this.isUserTouching } private getAnimatedLyricFontSize(index: number): number { if (!(index === this.currentIndex) || (this.currentLyric && this.currentLyric.isPlainText)) { return this.textSize } if (!this.shouldApplyHighlightFontAnimation(index)) { return this.textHighlightSize } const progress = Math.max(0, Math.min(1, this.highlightFontProgress)) return this.textSize + (this.textHighlightSize - this.textSize) * progress } private playHighlightFontAnimation(previousIndex: number, nextIndex: number): void { if (previousIndex === nextIndex) { return } if (this.currentLyric && this.currentLyric.isPlainText) { return } clearTimeout(this.highlightFontStartTimeout) this.highlightFontToken += 1 const token = this.highlightFontToken this.highlightFontActiveIndex = nextIndex this.highlightFontProgress = 0 this.highlightFontStartTimeout = setTimeout(() => { if (token !== this.highlightFontToken) { return } animateTo({ duration: this.highlightFontDurationMs, curve: Curve.EaseOut }, () => { if (token !== this.highlightFontToken) { return } this.highlightFontProgress = 1 this.highlightFontActiveIndex = -1 }) }, this.highlightFontStartDelayMs) } private onPositionChanged(mediaPosition: number) { if (this.isLyricEmpty) { // printW('The lyric data is empty!') return } if (this.listAdapter.isEmpty()) { // printW('The lyric lines is empty!') return } // 如果是纯文本歌词,不进行滚动同步 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) } } } function isEnglish(text: string, mode: string = 'strict', threshold: number = 0.9): boolean { if (!text || typeof text !== 'string') return false; switch (mode) { case 'basic': return /^[a-zA-Z\s.,!?'"-]+$/.test(text); case 'percentage': return checkByPercentage(text, threshold); default: // strict return /^[\u0000-\u007F]+$/.test(text.trim()); } } function checkByPercentage(text: string, threshold: number): boolean { const validChars = text.match(/[a-zA-Z\s.,!?'"-]/g) || []; return (validChars.length / text.length) >= threshold; }