Sfoglia il codice sorgente

初步实现逐步歌词功能
修复歌词的颜色选择器的布局
新增高亮歌词颜色自定义

onecold 1 anno fa
parent
commit
777196dabd

+ 107 - 12
entry/src/main/ets/view/LocalMusic.ets

@@ -4949,6 +4949,7 @@ export struct LocalMusic {
     this.setHighLyricTextSize(PreferencesUtil.getNumberSync('HighLyricTextSize', 1.23))
     this.lyricController.setLineSpace(PreferencesUtil.getNumberSync('LyricLineSpace', 52))
     this.changeLyricColor(PreferencesUtil.getStringSync('LyricColor', '#FFFFFF'))
+    this.changeLyricHightLightColor(PreferencesUtil.getStringSync('LyricHighLightColor', '#FFFFFF'))
     this.isHightLightCenter = PreferencesUtil.getBooleanSync(SettingPage.IS_SHOW_SIMI, false)
     this.lyricController.setHightLightCenter(this.isHightLightCenter)
     this.lyricTextWeight = PreferencesUtil.getNumberSync('lyricTextWeight',400)
@@ -5849,6 +5850,7 @@ export struct LocalMusic {
 
   @State timeOffset: number = 0.0
   @State currentLyricColor: string = '#FFFFFF'
+  @State currentHighLightLyricColor: string = '#FF4081'
   // 歌词字号状态
   @State currentLyricSize: number = 20
   // 高亮歌词放大倍数
@@ -5856,6 +5858,25 @@ export struct LocalMusic {
   @State currentLyricLineSpace: number = 52 //{
   @State currentLyricAlignMode: number = 1
   @State blurDegree: number = 2
+  @State isShowSelectColor: boolean = false
+  @State isShowHLSelectColor: boolean = false
+  // 构建颜色选择器
+  @Builder
+  SelectColor(isHighColor:boolean) {
+    Row({space: 10}) {
+      HSBColorPicker({
+        color: '#FFFFFF',
+        radius: 8,
+        layout: HSBColorPickerLayout.COLUMN,
+        predefine: ['#8b27f4', '#73f9fc', '#fffe55', '#f5cee3', '#eb4827', '#e93bf4', '#3e68f4', '#c5e6d3', '#e4e4e4', '#fa7105'],
+        onChange:(value: string) =>isHighColor?this.changeLyricHightLightColor(value):this.changeLyricColor(value)
+      })
+        .height(250)
+        .layoutWeight(1)
+        .padding(25)
+    }
+    .width('66%')
+  }
 
   // 构建字号控制器
   @Builder
@@ -5894,18 +5915,87 @@ export struct LocalMusic {
         Text(`歌词颜色:`)
           .fontSize(14)
           .fontColor(Color.White)
+        Text(this.currentLyricColor)
+          .fontSize(15)
+          .fontColor(Color.White)
+          .margin({ left: 10, right: 12 })
+        Button({ type: ButtonType.Capsule, stateEffect: true }) {
+          Row() {
+            SymbolGlyph($r('sys.symbol.paintbrush'))
+            Text(`选择颜色`)
+              .fontSize(13)
+              .margin({ left: 10 })
+              .fontColor('007DFF')
 
-        Row({space: 10}) {
-          HSBColorPicker({
-            color: '#FFFFFF',
-            radius: 8,
-            layout: HSBColorPickerLayout.COLUMN,
-            onChange:(value: string) =>this.changeLyricColor(value)
-          })
-            .height(88)
-            .layoutWeight(1)
-            .padding({left:15,right:35})
+          }
         }
+        .backgroundColor($r('app.color.bottom_control_background'))
+        .height(38)
+        .width(110)
+        .onClick(()=>{
+          this.isShowSelectColor = !this.isShowSelectColor
+        })
+        .bindPopup(this.isShowSelectColor, {
+          builder: this.SelectColor(false),
+          placement: Placement.Top,
+          // autoCancel: true,
+          mask: {color:'#33000000'},
+          // popupColor: Color.Yellow,
+          enableArrow: false,//是否显示箭头
+          showInSubWindow: false,
+          onStateChange: (e) => {
+            if (!e.isVisible) {
+              this.isShowSelectColor = false
+            }
+          }
+        })
+
+
+
+      }
+      .width('76%')
+      .margin({ top:10,bottom:10,right:45,left:20})
+
+      Row() {
+        Text(`高亮颜色:`)
+          .fontSize(14)
+          .fontColor(Color.White)
+        Text(this.currentHighLightLyricColor)
+          .fontSize(15)
+          .fontColor(Color.White)
+          .margin({ left: 10, right: 12 })
+        Button({ type: ButtonType.Capsule, stateEffect: true }) {
+          Row() {
+            SymbolGlyph($r('sys.symbol.paintbrush'))
+            Text(`选择颜色`)
+              .fontSize(13)
+              .margin({ left: 10 })
+              .fontColor('007DFF')
+
+          }
+        }
+        .backgroundColor($r('app.color.bottom_control_background'))
+        .height(38)
+        .width(110)
+        .onClick(()=>{
+          this.isShowHLSelectColor = !this.isShowHLSelectColor
+        })
+        .bindPopup(this.isShowHLSelectColor, {
+          builder: this.SelectColor(true),
+          placement: Placement.Top,
+          // autoCancel: true,
+          mask: {color:'#33000000'},
+          // popupColor: Color.Yellow,
+          enableArrow: false,//是否显示箭头
+          showInSubWindow: false,
+          onStateChange: (e) => {
+            if (!e.isVisible) {
+              this.isShowHLSelectColor = false
+            }
+          }
+        })
+
+
 
       }
       .width('76%')
@@ -5948,7 +6038,6 @@ export struct LocalMusic {
           .fontSize(14)
           .fontColor(Color.White)
 
-
         Slider({
           value: this.currentHightLyricSize,
           min: 1,
@@ -6152,10 +6241,16 @@ export struct LocalMusic {
     this.currentLyricColor = color
     this.lyricController
       .setTextColor(this.currentLyricColor)
-      .setHighlightColor(this.currentLyricColor)
     PreferencesUtil.putSync('LyricColor', this.currentLyricColor)
   }
 
+  private changeLyricHightLightColor(color: string) {
+    this.currentHighLightLyricColor = color
+    this.lyricController
+      .setHighlightColor(this.currentHighLightLyricColor)
+    PreferencesUtil.putSync('LyricHighLightColor', this.currentHighLightLyricColor)
+  }
+
   // 构建时间控制器
   @Builder
   BuildTimeControls() {

+ 10 - 3
lib/src/main/ets/bean/LyricLine.ts

@@ -1,19 +1,26 @@
+import { LyricWord } from './LyricWord'
 /**
  * The line info of lyric.
  */
 export class LyricLine {
-    readonly text: string
+     text: string
     readonly beginTime: number
     nextTime: number
-
+    words: LyricWord[] = []
     /**
      * @param text The text of lyric line.
      * @param beginTime The begin timestamp of this lyric line.
      * @param nextTime The begin timestamp of the next lyric line.
      */
-    constructor(text: string, beginTime: number, nextTime: number) {
+    constructor(text: string, beginTime: number, nextTime: number,words?: LyricWord[] ) {
         this.text = text
         this.beginTime = beginTime
         this.nextTime = nextTime
+        this.words = words || []
+    }
+
+    // 新增方法:判断是否有逐字歌词
+    hasWords(): boolean {
+        return this.words.length > 0
     }
 }

+ 12 - 0
lib/src/main/ets/bean/LyricWord.ts

@@ -0,0 +1,12 @@
+// 逐字歌词的数据结构
+export class LyricWord {
+  word: string
+  startTime: number
+  duration: number
+
+  constructor(word: string, startTime: number, duration: number) {
+    this.word = word
+    this.startTime = startTime
+    this.duration = duration
+  }
+}

+ 72 - 16
lib/src/main/ets/parse/LyricParser.ts

@@ -2,6 +2,7 @@ import { IParser } from './IParser';
 import { Lyric } from '../bean/Lyric';
 import { LyricLine } from '../bean/LyricLine';
 import { printD, printW } from '../extensions/Extension';
+import { LyricWord } from '../bean/LyricWord';
 
 /**
  * The parser to parse the string array of a standard lyric file.
@@ -54,23 +55,31 @@ export class LyricParser implements IParser {
             } else if (line.indexOf("offset") > 0) {
                 offset = Number.parseInt(this.parseIdTag(line))
             } else {
-                // [00:00.10]画心 - 张靓颖
-                // [01:05.49][02:08.40]看不穿 是你失落的魂魄
-                let spr = line.split(']')
-                if (spr.length <= 1) {
-                    printW("the lyric line is no timestamp, line index= " + i)
-                    continue
-                }
-                // parse text
-                let text = spr[spr.length-1]
-                // printD("text= " + text)
-                // parse timeline
-                for (let i = 0;i < spr.length - 1; i++) {
-                    let timeline = spr[i].replace("[", "")
-                    let timeStamp = this.parseTimeline(timeline)
-                    // printD("timestamp= " + timeStamp)
-                    lyricLines.push(new LyricLine(text, timeStamp - offset, -1))
+
+                // 新增逐字歌词解析逻辑
+                if (this.isWordByWordLyric(line)) {
+                    const { timeline, words } = this.parseWordByWordLine(line, offset);
+                    lyricLines.push(new LyricLine('', timeline, -1, words))
+                } else {
+                    // 原逻辑处理,但支持逐字歌词
+                    // [00:00.10]画心 - 张靓颖
+                    // [01:05.49][02:08.40]看不穿 是你失落的魂魄
+                    let spr = line.split(']');
+                    if (spr.length <= 1) {
+                        printW("the lyric line is no timestamp, line index= " + i)
+                        continue
+                    }
+                    // parse text
+                    let text = spr[spr.length-1]
+                    // ... 原来的文本解析逻辑保持不变 ...
+                    for (let i = 0; i < spr.length - 1; i++) {
+                        let timeline = spr[i].replace("[", "");
+                        let timeStamp = this.parseTimeline(timeline);
+                        lyricLines.push(new LyricLine(text, timeStamp - offset, -1));
+                    }
                 }
+
+
             }
         }
         lyricLines.sort((l1, l2) => {
@@ -85,10 +94,57 @@ export class LyricParser implements IParser {
                 lyricLine.nextTime = next.beginTime
             }
         }
+        // 为逐字歌词填充text(拼接所有歌词词)
+        this.populateTextForWordLyrics(lyricLines);
         let result = new Lyric(artist, title, album, by, offset, lyricLines)
         return result
     }
 
+    // 判断是否是逐字歌词行
+    private isWordByWordLyric(line: string): boolean {
+        const hasBracketTimestamp = /$$\d{2}:\d{2}\.\d{2,3}$$\S/.test(line);
+        const hasAngleTimestamp = /<\d{2}:\d{2}\.\d{2,3}>/.test(line);
+        return hasBracketTimestamp || hasAngleTimestamp;
+    }
+
+    // 解析逐字歌词行
+    private parseWordByWordLine(line: string, offset: number): { timeline: number, words: LyricWord[] } {
+        const words: LyricWord[] = [];
+        let firstTimeline = -1;
+        const regex = /((?:<|$$)(\d{2}:\d{2}\.\d{2,3})(?:>|$$))([^<\[]*)/g;
+        let match;
+
+        while ((match = regex.exec(line)) !== null) {
+            const [_, tag, timeStr, word] = match;
+            if (word.trim() === '') continue;
+
+            const timeline = this.parseTimeline(timeStr) - offset;
+            if (firstTimeline < 0) firstTimeline = timeline;
+
+            words.push(new LyricWord(word.trim(), timeline, 0));
+        }
+
+        // 设置每个词持续时间(下一个词开始时间-当前词开始时间)
+        for (let i = 0; i < words.length - 1; i++) {
+            words[i].duration = words[i + 1].startTime - words[i].startTime;
+        }
+        if (words.length > 0 && words[words.length - 1].duration === 0) {
+            // 最后一个词持续200ms
+            words[words.length - 1].duration = 200;
+        }
+
+        return { timeline: firstTimeline, words };
+    }
+
+    // 为逐字歌词拼接整行文本
+    private populateTextForWordLyrics(lyricLines: LyricLine[]) {
+        lyricLines.forEach(line => {
+            if (line.words.length > 0) {
+                line.text = line.words.map(word => word.word).join('');
+            }
+        });
+    }
+
     private parseIdTag(line: string): string {
         let spr = line.split(":")
         let spr1 = spr[1]

+ 58 - 29
lib/src/main/ets/view/LyricView2.ets

@@ -3,6 +3,7 @@ import { LyricController } from '../LyricController';
 import { Lyric } from '../bean/Lyric';
 import { ListAdapter } from '../extensions/ListAdapter';
 import { LyricLine } from '../bean/LyricLine';
+import { LyricWord } from '../bean/LyricWord';
 
 /**
  * A component to display the lyric with scroll animation.
@@ -69,6 +70,7 @@ export struct LyricView2 {
     @State isLoadingData: boolean = false
     private loadTimeout = -1
     @State isHightLightCenter: boolean = true
+    @State currentMediaPosition: number = 0
 
     private onDataChangedListener = (lyric: Lyric | null) => {
         clearTimeout(this.loadTimeout)
@@ -79,6 +81,7 @@ export struct LyricView2 {
         }, 300)
     }
     private onPositionChangedListener = (mediaPosition: number) => {
+        this.currentMediaPosition = mediaPosition
         this.onPositionChanged(mediaPosition)
     }
     private onInvalidatedListener = (reLayout: boolean) => {
@@ -181,32 +184,15 @@ export struct LyricView2 {
             LazyForEach(this.listAdapter, (item: LyricLine, index: number) => {
                 ListItem() {
                     Stack() {
-                        Text(item.text)
-                            .fontSize(this.textSize)
-                            .opacity(this.calculateOpacityFactor(index, this.currentIndex))
-                            .blur( this.calculateBlurFactor(index, this.currentIndex))
-                            .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(this.textWeight)
-                            .fontWeight(index == this.currentIndex && this.isHighlightBold ? FontWeight.Bold :
-                            this.textWeight)
-                                // .padding({
-                            .animation({ duration: this.animDuration, curve: Curve.FastOutSlowIn })
-                            .width(this.alignMode == 'center'?'100%':'76%')
-
-                        // if (this.seekUIStyle == 'listItem' && index == this.seekIndex && item.text.length > 0
-                        //     && this.enableSeek && this.isUserTouching) {
-                            // Row() {
-                            //     Image($r('app.media.cclyric_play'))
-                            //         .width(24)
-                            //         .height(24)
-                            //         .fillColor(this.seekUIColor)
-                            //         .objectFit(ImageFit.Fill)
+                        // 逐字歌词渲染
+                        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)
@@ -214,9 +200,7 @@ export struct LyricView2 {
                                 .width(100)
                                 .visibility(this.seekUIStyle == 'listItem' && index == this.seekIndex && item.text.length > 0
                                       && this.enableSeek && this.isUserTouching?Visibility.Visible:Visibility.Hidden)
-                            // }.width('100%')
-                            // .justifyContent(FlexAlign.SpaceBetween)
-                        // }
+
                     }
                     .align(Alignment.End)
 
@@ -281,6 +265,51 @@ export struct LyricView2 {
         })
     }
 
+    @Builder
+    NormalLyricLine(item: LyricLine, index: number) {
+        Text(item.text)
+            .fontSize(this.textSize)
+            .opacity(this.calculateOpacityFactor(index, this.currentIndex))
+            .blur(this.calculateBlurFactor(index, this.currentIndex))
+            .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 })
+            .width(this.alignMode == 'center' ? '100%' : '76%')
+    }
+
+    @Builder
+    WordByWordLyric(item: LyricLine, index: number) {
+        Row({ space: 0 }) {
+            ForEach(item.words, (word: LyricWord, wordIndex: number) => {
+                // 计算逐字歌词是否高亮
+                // const isHighlighted = this.currentMediaPosition >= word.startTime
+
+                Text(word.word)
+                    .fontSize(this.textSize)
+                    .fontColor(this.currentMediaPosition >= word.startTime ? this.textHighlightColor : this.textColor)
+                    .fontWeight(this.currentMediaPosition >= word.startTime && this.isHighlightBold ? FontWeight.Bold : this.textWeight)
+                    .margin(0)
+                    // .animation({ duration: 100, curve: Curve.Linear })
+                    .animation({
+                        // 动画播放速度
+                        tempo: 0.8,
+                        // 动画持续时间,单位是毫秒
+                        duration: 777,
+                        // 动画缓动函数
+                        curve: Curve.Linear
+                    })
+            })
+        }
+        .justifyContent(this.alignMode === 'center' ? FlexAlign.Center : FlexAlign.Start)
+        .width('100%')
+    }
+
     private handleSeekAction() {
         clearTimeout(this.seekUiHideTimeout);
         let targetPosition = this.listAdapter.getData(this.seekIndex).beginTime;