鸿蒙Next动态歌词组件封装增加暂停功能 原创

auhgnixgnahz
发布于 2025-9-16 10:01
浏览
0收藏

上一篇实现了一句歌词颜色渐变效果,这篇在上一篇的基础上,完整实现歌词的动态展示,并且实现播放和暂停功能。
最终效果:
鸿蒙Next动态歌词组件封装增加暂停功能-鸿蒙开发者社区
歌词解析:
我们把歌词先按行分割,记录每行歌词的开始时间和歌词内容,然后再将每行的歌词按照上一篇的方法分割,依次执行属性动画。

[00:00.000]义勇军进行曲
[00:07.00]
[00:08.44] 起来 [00:09.64] 不愿做 [00:10.84] 奴隶的 [00:12.04] 人们
[00:12.65]
[00:12.85] 把我们的 [00:14.05] 血肉 [00:15.25] 筑成我们 [00:16.45] 新的长城
[00:17.89]
[00:18.99] 中华民族 [00:20.19] 到了 [00:21.39] 最危险的 [00:22.59] 时候
[00:24.26]
[00:25.34] 每个人 [00:26.54] 被迫着 [00:27.74] 发出 [00:28.94] 最后的 [00:30.14] 吼声
[00:30.36]
[00:31.16] 起来 [00:32.36] 起来 [00:33.56] 起来
[00:34.25]
[00:35.37] 我们万众一心 [00:36.57] 冒着敌人的 [00:37.77] 炮火 [00:38.97] 前进
[00:39.27]
[00:40.79] 冒着敌人的 [00:41.99] 炮火 [00:43.19] 前进 [00:44.39] 前进 [00:45.59] 前进 [00:46.79] 进

注意
解析歌词时,需要读取歌词前后的时间,计算动画时长,需要注意时间格式,有的歌词格式下载下来可能时00:00:000,修改时间计算的正则表达式即可,保持歌词内容和解析的统一。
动画优化:
上篇说到animateTo的刷新和V2冲突,如果要实现动画的暂停功能,这里我们使用createAnimator方法,自定义一个动画,然后执行play、pause方法,即可实现动画的播放、暂停功能。
createAnimator()参数:AnimatorOptions

名称 说明
duration 动画播放的时长
easing 动画插值曲线
delay 动画延时播放时长
fill 是否恢复到初始状态
direction 动画播放模式
iterations 动画播放次数
begin 动画插值起点
end 动画插值终点

createAnimator()执行方法和回调

名称 说明
play 启动动画
finish 结束动画,会触发onFinish回调
pause 暂停动画
cancel 取消动画,会触发onCancel回调
onFrame 接收到帧时回调
onFinish 动画完成时回调
onCancel 动画被取消时回调
onRepeat 动画重复时回调

歌词组件封装源码:

