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 { curves, 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 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 } 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); } 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 }) // .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%') .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); } 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: // 纯文本歌词不需要自动滚动回顶部 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(index == this.currentIndex && !(this.currentLyric && this.currentLyric.isPlainText) ?this.textSize*this.controller.getHighlightScale():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) .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%') .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 ) // 中文翻译(整行显示) if (item.translation) { Text(item.translation) .fontSize(index == this.currentIndex && !(this.currentLyric && this.currentLyric.isPlainText) ?this.textSize*this.controller.getHighlightScale():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) .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%') .animation({ duration: 150, curve: Curve.Linear }) } } // 在 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渐变 - 同色系从浅到深的平滑过渡 */ 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) { console.info('heanup', `getLyricItemLinearGradient - 歌词时长<=0, 返回透明`) return [[Color.Transparent, 0.0], [Color.Transparent, 1.0]] } // 计算当前播放进度(0-1之间) let diff = this.currentMediaPosition - item.beginTime let value = diff / lyricDuration value = Math.max(0, Math.min(1, value)) return [[this.textHighlightColor, 0.0], [this.textHighlightColor, value], [this.textColor, value], [this.textColor, 1.0]] } /** * 计算逐字歌词的卡拉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 wordEndTime = word.startTime + word.duration const wordDuration = word.duration // 异常情况处理 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]] } } // 计算当前在该字内的播放进度(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)) } console.info('heanup', `getWordByWordLyricLyricItemLinearGradient - word=${word.word}, progress=${progress}, currentPos=${this.currentMediaPosition}, wordStart=${word.startTime}, wordEnd=${wordEndTime}`) // 返回卡拉OK渐变效果 // 0.0 到 progress:高亮色(已播放部分) // progress 到 1.0:普通色(未播放部分) return [[this.textHighlightColor, 0.0], [this.textHighlightColor, progress], [this.textColor, progress], [this.textColor, 1.0]] } @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.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) .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 ?{ // direction: GradientDirection.Right, // colors: this.getWordByWordLyricLyricItemLinearGradient(item, word, index) // }:undefined) .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%') // 中文翻译(整行显示) 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) .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 }) .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%') } } .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) => { printW('onSizeChanged: ' + JSON.stringify(newSize)) this.h = newSize.height as number this.w = newSize.width as number 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 // 空列表保护 // 如果是纯文本歌词,始终返回 0(不滚动) if (this.currentLyric && this.currentLyric.isPlainText) { 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 } // 如果是纯文本歌词,不进行滚动同步 if (this.currentLyric && this.currentLyric.isPlainText) { 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; }