import { duration2text, printD, 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 { 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 = 0 @State animDuration: number = 300 @State isUserTouching: boolean = false private centerOffsetSize: number = 0 // seek action: center line view @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 onDataChangedListener = (lyric: Lyric | null) => { clearTimeout(this.loadTimeout) this.isLoadingData = true this.loadData(lyric) this.loadTimeout = setTimeout(() => { this.isLoadingData = false }, 300) } private onPositionChangedListener = (mediaPosition: number) => { 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; 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.currentIndex = 0; this.animateToIndex(0); printW('loadData isEmpty = ' + this.isLyricEmpty); } 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) } Text(this.scrollDurationText) .fontSize(this.textSize) .fontColor(this.seekUIColor) .textAlign( TextAlign.End) .width(100) .visibility(this.seekUIStyle == 'listItem' && index == this.seekIndex && item.text.length > 0 && this.enableSeek && this.isUserTouching?Visibility.Visible:Visibility.Hidden) } .align(Alignment.End) } .padding(8) .transition(TransitionEffect.asymmetric(TransitionEffect.scale({ x: 1.2, y: 1.2 }).animation({ duration: 500 }), TransitionEffect.scale({ x: 0, y: 0 }) )) .border({ radius: 12 }) .backgroundColor(this.seekUIStyle == 'listItem' && index == this.seekIndex && item.text.length > 0 && this.enableSeek && this.isUserTouching ? '#80a9a9a9' : '#00000000') .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%') .scrollBar(BarState.Off) .fadingEdge(true,{fadingEdgeLength:LengthMetrics.percent(20)}) .cachedCount(this.cacheSize) .transition(TransitionEffect.asymmetric( this.isSingleLine? TransitionEffect.scale({ x: 1.2, y: 1.2 }).animation({ duration: 500 }): TransitionEffect.move(TransitionEdge.BOTTOM).animation({ duration: 500 }), TransitionEffect.scale({ x: 0, y: 0 }) )) .visibility(this.isLoadingData ? Visibility.Hidden : Visibility.Visible) .onScrollIndex((_, __, center) => { const now = Date.now() if (now - this.lastScrollTime < this.scrollThrottle) return this.lastScrollTime = now if (this.isUserTouching) { //修复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); } else { // 处理 center 不在范围内的情况 console.error(`Index out of range: ${center}`); } // 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: 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.textSize) .textAlign(this.alignMode == 'center' ? TextAlign.Center : TextAlign.Start) .scale({ x: index == this.currentIndex ? this.controller.getHighlightScale() : 1, y: index == this.currentIndex ? this.controller.getHighlightScale() : 1, centerX: this.alignMode == 'center' ? '50%' : 0 }) .fontColor(index == this.currentIndex ? this.textHighlightColor : this.textColor) .fontWeight(index == this.currentIndex && this.isHighlightBold ? FontWeight.Bold : this.textWeight) .animation({ duration: this.animDuration, curve: Curve.FastOutSlowIn }) .visibility(this.isSingleLine? (index == this.currentIndex ?Visibility.Visible:Visibility.None) :Visibility.Visible) .width(this.alignMode == 'center' ? '100%' : '76%') // 中文翻译(整行显示) if (item.translation) { Row({ space: 0 }) { Text(item.translation) .fontSize(index == this.currentIndex ?this.textSize*this.controller.getHighlightScale():this.textSize) .fontColor(this.currentMediaPosition >= item.beginTime ? index == this.currentIndex ? this.textHighlightColor : this.textColor : this.textColor) .fontWeight(index == this.currentIndex && this.isHighlightBold ? FontWeight.Bold : this.textWeight) .margin({ top: 4 }) } .justifyContent(this.alignMode === 'center' ? FlexAlign.Center : FlexAlign.Start) .width(this.alignMode == 'center' ? '100%' :index == this.currentIndex ?'95%': '85%') } } } @Builder WordByWordLyric(item: LyricLine, index: number) { Column() { Row({ space: 0 }) { ForEach(item.words, (word: LyricWord, wordIndex: number) => { Text(word.word) .fontSize(index == this.currentIndex ?this.textSize*this.controller.getHighlightScale():this.textSize) .fontColor(this.currentMediaPosition >= word.startTime ? index == this.currentIndex ? this.textHighlightColor : this.textColor : this.textColor) .fontWeight(this.currentMediaPosition >= word.startTime && this.isHighlightBold ? index == this.currentIndex? FontWeight.Bold : this.textWeight: this.textWeight) .margin(isEnglish(word.word) ?{ right:4 }:{}) .visibility(this.isSingleLine? (index == this.currentIndex ?Visibility.Visible:Visibility.None) :Visibility.Visible) .animation({ // 动画播放速度 tempo: 0.8, // 动画持续时间,单位是毫秒 duration: 777, // 动画缓动函数 curve: Curve.FastOutSlowIn }) }) } .justifyContent(this.alignMode === 'center' ? FlexAlign.Center : FlexAlign.Start) .width(this.alignMode == 'center' ? '100%' :index == this.currentIndex ?'95%': '85%') // 中文翻译(整行显示) if (item.translation) { Row({ space: 0 }) { Text(item.translation) .fontSize(index == this.currentIndex ?this.textSize*this.controller.getHighlightScale():this.textSize) .fontColor(this.currentMediaPosition >= item.beginTime ? index == this.currentIndex ? this.textHighlightColor : this.textColor : this.textColor) .fontWeight(index == this.currentIndex && this.isHighlightBold ? FontWeight.Bold : this.textWeight) .margin({ top: 4 }) } .justifyContent(this.alignMode === 'center' ? FlexAlign.Center : FlexAlign.Start) .width(this.alignMode == 'center' ? '100%' :index == this.currentIndex ?'95%': '85%') } } } isTopBottomLine(index:number){ return index === 0 || index === this.listAdapter.totalCount() - 1; } //修复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 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() } if (this.seekUIStyle == 'seekLine') { this.SeekLine() } } .width('100%') .height('100%') .onAreaChange((_, newSize) => { printW('onSizeChanged: ' + JSON.stringify(newSize)) this.h = newSize.height as number this.w = newSize.width as number let lineH = this.textSize + this.lineSpace this.centerOffsetSize = Math.floor((this.h - lineH) / 2 / lineH) printW('offsetSize = ' + this.centerOffsetSize) if (this.currentLyric) { this.loadData(this.currentLyric) } }) } aboutToDisappear() { clearTimeout(this.loadTimeout) clearTimeout(this.seekUiHideTimeout) } private getIndex(position: number): number { let size = this.listAdapter.totalCount() if (size === 0) return 0 // 空列表保护 let 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 } for (let i = 0; i < size - 1; i++) { let line = this.listAdapter.getData(i) if (position >= line.beginTime && position < line.nextTime) { return i } } return this.currentIndex } private animateToIndex(index: number) { printD('animate to index= ' + index) this.currentIndex = index if (this.isUserTouching) { return } if(this.isHightLightCenter){ this.scroller.scrollToIndex(index, true, ScrollAlign.CENTER) }else{ // 计算目标索引,使其在居中位置上方有两条歌词 const targetIndex = Math.max(0, index - 2); this.scroller.scrollToIndex(targetIndex, true, ScrollAlign.START); } } 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 } 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; }