Forráskód Böngészése

新增蓝牙歌词

chendeben 6 hónapja
szülő
commit
465d4d235a

+ 1 - 0
.gitignore

@@ -29,3 +29,4 @@ stash_output.txt
 .codex/
 .cursor/
 .history/
+.spectra/

+ 67 - 0
entry/src/main/ets/common/util/LyricUtil.ets

@@ -15,6 +15,12 @@ interface NavidromeLyricLine {
   value: string;
 }
 
+// 定义歌词行接口(用于蓝牙歌词)
+interface LyricLineItem {
+  time: number;
+  text: string;
+}
+
 // 定义Navidrome语言数据的接口
 interface NavidromeLangData {
   lang?: string;
@@ -235,6 +241,67 @@ class LyricUtil {
       return undefined;
     }
   }
+
+  /**
+   * 根据当前播放时间获取对应的歌词行
+   * @param lyricContent LRC格式歌词内容
+   * @param currentTimeMs 当前播放时间(毫秒)
+   * @returns 当前歌词行文本,如果没有则返回空字符串
+   */
+  public getCurrentLyricLine(lyricContent: string, currentTimeMs: number): string {
+    if (!lyricContent || currentTimeMs < 0) {
+      return '';
+    }
+
+    try {
+      const lines = lyricContent.split('\n');
+      const lyricLines: LyricLineItem[] = [];
+      const ignoredTags = ['ti', 'ar', 'al', 'by', 'offset', 'hash', 'sign', 'qq', 'total', 'Outro', 'tool'];
+
+      for (let i = 0; i < lines.length; i++) {
+        const line = lines[i].trim();
+        if (!line) continue;
+        if (ignoredTags.some(tag => line.startsWith(`[${tag}]`))) continue;
+
+        // 匹配标准LRC时间戳 [mm:ss.xx] 或 [mm:ss.xxx]
+        const match = /^\[(\d{2}):(\d{2})\.(\d{2,3})\](.*)$/.exec(line);
+        if (match) {
+          const minutes = parseInt(match[1], 10);
+          const seconds = parseInt(match[2], 10);
+          const fraction = parseInt(match[3], 10);
+          const milliseconds = match[3].length === 2 ? fraction * 10 : fraction;
+          const timeMs = minutes * 60000 + seconds * 1000 + milliseconds;
+          const text = match[4].trim();
+          
+          if (text) {
+            const item: LyricLineItem = { time: timeMs, text: text };
+            lyricLines.push(item);
+          }
+        }
+      }
+
+      if (lyricLines.length === 0) {
+        return '';
+      }
+
+      // 按时间排序
+      lyricLines.sort((a, b) => a.time - b.time);
+
+      // 找到当前时间对应的歌词行
+      let currentLyric = '';
+      for (let i = lyricLines.length - 1; i >= 0; i--) {
+        if (currentTimeMs >= lyricLines[i].time) {
+          currentLyric = lyricLines[i].text;
+          break;
+        }
+      }
+
+      return currentLyric;
+    } catch (error) {
+      console.warn('getCurrentLyricLine error:', (error as Error).message);
+      return '';
+    }
+  }
 }
 
 export default new LyricUtil();

+ 89 - 1
entry/src/main/ets/controller/AvSessionController.ets

@@ -171,7 +171,6 @@ export class AvSessionController {
     } catch (error) {
       console.warn(' setAVMetadataMusic:', error.message);
     }
-
   }
 
   public setAvSessionPlayState(playbackState: avSession.AVPlaybackState) {
@@ -187,6 +186,95 @@ export class AvSessionController {
     }
   }
 