// 一行中有单独时间分割的字或词语
export class Word {
  index: number = 0;
  text: string = '';
  startTime: number = 0;
  durationTime: number = 0;
}
// 一行歌词
export class LrcLine {
  lineStartTime: number = 0;
  words: Word[] = [];
}
@ComponentV2
struct LyricView{
  private scroller: Scroller = new Scroller();
  @Require @Param lrcTitle:string
  @Require @Param lrcName:string
  @Local mLrcEntryList: LrcLine[] = [];
  @Local currentIndex: number = 0; //每词在每句中索引
  @Local colorStopValue: number = 0;
  @Local currentLineIndex: number = 0; //每句歌词索引
  @Local lrcDuration: number = 0; // 歌词持续时间
  private lrcAnimator: AnimatorResult | undefined = undefined
  @Consumer() displaying: boolean = false
  @Monitor('displaying')
  play(){
    if (this.displaying) {
      //播放
      this.lrcAnimator?.play()
    }else {
      //播放
      this.lrcAnimator?.pause()
    }
  }
  playLrc(duration: number) {
    this.lrcAnimator = this.getUIContext().createAnimator({
      duration: duration,
      easing: 'linear',
      delay: 0,
      fill: 'none',
      direction: 'normal',
      iterations: 1,
      begin: 0,
      end: 1
    })
    this.lrcAnimator.onFinish = () => {
      this.colorStopValue = 0
      this.currentIndex++;
      let currentLine = this.mLrcEntryList[this.currentLineIndex]
      if (this.currentIndex < currentLine.words.length) {
        this.lrcDuration = currentLine.words[this.currentIndex].durationTime;
        this.playLrc(this.lrcDuration);
      } else {
        this.currentIndex = 0
        this.currentLineIndex++
        if (this.currentLineIndex < this.mLrcEntryList.length) {
          let nextLine = this.mLrcEntryList[this.currentLineIndex]
          this.lrcDuration = nextLine.words[this.currentIndex].durationTime;
          this.playLrc(this.lrcDuration);
        }else {
          this.currentLineIndex = 0
          this.displaying = false
        }
      }
    }
    this.lrcAnimator.onFrame = (value: number) => {
      this.colorStopValue=value
    }
    if (this.displaying) this.lrcAnimator.play()
  }
  async aboutToAppear(): Promise<void> {
    let lrcString = await getContext(this).resourceManager.getRawFileContent(this.lrcName);
    let textDecoder = util.TextDecoder.create('utf-8', { ignoreBOM: true });
    let stringData = textDecoder.decodeToString(lrcString, { stream: false });
    this.processLrc(stringData.split('\n'));
  }
  processLrc(lyric: string[]): void {
    let lrcList: LrcLine[] = [];
    //读取歌词文件中的时间 时间格式为00:00:00
    let wordRegex: RegExp = /\[(\d{2}):(\d{2})\.(\d{2})\]([^[\]]+)/g;
    let wordIndex = 0;

    lyric.forEach((line: string) => {
      let words: Word[] = [];
      let lineStartTime: number = 0;
      let match: RegExpExecArray | null;

      while ((match = wordRegex.exec(line)) !== null) {
        let mm: number = Number.parseInt(match[1], 10);
        let ss: number = Number.parseInt(match[2], 10);
        let ms: number = Number.parseInt(match[3], 10);
        let text: string = match[4].trim();
        let time: number = mm * 60 * 1000 + ss * 1000 + ms * 10;

        if (words.length === 0) {
          lineStartTime = time;
        }
        let nextTime = this.extractAllTimestampsFromLyrics(lyric)[wordIndex+1]
        let lineTimeDuration: number = nextTime !== undefined ? (nextTime! - time) : 1000;
        words.push({
          index: wordIndex,
          text: text,
          startTime: time,
          durationTime: lineTimeDuration
        });
        wordIndex++
      }
      if (words.length > 0) {
        lrcList.push({
          lineStartTime: lineStartTime,
          words: words
        });
      }
    });
    this.mLrcEntryList = lrcList;
  }
  extractAllTimestampsFromLyrics(lyric: string[]): number[] {
    const timestamps: number[] = [];

    for (const line of lyric) {
      let regex = /\[(\d{2}):(\d{2})\.(\d{2})\]/g;
      let match: RegExpExecArray | null;

      while ((match = regex.exec(line)) !== null) {
        let mm = parseInt(match[1], 10);
        let ss = parseInt(match[2], 10);
        let ms = parseInt(match[3], 10);

        let timeMs = mm * 60 * 1000 + ss * 1000 + ms * 10;
        timestamps.push(timeMs);
      }
    }
    // 升序排序(确保时间顺序正确)
    return timestamps.sort((a, b) => a - b);
  }
  build() {
    Column(){
      Text(this.lrcTitle)
        .fontColor(Color.White)
        .fontSize(24)
        .fontWeight(700)
        .opacity(0.86)
        .height(40)
      Stack() {
        List({ initialIndex: 0, scroller: this.scroller }) {
          ForEach(this.mLrcEntryList, (line: LrcLine, lineIndex: number) => {
            ListItem() {
              Column() {
                Flex({ wrap: FlexWrap.Wrap, justifyContent: FlexAlign.Center }) {
                  ForEach(line.words, (word: Word, wordIndex: number) => {
                    Row() {
                      Text(word.text)
                        .fontSize(this.currentLineIndex==lineIndex?20:16)
                        .blendMode(BlendMode.DST_IN, BlendApplyType.OFFSCREEN)
                    }
                    .margin({
                      right: word.text.match(/[a-zA-Z0-9]/) ? 4 : 0,
                      bottom: 2
                    })
                    .blendMode(BlendMode.SRC_OVER, BlendApplyType.OFFSCREEN)
                    .linearGradient({
                      direction: GradientDirection.Right,
                      colors: (() => {
                        if (this.currentLineIndex !== lineIndex) {
                          // 当前行未激活,全部白色
                          return [['#FFFFFFFF', 0], ['#FFFFFFFF', 1.0]];
                        }
                        if (wordIndex < this.currentIndex) {
                          // 已播放的字:全红
                          return [['#FF0000', 0.0], ['#FF0000', 1.0]];
                        } else if (wordIndex === this.currentIndex) {
                          // 正在播放的字:红色渐变(从 0 到 this.colorStopValue)
                          return [
                            ['#FF0000', 0.0],
                            ['#FF0000', this.colorStopValue],
                            ['#FFFFFF', this.colorStopValue],
                            ['#FFFFFF', 1.0]
                          ];
                        } else {
                          // 未播放的字:全白
                          return [['#FFFFFFFF', 0], ['#FFFFFFFF', 1.0]];
                        }
                      })()
                    })
                    .onAppear(() => {
                      this.lrcDuration = line.words[wordIndex].durationTime
                      if (lineIndex === this.currentLineIndex && wordIndex === 0) {
                        this.playLrc(line.words[wordIndex].durationTime);
                      }
                    })
                  })
                }
                .width('90%')
                .padding(10)
              }
              .width('100%')
            }
            .opacity(lineIndex === this.currentLineIndex ? 1 : 0.8)
          })
        }
        .height('auto')
      }
    }
  }
  aboutToDisappear(): void {
    this.lrcAnimator?.finish();
    this.lrcAnimator = undefined;
  }
}

歌词组件使用

import { util } from '@kit.ArkTS';
import { AnimatorResult } from '@kit.ArkUI';
@Entry
@ComponentV2
struct LyricTest {
  @Provider() displaying: boolean = false;
  build() {
    Column({space:20}) {
      LyricView({lrcTitle:'义勇军进行曲',lrcName:'sing.lrc'})
      Button(this.displaying?'暂停':'开始').fontSize(24).onClick(()=>{
        this.displaying=!this.displaying
      })
    }
    .width('100%')
    .height('100%')
    .linearGradient({
      // 渐变方向
      direction: GradientDirection.Bottom,
      // 渐变颜色是否重复
      repeating: true,
      // 数组末尾元素占比小于1时满足重复着色效果
      colors: [['#17233c', 0.0], ['#2b3755', 0.6], ['#181e34', 1]]
    })
  }
}

©著作权归作者所有,如需转载,请注明出处,否则将追究法律责任
收藏
回复
举报
回复
    相关推荐