+  /**
+   * 更新蓝牙歌词显示
+   * @param lyricLine 当前歌词行
+   * @param originalTitle 原始歌曲名
+   * @param originalArtist 原始歌手名
+   * @param mode 显示模式: 0=歌词在artist字段, 1=歌词在title字段
+   * @param mediaImage 媒体封面
+   * @param duration 时长
+   */
+  public async updateBluetoothLyric(
+    lyricLine: string,
+    originalTitle: string,
+    originalArtist: string,
+    mode: number,
+    mediaImage?: PixelMap | string,
+    duration?: number
+  ) {
+    if (!this.avSession) {
+      return;
+    }
+
+    try {
+      let title: string;
+      let artist: string;
+
+      if (mode === 0) {
+        // 模式A:歌词显示在artist字段
+        title = originalTitle;
+        artist = lyricLine || originalArtist;
+      } else {
+        // 模式B:歌词显示在title字段
+        title = lyricLine || originalTitle;
+        artist = originalArtist;
+      }
+
+      const currentMetadata = this.avSessionMetadata;
+      
+      let metadata: avSession.AVMetadata = {
+        assetId: currentMetadata?.assetId || '',
+        title: title,
+        artist: artist,
+        filter: avSession.ProtocolType.TYPE_CAST_PLUS_STREAM | avSession.ProtocolType.TYPE_DLNA,
+        mediaImage: mediaImage || currentMetadata?.mediaImage,
+        duration: duration || currentMetadata?.duration,
+      };
+
+      this.avSession.setAVMetadata(metadata).catch((err: BusinessError) => {
+        hilog.error(0x0000, TAG, `updateBluetoothLyric error: ${err.code}, ${err.message}`);
+      });
+    } catch (error) {
+      console.warn('updateBluetoothLyric error:', (error as Error).message);
+    }
+  }
+
+  /**
+   * 恢复原始元数据(蓝牙断开或关闭歌词功能时调用)
+   */
+  public async restoreOriginalMetadata(
+    originalTitle: string,
+    originalArtist: string,
+    mediaImage?: PixelMap | string,
+    duration?: number
+  ) {
+    if (!this.avSession) {
+      return;
+    }
+
+    try {
+      const currentMetadata = this.avSessionMetadata;
+      
+      let metadata: avSession.AVMetadata = {
+        assetId: currentMetadata?.assetId || '',
+        title: originalTitle,
+        artist: originalArtist,
+        filter: avSession.ProtocolType.TYPE_CAST_PLUS_STREAM | avSession.ProtocolType.TYPE_DLNA,
+        mediaImage: mediaImage || currentMetadata?.mediaImage,
+        duration: duration || currentMetadata?.duration,
+      };
+
+      this.avSession.setAVMetadata(metadata).then(() => {
+        hilog.info(0x0000, TAG, 'restoreOriginalMetadata successfully');
+      }).catch((err: BusinessError) => {
+        hilog.error(0x0000, TAG, `restoreOriginalMetadata error: ${err.code}, ${err.message}`);
+      });
+    } catch (error) {
+      console.warn('restoreOriginalMetadata error:', (error as Error).message);
+    }
+  }
+
   async unregisterSessionListener() {
     if (!this.avSession) {
       return;

+ 79 - 0
entry/src/main/ets/pages/SettingPage.ets

@@ -54,10 +54,14 @@ export struct SettingPage {
   static readonly WEBDAV_UPLOAD_ALLOW_MOBILE: string = 'webdavUploadAllowMobile';
   static readonly WEBDAV_UPLOAD_RETRY_COUNT: string = 'webdavUploadRetryCount';
   static readonly BLUETOOTH_DISCONNECT_PAUSE: string = 'bluetoothDisconnectPause';
+  static readonly BLUETOOTH_LYRIC_ENABLED: string = 'bluetoothLyricEnabled';
+  static readonly BLUETOOTH_LYRIC_MODE: string = 'bluetoothLyricMode';
   @State fastForwardSeconds: string = '10'
   @State isShowBackFast: boolean = true//快进快退按钮
   @State preloadNextSong: boolean = true // 提前缓存下一首
   @State bluetoothDisconnectPause: boolean = false // 蓝牙设备断开暂停
+  @State bluetoothLyricEnabled: boolean = false // 蓝牙歌词显示
+  @State bluetoothLyricMode: number = 0 // 蓝牙歌词显示模式: 0=歌词在artist, 1=歌词在title
   @State isClearingCache: boolean = false
   @Consume isShowDrawer: boolean;
   @Consume offsetX: number;
@@ -311,6 +315,8 @@ export struct SettingPage {
     this.fastForwardSeconds  = PreferencesUtil.getStringSync('fastForwardSeconds', '10')
     this.preloadNextSong = PreferencesUtil.getBooleanSync('preload_next_song', true)
     this.bluetoothDisconnectPause = PreferencesUtil.getBooleanSync(SettingPage.BLUETOOTH_DISCONNECT_PAUSE, false)
+    this.bluetoothLyricEnabled = PreferencesUtil.getBooleanSync(SettingPage.BLUETOOTH_LYRIC_ENABLED, false)
+    this.bluetoothLyricMode = PreferencesUtil.getNumberSync(SettingPage.BLUETOOTH_LYRIC_MODE, 0)
     this.isCopyFileToDownLoad = PreferencesUtil.getBooleanSync(SettingPage.IS_COPYFILE_TO_DOWNLOAD, false)
     this.isNoJumpToHome = PreferencesUtil.getBooleanSync('isNoJumpToHome', true)
     this.webdavUploadDuplicateAction = PreferencesUtil.getStringSync(SettingPage.WEBDAV_UPLOAD_DUPLICATE_ACTION, 'skip')
@@ -1662,6 +1668,79 @@ export struct SettingPage {
             .clickEffect({ level: ClickEffectLevel.HEAVY })
             Line().width('100%').height(0.3).backgroundColor($r('app.color.index_tab_unselected_font_color'))
 
+            // 蓝牙歌词显示
+            Row() {
+              SymbolGlyph($r('sys.symbol.bluetooth'))
+                .fontSize(20)
+                .fontColor([this.themeColor])
+                .alignSelf(ItemAlign.Center)
+                .margin({ left: 15 })
+              Column({ space: 4 }) {
+                Text('蓝牙歌词显示')
+                  .fontSize(15)
+                  .fontColor(Color.Gray)
+                  .fontWeight(480)
+                Text('连接蓝牙时在车机显示歌词')
+                  .fontSize(12)
+                  .fontColor(Color.Gray)
+              }
+              .layoutWeight(1)
+              .alignItems(HorizontalAlign.Start)
+              .margin({ left: 8 })
+              Toggle({ type: ToggleType.Switch, isOn: this.bluetoothLyricEnabled })
+                .selectedColor(this.themeColor)
+                .switchPointColor(Color.White)
+                .margin({ right: 18 })
+                .onChange((checked: boolean) => {
+                  this.bluetoothLyricEnabled = checked;
+                  PreferencesUtil.put(SettingPage.BLUETOOTH_LYRIC_ENABLED, this.bluetoothLyricEnabled)
+                  this.sendChangeEvent()
+                  ToastUtil.showToast(checked ? '已开启蓝牙歌词' : '已关闭蓝牙歌词')
+                })
+                .width(50)
+                .height(30);
+            }
+            .height(70)
+            .clickEffect({ level: ClickEffectLevel.HEAVY })
+
+            // 蓝牙歌词显示模式(仅在开启时显示)
+            if (this.bluetoothLyricEnabled) {
+              Line().width('100%').height(0.3).backgroundColor($r('app.color.index_tab_unselected_font_color'))
+              Row() {
+                SymbolGlyph($r('sys.symbol.text_alignleft'))
+                  .fontSize(20)
+                  .fontColor([this.themeColor])
+                  .alignSelf(ItemAlign.Center)
+                  .margin({ left: 15 })
+                Text('歌词显示位置')
+                  .margin({ left: 8 })
+                  .fontSize(15)
+                  .fontColor(Color.Gray)
+                  .fontWeight(480)
+                  .layoutWeight(1)
+                Select([
+                  { value: '歌手位置(推荐)' },
+                  { value: '歌名位置(兼容)' }])
+                  .font({ size: 14, weight: FontWeight.Medium })
+                  .fontColor(Color.Gray)
+                  .margin({ right: 18 })
+                  .selected(this.bluetoothLyricMode)
+                  .value(this.bluetoothLyricMode === 0 ? '歌手位置(推荐)' : '歌名位置(兼容)')
+                  .onSelect((_index: number, text?: string | undefined) => {
+                    this.bluetoothLyricMode = _index
+                    PreferencesUtil.put(SettingPage.BLUETOOTH_LYRIC_MODE, this.bluetoothLyricMode)
+                    this.sendChangeEvent()
+                  })
+              }
+              .height(55)
+              .clickEffect({ level: ClickEffectLevel.HEAVY })
+              .animation({
+                duration: 300,
+                curve: 'ease-in-out'
+              })
+            }
+            Line().width('100%').height(0.3).backgroundColor($r('app.color.index_tab_unselected_font_color'))
+
             // 均衡器设置入口
             Button({ type: ButtonType.Normal, stateEffect: true }) {
               Row() {

+ 51 - 5
entry/src/main/ets/view/LocalMusic.ets

@@ -46,6 +46,7 @@ import {
   ToastUtil
 } from '@pura/harmony-utils';
 import { imagePathToPixelMap } from '../common/util/CommUtils';
+import LyricUtil from '../common/util/LyricUtil';
 import { unifiedDataChannel, uniformDataStruct, uniformTypeDescriptor } from '@kit.ArkData';
 import { CommonConstants } from '../common/constants/CommonConstants';
 import { BaiduConstants } from '../common/constants/BaiduConstants';
@@ -104,7 +105,6 @@ import { hilog } from '@kit.PerformanceAnalysisKit';
 import { LazyDataSource } from '../common/util/LazyDataSource';
 import  MediaTable, { PageQueryOptions, PageQueryResult, RandomSongQueryOptions }  from '../common/util/MediaTable';
 import FileDeletionWatcher from '../common/util/FileDeletionWatcher';
-import LyricUtil from '../common/util/LyricUtil';
 import NetAxiosUtil from '../common/util/NetAxiosUtil';
 import { ringtone } from '@kit.RingtoneKit';
 import { BreakpointType, BreakpointTypeEnum } from '../common/util/BreakpointSystem';
@@ -344,6 +344,10 @@ export enum SortMode {
 @Component
 export struct LocalMusic {
   @State bluetoothDisconnectPause: boolean = false // 蓝牙设备断开暂停
+  @State isBluetoothConnected: boolean = false // 蓝牙连接状态
+  @State bluetoothLyricEnabled: boolean = false // 蓝牙歌词开关
+  @State bluetoothLyricMode: number = 0 // 蓝牙歌词显示模式
+  private lastBluetoothLyricLine: string = '' // 上一次发送的歌词行
   @State showFindLocation: boolean = true
   @StorageProp("EnablePointLight") enablePointLight: boolean = true
   @State showSimple: boolean = true//开启沉浸模式
@@ -1040,10 +1044,35 @@ export struct LocalMusic {
       console.info(`onecold device descriptor size : ${deviceChanged.deviceDescriptors.length}`);
       console.info(`onecold device change descriptor : ${deviceChanged.deviceDescriptors[0].deviceRole}`);  // 设备角色。
       console.info(`onecold device change descriptor : ${deviceChanged.deviceDescriptors[0].deviceType}`);
-      if(this.bluetoothDisconnectPause&&deviceChanged.type==1&&deviceChanged.deviceDescriptors[0].deviceRole==2&&
-        (deviceChanged.deviceDescriptors[0].deviceType==8)){
-        console.info('heanup onecold 蓝牙音响断开, 暂停播放')
-        this.pause();
+      
+      // 蓝牙设备类型为8
+      const isBluetooth = deviceChanged.deviceDescriptors[0].deviceType === 8;
+      
+      if (isBluetooth) {
+        if (deviceChanged.type === 0) {
+          // 蓝牙连接
+          this.isBluetoothConnected = true;
+          console.info('onecold 蓝牙设备已连接');
+        } else if (deviceChanged.type === 1) {
+          // 蓝牙断开
+          this.isBluetoothConnected = false;
+          this.lastBluetoothLyricLine = '';
+          console.info('onecold 蓝牙设备已断开');
+          
+          // 恢复原始元数据
+          if (this.currentSong) {
+            this.avSessionController.restoreOriginalMetadata(
+              this.currentSong.name || '',
+              this.currentSong.artist || ''
+            );
+          }
+          
+          // 蓝牙断开暂停
+          if (this.bluetoothDisconnectPause && deviceChanged.deviceDescriptors[0].deviceRole === 2) {
+            console.info('heanup onecold 蓝牙音响断开, 暂停播放');
+            this.pause();
+          }
+        }
       }
     });
 
@@ -1208,6 +1237,8 @@ export struct LocalMusic {
   }
   initSetting() {
     this.bluetoothDisconnectPause = PreferencesUtil.getBooleanSync(SettingPage.BLUETOOTH_DISCONNECT_PAUSE, false)
+    this.bluetoothLyricEnabled = PreferencesUtil.getBooleanSync(SettingPage.BLUETOOTH_LYRIC_ENABLED, false)
+    this.bluetoothLyricMode = PreferencesUtil.getNumberSync(SettingPage.BLUETOOTH_LYRIC_MODE, 0)
     this.isShowSpectrum  = PreferencesUtil.getBooleanSync('isShowSpectrum', false)
     this.spectrumModeIndex = PreferencesUtil.getNumberSync('spectrumModeIndex', 0)
     this.enablePointLight = PreferencesUtil.getBooleanSync('enablePointLight', true)
@@ -14043,6 +14074,21 @@ export struct LocalMusic {
     if(this.isShowSingleLineLyric){
       this.lyricControllerSingle.updatePosition(position + this.timeOffset * 1000);
     }
+    
+    // 蓝牙歌词更新逻辑
+    if (this.bluetoothLyricEnabled && this.isBluetoothConnected && this.lyricContent && this.currentSong) {
+      const currentLyricLine = LyricUtil.getCurrentLyricLine(this.lyricContent, position + this.timeOffset * 1000);
+      // 仅在歌词行变化时更新,避免频繁调用
+      if (currentLyricLine !== this.lastBluetoothLyricLine) {
+        this.lastBluetoothLyricLine = currentLyricLine;
+        this.avSessionController.updateBluetoothLyric(
+          currentLyricLine,
+          this.currentSong.name || '',
+          this.currentSong.artist || '',
+          this.bluetoothLyricMode
+        );
+      }
+    }
 
     this.currentTime = this.stringForTime(position);
     this.isCurrentTime = false