Explorar el Código

fix(player): restore unified playback controller

onecold hace 4 meses
padre
commit
55aacac195

+ 273 - 0
entry/src/main/ets/controller/MusicPlaybackController.ets

@@ -0,0 +1,273 @@
+export interface MusicPlaybackControllerActions {
+  playOrPause: () => void | Promise<void>
+  playNext: () => void | Promise<void>
+  playPrevious: () => void | Promise<void>
+  setLoopMode: () => void | Promise<void>
+  seekTo: (value: string, source?: string) => void | Promise<void>
+}
+
+export interface LoopModeResolution {
+  playType: number
+  toastText: string
+}
+
+export interface SessionLoopModeResolution {
+  playType: number
+  toastText: string
+  reportedLoopMode: number
+}
+
+export interface SeekResolutionOptions {
+  requestedValue: string
+  activeDuration: number
+  cueTrackStartOffset?: number
+  cueTrackEndOffset?: number
+  isRemoteSong?: boolean
+}
+
+export interface SeekResolution {
+  canSeek: boolean
+  seekPos: number
+}
+
+export interface QueueIndexResolution {
+  nextIndex: number
+  reachedBoundary: boolean
+}
+
+export interface QueueSongIndexResolution {
+  index: number
+  shouldAppend: boolean
+}
+
+export interface CompletionActionResolution {
+  action: 'play_next' | 'replay_current' | 'stop_current' | 'random_next'
+  showBoundaryToast: boolean
+}
+
+export type NextPlaybackAction = 'random_play' | 'stop_at_end' | 'advance_queue'
+export type PreviousPlaybackAction = 'history_previous' | 'queue_previous'
+export type TogglePlaybackAction = 'pause' | 'resume_cast' | 'resume_local'
+export type ItemPlaybackToggleAction = 'toggle_current' | 'play_target'
+
+export class MusicPlaybackController {
+  private static instance?: MusicPlaybackController
+  private actions?: MusicPlaybackControllerActions
+
+  public static getInstance(): MusicPlaybackController {
+    if (!MusicPlaybackController.instance) {
+      MusicPlaybackController.instance = new MusicPlaybackController()
+    }
+    return MusicPlaybackController.instance
+  }
+
+  public static resolveNextLoopMode(playType: number): LoopModeResolution {
+    const nextType = playType >= 4 || playType < 0 ? 0 : playType + 1
+    if (nextType === 1) {
+      return { playType: 1, toastText: '单曲循环' }
+    }
+    if (nextType === 2) {
+      return { playType: 2, toastText: '单曲播完' }
+    }
+    if (nextType === 3) {
+      return { playType: 3, toastText: '随机播放' }
+    }
+    if (nextType === 4) {
+      return { playType: 4, toastText: '连续播放不循环' }
+    }
+    return { playType: 0, toastText: '连续循环播放' }
+  }
+
+  public static resolveSessionLoopMode(mode: number): SessionLoopModeResolution {
+    let reportedLoopMode = mode + 1
+    if (reportedLoopMode >= 4) {
+      reportedLoopMode = 0
+    }
+
+    if (reportedLoopMode === 1) {
+      return { playType: 1, toastText: '单曲循环', reportedLoopMode }
+    }
+    if (reportedLoopMode === 0) {
+      return { playType: 2, toastText: '单曲播完', reportedLoopMode }
+    }
+    if (reportedLoopMode === 3) {
+      return { playType: 3, toastText: '随机播放', reportedLoopMode }
+    }
+    if (reportedLoopMode === 2) {
+      return { playType: 0, toastText: '连续循环播放', reportedLoopMode }
+    }
+    return { playType: 4, toastText: '连续播放不循环', reportedLoopMode }
+  }
+
+  public static resolveAvSessionLoopMode(playType: number): number {
+    if (playType === 1) {
+      return 1
+    }
+    if (playType === 2) {
+      return 0
+    }
+    if (playType === 3) {
+      return 3
+    }
+    if (playType === 4) {
+      return 4
+    }
+    return 2
+  }
+
+  public static resolveSeekPosition(options: SeekResolutionOptions): SeekResolution {
+    let seekPos = Number.parseInt(options.requestedValue)
+    if (Number.isNaN(seekPos)) {
+      return { canSeek: false, seekPos: 0 }
+    }
+
+    const cueTrackStartOffset = options.cueTrackStartOffset ?? 0
+    const cueTrackEndOffset = options.cueTrackEndOffset ?? 0
+    const activeDuration = options.activeDuration
+    const isRemoteSong = options.isRemoteSong === true
+
+    if (activeDuration > 0) {
+      seekPos = Math.max(0, Math.min(seekPos, activeDuration - 200))
+    } else if (seekPos < 0) {
+      seekPos = 0
+    }
+
+    if (cueTrackStartOffset > 0 || cueTrackEndOffset > 0) {
+      seekPos += cueTrackStartOffset
+      if (cueTrackEndOffset > cueTrackStartOffset) {
+        seekPos = Math.min(seekPos, cueTrackEndOffset - 200)
+      }
+    }
+
+    if (isRemoteSong && activeDuration <= 0 && seekPos > 0) {
+      return { canSeek: false, seekPos }
+    }
+
+    return { canSeek: true, seekPos }
+  }
+
+  public static resolveSeekValueFromPercent(percent: number, activeDuration: number): number {
+    if (activeDuration <= 0) {
+      return 0
+    }
+    const clampedPercent = Math.max(0, Math.min(percent, 100))
+    return clampedPercent * (activeDuration / 100)
+  }
+
+  public static resolveNextQueueIndex(currentIndex: number, queueLength: number): QueueIndexResolution {
+    if (queueLength <= 0) {
+      return { nextIndex: -1, reachedBoundary: true }
+    }
+    if (currentIndex >= queueLength - 1) {
+      return { nextIndex: 0, reachedBoundary: true }
+    }
+    return { nextIndex: currentIndex + 1, reachedBoundary: false }
+  }
+
+  public static resolvePreviousQueueIndex(currentIndex: number, queueLength: number): QueueIndexResolution {
+    if (queueLength <= 0) {
+      return { nextIndex: -1, reachedBoundary: true }
+    }
+    if (currentIndex <= 0) {
+      return { nextIndex: queueLength - 1, reachedBoundary: true }
+    }
+    return { nextIndex: currentIndex - 1, reachedBoundary: false }
+  }
+
+  public static resolveQueueSongIndex(queueFilePaths: string[], targetFilePath: string): QueueSongIndexResolution {
+    const existingIndex = queueFilePaths.findIndex((filePath: string): boolean => filePath === targetFilePath)
+    if (existingIndex >= 0) {
+      return { index: existingIndex, shouldAppend: false }
+    }
+    return { index: queueFilePaths.length, shouldAppend: true }
+  }
+
+  public static shouldStopAtQueueEnd(playType: number, currentIndex: number, queueLength: number): boolean {
+    if (queueLength <= 0) {
+      return false
+    }
+    return playType === 4 && currentIndex >= queueLength - 1
+  }
+
+  public static resolveCompletionAction(playType: number, currentIndex: number,
+    queueLength: number): CompletionActionResolution {
+    if (playType === 1) {
+      return { action: 'replay_current', showBoundaryToast: false }
+    }
+    if (playType === 2) {
+      return { action: 'stop_current', showBoundaryToast: false }
+    }
+    if (playType === 3) {
+      return { action: 'random_next', showBoundaryToast: false }
+    }
+    if (MusicPlaybackController.shouldStopAtQueueEnd(playType, currentIndex, queueLength)) {
+      return { action: 'stop_current', showBoundaryToast: true }
+    }
+    return { action: 'play_next', showBoundaryToast: false }
+  }
+
+  public static resolveNextPlaybackAction(playType: number, currentIndex: number, queueLength: number): NextPlaybackAction {
+    if (playType === 3) {
+      return 'random_play'
+    }
+    if (MusicPlaybackController.shouldStopAtQueueEnd(playType, currentIndex, queueLength)) {
+      return 'stop_at_end'
+    }
+    return 'advance_queue'
+  }
+
+  public static resolvePreviousPlaybackAction(playType: number): PreviousPlaybackAction {
+    if (playType === 3) {
+      return 'history_previous'
+    }
+    return 'queue_previous'
+  }
+
+  public static resolveTogglePlaybackAction(isPlaying: boolean, isCastPlaying: boolean): TogglePlaybackAction {
+    if (isPlaying) {
+      return 'pause'
+    }
+    if (isCastPlaying) {
+      return 'resume_cast'
+    }
+    return 'resume_local'
+  }
+
+  public static resolveItemPlaybackToggleAction(currentVideoUrl: string, targetFilePath: string,
+    isPlaying: boolean, allowAnyCurrentPlaying: boolean = false): ItemPlaybackToggleAction {
+    if (currentVideoUrl === targetFilePath || (allowAnyCurrentPlaying && isPlaying)) {
+      return 'toggle_current'
+    }
+    return 'play_target'
+  }
+
+  public setActions(actions: MusicPlaybackControllerActions): void {
+    this.actions = actions
+  }
+
+  public clearActions(actions?: MusicPlaybackControllerActions): void {
+    if (!actions || this.actions === actions) {
+      this.actions = undefined
+    }
+  }
+
+  public playOrPause(): void {
+    this.actions?.playOrPause()
+  }
+
+  public async playNext(): Promise<void> {
+    await this.actions?.playNext?.()
+  }
+
+  public async playPrevious(): Promise<void> {
+    await this.actions?.playPrevious?.()
+  }
+
+  public async setLoopMode(): Promise<void> {
+    await this.actions?.setLoopMode?.()
+  }
+
+  public async seekTo(value: string, source?: string): Promise<void> {
+    await this.actions?.seekTo?.(value, source)
+  }
+}

+ 59 - 59
entry/src/main/ets/pages/NewIndex.ets

@@ -589,8 +589,8 @@ struct NewIndex {
     try {
       const shouldShow = await UpdateLogManager.checkAndShowUpdateLog();
       if (shouldShow) {
-          this.isShowUpdateDialog = !this.isShowUpdateDialog
-          UpdateLogManager.markCurrentVersionShown()
+        this.isShowUpdateDialog = !this.isShowUpdateDialog
+        UpdateLogManager.markCurrentVersionShown()
       }
       // this.isShowUpdateDialog = true;//调试期间显示更新日志,测试完成后请删除
     } catch (error) {
@@ -2395,15 +2395,15 @@ struct NewIndex {
             .effectStrategy(SymbolEffectStrategy.HIERARCHICAL)
             .alignSelf(ItemAlign.Center)
             .margin({ left: 25 })
-          
+
           Text('创建歌单')
             .margin({ left: 10, right: 20 })
             .fontSize(14)
             .fontColor($r('app.color.index_tab_font_color'))
             .fontWeight(480)
-          
+
           Blank()
-          
+
           Image($r('app.media.arrow_right'))
             .width(22)
             .height(22)
@@ -2419,7 +2419,7 @@ struct NewIndex {
         this.showCreatePlaylistDialog()
       })
     }
-    
+
     // 歌单列表
     ForEach(this.playlistList, (playlist: Playlist,index:number) => {
       ListItem() {
@@ -2432,7 +2432,7 @@ struct NewIndex {
               .borderRadius(4)
               .fillColor(this.themeColor)
               .clip(true)
-            
+
             Column() {
               Text(playlist.name)
                 .margin({ left: 10, right: 20 })
@@ -2441,7 +2441,7 @@ struct NewIndex {
                 .fontWeight(480)
                 .maxLines(1)
                 .textOverflow({ overflow: TextOverflow.Ellipsis })
-              
+
               Text(`${playlist.songCount}首`)
                 .margin({ left: 10, right: 20 })
                 .fontSize(12)
@@ -2449,9 +2449,9 @@ struct NewIndex {
                 .opacity(0.7)
             }
             .alignItems(HorizontalAlign.Start)
-            
+
             Blank()
-            
+
             Image($r('app.media.arrow_right'))
               .width(22)
               .height(22)
@@ -2556,10 +2556,10 @@ struct NewIndex {
             // 账户封面或默认图标
             Stack() {
               Image(account.coverPath?account.coverPath:getCloudDiskIcon(account.webType))
-                  .width(25)
-                  .height(25)
-                  .borderRadius(4)
-                  .objectFit(ImageFit.Cover)
+                .width(25)
+                .height(25)
+                .borderRadius(4)
+                .objectFit(ImageFit.Cover)
             }
             .margin({ left: 20 })
 
@@ -2747,7 +2747,7 @@ struct NewIndex {
         content: '编辑'
       })
         .onClick(async() => {
-         this.showRemoteDriveAccountDialog(true,account)
+          this.showRemoteDriveAccountDialog(true,account)
         })
 
       MenuItem({
@@ -3109,36 +3109,36 @@ struct NewIndex {
 
   @Builder
   webDavAccountBuilder(isEditMode?: boolean,account?: WebDavAccount,driveType?:number) {
-     RemoteDriveAccountDialog({
-       isEditMode: isEditMode,
-       account: account,
-       initialDriveType: driveType,
-       onCancel: () => {
-         // 取消添加
-         this.getUIContext().getPromptAction().closeCustomDialog(this.addDavDialogId)
-       },
-       onConfirm: (account: WebDavAccount) => {
-         if(this.webDavAccounts.length>=1&&!Utility.isNoble()&&!isEditMode){
-           ToastUtil.showToast('普通用户只能添加一个网盘账户,请开通会员')
-           return
-         }
-         this.getUIContext().getPromptAction().closeCustomDialog(this.addDavDialogId)
-
-         if (isEditMode && account?.id) {
-           // 编辑模式:更新现有账户
-           this.webdavManager.editAccount(account).then(() => {
-             ToastUtil.showToast('修改成功')
-             // 重新加载网盘账户列表,观察着模式去更新了,所以这里不需要更新,先注释掉
-             // this.loadWebDavAccounts()
-             LogUtil.info('heanup NewIndex', '网盘账户修改成功:', account.name)
-             this.logAccount('info', `网盘账户修改成功: ${account.name}`)
-           }).catch((error: Error) => {
-             LogUtil.error('heanup NewIndex', `修改网盘账户失败: ${error.message}`)
-             ToastUtil.showToast('修改失败')
-             this.logAccount('error', `修改网盘账户失败: ${error.message}`)
-           })
-         } else {
-           // 添加模式:创建新账户
+    RemoteDriveAccountDialog({
+      isEditMode: isEditMode,
+      account: account,
+      initialDriveType: driveType,
+      onCancel: () => {
+        // 取消添加
+        this.getUIContext().getPromptAction().closeCustomDialog(this.addDavDialogId)
+      },
+      onConfirm: (account: WebDavAccount) => {
+        if(this.webDavAccounts.length>=1&&!Utility.isNoble()&&!isEditMode){
+          ToastUtil.showToast('普通用户只能添加一个网盘账户,请开通会员')
+          return
+        }
+        this.getUIContext().getPromptAction().closeCustomDialog(this.addDavDialogId)
+
+        if (isEditMode && account?.id) {
+          // 编辑模式:更新现有账户
+          this.webdavManager.editAccount(account).then(() => {
+            ToastUtil.showToast('修改成功')
+            // 重新加载网盘账户列表,观察着模式去更新了,所以这里不需要更新,先注释掉
+            // this.loadWebDavAccounts()
+            LogUtil.info('heanup NewIndex', '网盘账户修改成功:', account.name)
+            this.logAccount('info', `网盘账户修改成功: ${account.name}`)
+          }).catch((error: Error) => {
+            LogUtil.error('heanup NewIndex', `修改网盘账户失败: ${error.message}`)
+            ToastUtil.showToast('修改失败')
+            this.logAccount('error', `修改网盘账户失败: ${error.message}`)
+          })
+        } else {
+          // 添加模式:创建新账户
           this.webdavManager.insertAccount(
             account.name,
             account.host,
@@ -3164,20 +3164,20 @@ struct NewIndex {
             account.baiduRefreshToken,
             account.baiduTokenExpiresAt
           ).then(() => {
-             ToastUtil.showToast('添加成功')
-             // 重新加载网盘账户列表 观察着模式去更新了,所以这里不需要更新,先注释掉
-             // this.loadWebDavAccounts()
-             LogUtil.info('heanup NewIndex', '网盘账户添加成功:', account.name)
-             this.logAccount('info', `网盘账户添加成功: ${account.name}`)
-           }).catch((error: Error) => {
-             LogUtil.error('heanup NewIndex', `添加网盘账户失败: ${error.message}`)
-             ToastUtil.showToast('添加失败')
-             this.logAccount('error', `添加网盘账户失败: ${error.message}`)
-           })
-         }
-       }
-
-     });
+            ToastUtil.showToast('添加成功')
+            // 重新加载网盘账户列表 观察着模式去更新了,所以这里不需要更新,先注释掉
+            // this.loadWebDavAccounts()
+            LogUtil.info('heanup NewIndex', '网盘账户添加成功:', account.name)
+            this.logAccount('info', `网盘账户添加成功: ${account.name}`)
+          }).catch((error: Error) => {
+            LogUtil.error('heanup NewIndex', `添加网盘账户失败: ${error.message}`)
+            ToastUtil.showToast('添加失败')
+            this.logAccount('error', `添加网盘账户失败: ${error.message}`)
+          })
+        }
+      }
+
+    });
 
   }
 

+ 3 - 3
entry/src/main/ets/view/FindView.ets

@@ -2905,7 +2905,7 @@ export struct FindView {
         textColor: $r('app.color.text_color'),
         buttonColor: this.getCardBackgroundColor()
       })
-        .opacity(0.8)
+        .opacity(0.7)
         .layoutWeight(1)
         .onClick(() => {
           void this.handleActionButtonTap('top')
@@ -2918,7 +2918,7 @@ export struct FindView {
         textColor: $r('app.color.text_color'),
         buttonColor: this.getCardBackgroundColor()
       })
-        .opacity(0.8)
+        .opacity(0.7)
         .layoutWeight(1)
         .onClick(() => {
           void this.handleActionButtonTap('local-random')
@@ -2931,7 +2931,7 @@ export struct FindView {
         textColor: $r('app.color.text_color'),
         buttonColor: this.getCardBackgroundColor()
       })
-        .opacity(0.8)
+        .opacity(0.7)
         .layoutWeight(1)
         .onClick(() => {
           void this.handleActionButtonTap('cloud-random')

La diferencia del archivo ha sido suprimido porque es demasiado grande
+ 348 - 264
entry/src/main/ets/view/LocalMusic.ets


+ 367 - 0
entry/src/main/ets/view/player/PlayerControls.ets

@@ -0,0 +1,367 @@
+import { PlayStatus } from '../../common/PlayStatus'
+import { PointLightButton } from '../PointLight/PointLightButton'
+import { PointLightDefaultButton } from '../PointLight/PointLightDeFaultButton'
+
+@Component
+export struct PlayerControls {
+  // 播控支持紧凑模式,给横屏封面页复用同一套独立组件。
+  @Prop compactMode: boolean = false
+  // 整体透明度和底部偏移由外部页面控制,避免组件再持有布局状态。
+  @Prop controlOpacity: number = 1
+  @Prop bottomOffset: number = 70
+  // 是否展示底部功能操作区,HiCar 等场景沿用原有隐藏逻辑。
+  @Prop showBottomRow: boolean = true
+  // 播控区需要的播放态、进度态都作为纯数据输入。
+  @Prop isLandscape: boolean = false
+  @Prop progressValue: number = 0
+  @Prop progressMaxValue: number = 100
+  @Prop slideEnable: boolean = false
+  @Prop currentTime: string = '00:00'
+  @Prop totalTime: string = '00:00'
+  @Prop controlPlayStatus: number = PlayStatus.INIT
+  @Prop isCircleBtn: boolean = false
+  @Prop isPlayerLoading: boolean = false
+  @Prop playType: number = 0
+  @Prop fastForwardSeconds: string = '10'
+  @Prop isShowBackFast: boolean = false
+  // 编辑、收藏等按钮是否展示,由外部决定当前歌曲是否可操作。
+  @Prop showEditButton: boolean = false
+  @Prop isFavorite: boolean = false
+
+  // 频谱区域也改成可注入 builder,后续可以继续抽到独立组件而不动页面骨架。
+  @BuilderParam spectrumBuilder: () => void
+
+  // 播控内部只触发动作,不再持有具体业务逻辑。
+  onPlayPrevious: () => void = (): void => {}
+  onPlayOrPause: () => void = (): void => {}
+  onPlayNext: () => void = (): void => {}
+  onToggleMore: () => void = (): void => {}
+  onSetLoopMode: () => void = (): void => {}
+  onSeek: (value: number) => void = (_value: number): void => {}
+  onBackFast: () => void = (): void => {}
+  onForwardFast: () => void = (): void => {}
+  onRotate: () => void = (): void => {}
+  onToggleEdit: () => void = (): void => {}
+  onToggleEqualizer: () => void = (): void => {}
+  onToggleFavorite: () => void = (): void => {}
+  onOpenPlaylist: () => void = (): void => {}
+
+  private resolvePlayModeIcon(): Resource {
+    // 播放模式图标统一在独立组件里收口,减少 LocalMusic 内部判断分支。
+    if (this.playType === 0) {
+      return $r('app.media.loop')
+    }
+    if (this.playType === 1) {
+      return $r('app.media.single')
+    }
+    if (this.playType === 2) {
+      return $r('app.media.normal_play')
+    }
+    if (this.playType === 3) {
+      return $r('app.media.random')
+    }
+    return $r('app.media.noloop')
+  }
+
+  private resolveFastForwardText(): string {
+    // 统一把快进秒数当成字符串展示,避免上层状态类型变化时影响 UI 组件。
+    return this.fastForwardSeconds
+  }
+
+  @Builder
+  private MoreButtonContent() {
+    // 更多按钮沿用原有资源,保持现有视觉风格不变。
+    Image($r('app.media.menu'))
+      .width(24)
+  }
+
+  @Builder
+  private PreviousButtonContent() {
+    Image($r('app.media.ic_previous'))
+      .width(32)
+      .aspectRatio(1)
+  }
+
+  @Builder
+  private NextButtonContent() {
+    Image($r('app.media.ic_next'))
+      .width(32)
+      .aspectRatio(1)
+  }
+
+  @Builder
+  private PlayOrPauseButtonContent() {
+    Stack() {
+      // 播放暂停按钮单独保留加载圈,兼容远程歌曲缓冲场景。
+      Image(this.controlPlayStatus === PlayStatus.PLAY
+        ? (this.isCircleBtn ? $r('app.media.hm_pause') : $r('app.media.ic_public_play'))
+        : (this.isCircleBtn ? $r('app.media.hm_play2') : $r('app.media.ic_public_pause')))
+        .width(this.isLandscape ? 38 : 40)
+        .fillColor(Color.White)
+        .aspectRatio(1)
+
+      if (this.isPlayerLoading) {
+        Progress({ value: 0, total: 100, type: ProgressType.Ring })
+          .width(this.isLandscape ? 40 : 43)
+          .height(this.isLandscape ? 40 : 43)
+          .color(Color.White)
+          .style({ strokeWidth: 5, status: ProgressStatus.LOADING })
+      }
+    }
+    .width(this.isLandscape ? 38 : 41)
+    .height(this.isLandscape ? 38 : 41)
+  }
+
+  @Builder
+  private CenterControls() {
+    Row() {
+      PointLightButton({
+        builder: () => {
+          this.MoreButtonContent()
+        },
+        isPx: false,
+        builderHeight: 26,
+        builderWidth: 26,
+      })
+        .onClick(() => {
+          this.onToggleMore()
+        })
+
+      PointLightButton({
+        builder: () => {
+          this.PreviousButtonContent()
+        },
+        isPx: false,
+        builderHeight: 26,
+        builderWidth: 26,
+      })
+        .onClick(() => {
+          this.onPlayPrevious()
+        })
+
+      PointLightButton({
+        builder: () => {
+          this.PlayOrPauseButtonContent()
+        },
+        isPx: false,
+        builderHeight: this.isLandscape ? 38 : 41,
+        builderWidth: this.isLandscape ? 38 : 41,
+      })
+        .onClick(() => {
+          this.onPlayOrPause()
+        })
+
+      PointLightButton({
+        builder: () => {
+          this.NextButtonContent()
+        },
+        isPx: false,
+        builderHeight: 26,
+        builderWidth: 26,
+      })
+        .onClick(() => {
+          this.onPlayNext()
+        })
+
+      PointLightButton({
+        builder: () => {
+          Image(this.resolvePlayModeIcon())
+            .width(24)
+            .aspectRatio(1)
+        },
+        isPx: false,
+        builderHeight: 26,
+        builderWidth: 26,
+      })
+        .onClick(() => {
+          this.onSetLoopMode()
+        })
+    }
+    .width('95%')
+    .justifyContent(FlexAlign.SpaceEvenly)
+  }
+
+  @Builder
+  private ProgressControls() {
+    Column() {
+      Row() {
+        Stack() {
+          SymbolGlyph($r('sys.symbol.arrow_counterclockwise'))
+            .fontColor([Color.White])
+            .fontSize(21)
+            .effectStrategy(1)
+          Text(this.resolveFastForwardText())
+            .fontColor(Color.White)
+            .fontSize(10)
+            .fontWeight(FontWeight.Bold)
+        }
+        .padding({ left: 12, bottom: 6 })
+        .visibility(this.isShowBackFast ? Visibility.Visible : Visibility.None)
+        .clickEffect({ level: ClickEffectLevel.MIDDLE, scale: 0.6 })
+        .onClick(() => {
+          this.onBackFast()
+        })
+
+        Slider({
+          value: this.progressValue,
+          min: 0,
+          max: this.progressMaxValue,
+          step: 1,
+          style: SliderStyle.InSet
+        })
+          .height(20)
+          .blockColor('rgba(255,255,255,1)')
+          .trackColor('rgba(255,255,255,0.3)')
+          .selectedColor(Color.White)
+          .trackThickness(4)
+          .showSteps(false)
+          .showTips(true)
+          .layoutWeight(1)
+          .enabled(this.slideEnable)
+          .onChange((value: number, mode: SliderChangeMode) => {
+            if (mode !== 2) {
+              return
+            }
+            this.onSeek(value)
+          })
+
+        Stack() {
+          SymbolGlyph($r('sys.symbol.arrow_clockwise'))
+            .fontColor([Color.White])
+            .fontSize(21)
+            .effectStrategy(1)
+          Text(this.resolveFastForwardText())
+            .fontColor(Color.White)
+            .fontSize(10)
+            .fontWeight(FontWeight.Bold)
+        }
+        .padding({ right: 10, bottom: 6 })
+        .visibility(this.isShowBackFast ? Visibility.Visible : Visibility.None)
+        .clickEffect({ level: ClickEffectLevel.MIDDLE, scale: 0.6 })
+        .onClick(() => {
+          this.onForwardFast()
+        })
+      }
+      .justifyContent(FlexAlign.Center)
+      .padding({ left: 20, right: 20 })
+      .width(this.isLandscape ? '88%' : '95%')
+
+      Row() {
+        Text(this.currentTime)
+          .fontSize(10)
+          .fontColor(Color.White)
+          .margin({ left: 25 })
+        Blank()
+        Text(this.totalTime)
+          .fontSize(10)
+          .fontColor(Color.White)
+          .margin({ right: 25 })
+      }
+      .width(this.isLandscape ? '85%' : '90%')
+      .height(10)
+    }
+    .width('100%')
+    .alignItems(HorizontalAlign.Center)
+  }
+
+  @Builder
+  private BottomActionRow() {
+    Row() {
+      PointLightDefaultButton({
+        isSysBol: true,
+        pointColor: Color.White,
+        imageResource: $r('sys.symbol.rectangle_portrait_rotate'),
+        isPx: false,
+        builderHeight: 22,
+        builderWidth: 22,
+      })
+        .onClick(() => {
+          this.onRotate()
+        })
+
+      if (this.showEditButton) {
+        // 编辑标签只在当前有歌曲时展示,避免空态按钮触发无效动作。
+        PointLightDefaultButton({
+          isSysBol: true,
+          pointColor: Color.White,
+          imageResource: $r('sys.symbol.rename'),
+          isPx: false,
+          builderHeight: 22,
+          builderWidth: 22,
+        })
+          .onClick(() => {
+            this.onToggleEdit()
+          })
+      }
+
+      PointLightDefaultButton({
+        isSysBol: true,
+        pointColor: Color.White,
+        imageResource: $r('sys.symbol.slider_vertical_3'),
+        isPx: false,
+        builderHeight: 22,
+        builderWidth: 22,
+      })
+        .onClick(() => {
+          this.onToggleEqualizer()
+        })
+
+      PointLightDefaultButton({
+        isSysBol: true,
+        pointColor: Color.White,
+        imageResource: this.isFavorite ? $r('sys.symbol.heart_fill') : $r('sys.symbol.heart'),
+        isPx: false,
+        builderHeight: 22,
+        builderWidth: 22,
+      })
+        .onClick(() => {
+          this.onToggleFavorite()
+        })
+
+      PointLightDefaultButton({
+        isSysBol: true,
+        pointColor: Color.White,
+        imageResource: $r('sys.symbol.music_note_list'),
+        isPx: false,
+        builderHeight: 26,
+        builderWidth: 26,
+      })
+        .onClick(() => {
+          this.onOpenPlaylist()
+        })
+    }
+    .width(this.isLandscape ? '88%' : '95%')
+    .justifyContent(FlexAlign.SpaceAround)
+    .visibility(this.showBottomRow ? Visibility.Visible : Visibility.None)
+    .height(30)
+    .animation({
+      duration: 666,
+      curve: 'ease-in-out'
+    })
+  }
+
+  @Builder
+  private FullControls() {
+    Column() {
+      // 频谱显示是否启用由外部 builder 自己判断,组件本身不再持有该业务状态。
+      this.spectrumBuilder()
+      this.ProgressControls()
+      this.CenterControls()
+      this.BottomActionRow()
+    }
+    .justifyContent(FlexAlign.Center)
+    .alignItems(HorizontalAlign.Center)
+    .opacity(this.controlOpacity)
+    .position({ bottom: this.bottomOffset })
+  }
+
+  build() {
+    if (this.compactMode) {
+      // 横屏封面区域只复用紧凑播控,不再在 LocalMusic 里额外维护一套 UI。
+      this.CenterControls()
+    } else {
+      // 竖屏和完整播放页统一走独立播控组件。
+      this.FullControls()
+    }
+  }
+}

+ 67 - 0
entry/src/main/ets/view/player/PlayerPage.ets

@@ -0,0 +1,67 @@
+import { deviceInfo } from '@kit.BasicServicesKit'
+import { hdsEffect } from '@kit.UIDesignKit'
+
+@Component
+export struct PlayerPage {
+  // 播放页拖拽关闭时的位移状态,由 LocalMusic 统一维护,页面只负责消费。
+  @Prop translateY: number = 0
+  // 是否启用播放页背景流光效果,避免页面组件反向依赖 LocalMusic 的实现细节。
+  @Prop enableBackgroundEffect: boolean = false
+  // 是否显示背景流光效果,和启用开关拆开,便于后续继续裁剪状态依赖。
+  @Prop showBackgroundEffect: boolean = false
+  // 背景流光控制器仍然由 LocalMusic 持有,独立页面只做承载。
+  @Prop bgController: hdsEffect.ShaderEffectController | undefined = undefined
+
+  // 页面拖拽和销毁清理动作通过回调下沉给 LocalMusic,保证业务状态仍在一个地方维护。
+  onPanUpdate: (event?: GestureEvent) => void = (_event?: GestureEvent): void => {}
+  onPanEnd: (event?: GestureEvent) => void = (_event?: GestureEvent): void => {}
+  onDisappearCleanup: () => void = (): void => {}
+
+  // 实际播放器内容由外部 builder 传入,这样播放页文件独立后还能平滑迁移剩余内容。
+  @BuilderParam contentBuilder: () => void
+
+  build() {
+    Column() {
+      // 播放页只负责壳层结构,内部内容逐步从 LocalMusic 迁移。
+      this.contentBuilder()
+    }
+    .transition(TransitionEffect.asymmetric(
+      TransitionEffect.opacity(1),
+      TransitionEffect.OPACITY
+    ))
+    .visualEffect(this.enableBackgroundEffect && this.showBackgroundEffect && deviceInfo.sdkApiVersion >= 20 &&
+      this.bgController
+      ? new hdsEffect.HdsEffectBuilder()
+        .shaderEffect({
+          effectType: hdsEffect.EffectType.UV_BACKGROUND_FLOW_LIGHT,
+          animation: {
+            duration: 10000,
+            iterations: -1,
+            autoPlay: true,
+            onFinish: () => {
+              console.info('Succeeded in finishing')
+            }
+          },
+          controller: this.bgController,
+        })
+        .buildEffect()
+      : undefined)
+    .height('100%')
+    .width('100%')
+    .onDisAppear(() => {
+      // 页面消失时统一回调外部清理拖拽中间态,避免残留到下一次打开。
+      this.onDisappearCleanup()
+    })
+    .translate({ y: this.translateY })
+    .gesture(
+      // 播放页的关闭/切歌手势放在独立页面里承载,但具体业务判断仍交给 LocalMusic。
+      PanGesture()
+        .onActionUpdate((event?: GestureEvent) => {
+          this.onPanUpdate(event)
+        })
+        .onActionEnd((event?: GestureEvent) => {
+          this.onPanEnd(event)
+        })
+    )
+  }
+}

+ 115 - 0
entry/src/main/ets/view/player/SongDetailSheet.ets

@@ -0,0 +1,115 @@
+import { StrUtil } from '@pura/harmony-utils'
+import { CommonConstants } from '../../common/constants/CommonConstants'
+import { Utility } from '../../common/util/Utility'
+import { VideoItem } from '../../viewmodel/VideoItem'
+
+@Component
+export struct SongDetailSheet {
+  @Prop item: VideoItem | undefined = undefined
+  @Prop themeColor: string = CommonConstants.DEFAULT_THEME_COLOR
+  @Prop opacityItem: number = 1
+  @Prop totalTime: string = '00:00'
+  @Prop useFallbackDuration: boolean = false
+
+  private resolveText(value: string | undefined, fallback: string = ''): string {
+    return StrUtil.isNotEmpty(value) ? value as string : fallback
+  }
+
+  private resolveDurationText(): string {
+    // 播放页更多里的详情允许回退到实时总时长,本地列表详情则优先展示文件元数据。
+    if (StrUtil.isNotEmpty(this.item?.duration)) {
+      return this.item?.duration as string
+    }
+    return this.useFallbackDuration ? this.totalTime : '未知'
+  }
+
+  @Builder
+  private detailRow(label: string, value: string, fontSize: number = 14) {
+    // 详情项统一走同一个 builder,保证 LocalMusic 和播放页弹层展示结构一致。
+    Row() {
+      Text(label)
+        .fontSize(14)
+        .fontColor($r('app.color.text_color'))
+        .margin({ left: 22 })
+      Text(value)
+        .fontSize(fontSize)
+        .margin({ left: 10 })
+        .fontColor($r('app.color.text_color'))
+        .layoutWeight(1)
+    }
+    .width('100%')
+    .margin({ top: 10, bottom: 10 })
+    .justifyContent(FlexAlign.Start)
+  }
+
+  build() {
+    Scroll() {
+      Column() {
+        if (this.item) {
+          // 详情弹层先保留原有字段展示顺序,优先做无感拆分,不改用户认知。
+          Row() {
+            Stack() {
+              Image(StrUtil.isEmpty(this.item?.pixelMapPath) ? $r('app.media.alt') : this.item?.pixelMapPath)
+                .fillColor(this.themeColor)
+                .height(80)
+                .width(80)
+                .borderRadius(10)
+                .opacity(this.opacityItem)
+                .margin({ left: 22 })
+            }
+
+            Column() {
+              Column() {
+                Text(this.resolveText(this.item?.name))
+                  .fontSize(16)
+                  .maxLines(1)
+                  .animation({
+                    duration: 555,
+                    curve: 'Linear',
+                  })
+                  .fontColor($r('app.color.text_color'))
+                  .margin({ left: 15 })
+              }
+              .height('100%')
+              .width('100%')
+              .justifyContent(FlexAlign.Center)
+              .alignItems(HorizontalAlign.Start)
+            }
+            .height('100%')
+          }
+          .width('100%')
+          .height(90)
+          .justifyContent(FlexAlign.SpaceBetween)
+
+          this.detailRow('艺术家:', this.resolveText(this.item?.artist, '未知歌手'))
+          this.detailRow('专辑名:', this.resolveText(this.item?.album, '未知专辑'))
+          this.detailRow('时长:', this.resolveDurationText())
+          this.detailRow('采样率:', Utility.convertToKHz(this.item?.sampleRate))
+          this.detailRow('比特率:', this.resolveText(this.item?.bit_rate))
+          this.detailRow('位深:', `${this.resolveText(this.item?.bits_per_raw_sample)} bits`)
+          this.detailRow('声道:', `${this.resolveText(this.item?.channels)}`)
+          this.detailRow('声道布局:', `${this.resolveText(this.item?.channel_layout)}`)
+          this.detailRow('起始播放点:', `${this.resolveText(this.item?.start_time)}`)
+          this.detailRow('风格:', this.resolveText(this.item?.genre))
+          this.detailRow('发行时间:', this.resolveText(this.item?.year))
+          this.detailRow('质量评分:', `${this.item?.probe_score ?? ''}`)
+          this.detailRow('音轨号:', this.resolveText(this.item?.track))
+          this.detailRow('碟号:', this.resolveText(this.item?.disc))
+          this.detailRow('专辑艺术家:', this.resolveText(this.item?.ALBUMARTIST))
+          this.detailRow('作曲家:', this.resolveText(this.item?.COMPOSER))
+          this.detailRow('作词家:', this.resolveText(this.item?.LYRICIST))
+          this.detailRow('注释:', this.resolveText(this.item?.COMMENT))
+          this.detailRow('格式:', Utility.formatMimeType(this.item?.mimeType))
+          this.detailRow('播放次数:', `${this.item?.playCount ?? ''}`)
+          this.detailRow('流数量:', `${this.item?.nb_streams ?? ''}`)
+          this.detailRow('文件大小:', this.resolveText(this.item?.size))
+          this.detailRow('文件名:', this.resolveText(this.item?.fileName))
+          this.detailRow('修改时间:', this.resolveText(this.item?.cTime))
+          this.detailRow('存放目录:', this.resolveText(this.item?.filePath), 12)
+        }
+      }
+    }
+    .width('100%')
+    .height('100%')
+  }
+}

+ 82 - 0
entry/src/main/ets/view/player/SongEditFormState.ets

@@ -0,0 +1,82 @@
+import { VideoItem } from '../../viewmodel/VideoItem'
+
+// 编辑标签表单状态单独抽成纯数据对象,方便 LocalMusic 和独立播放页共用同一套字段映射。
+export class SongEditFormState {
+  title: string = ''
+  artist: string = ''
+  album: string = ''
+  lyricContent: string = ''
+  year: string = ''
+  genre: string = ''
+  track: string = ''
+  albumArtist: string = ''
+  composer: string = ''
+  lyricist: string = ''
+  comment: string = ''
+  disc: string = ''
+  imagePath: string = ''
+}
+
+export function createEmptySongEditFormState(): SongEditFormState {
+  // 新建空表单时统一走这里,避免字段默认值分散在多个页面里。
+  return new SongEditFormState()
+}
+
+export function createSongEditFormState(item?: VideoItem): SongEditFormState {
+  const form = createEmptySongEditFormState()
+  if (!item) {
+    return form
+  }
+
+  // 播放页和 LocalMusic 复用同一套字段映射,后续只需要维护这一处。
+  form.title = item.name ?? ''
+  form.artist = item.artist ?? ''
+  form.album = item.album ?? ''
+  form.lyricContent = item.lyricContent ?? ''
+  form.year = item.year ?? ''
+  form.genre = item.genre ?? ''
+  form.track = item.track ?? ''
+  form.albumArtist = item.ALBUMARTIST ?? ''
+  form.composer = item.COMPOSER ?? ''
+  form.lyricist = item.LYRICIST ?? ''
+  form.comment = item.COMMENT ?? ''
+  form.disc = item.disc ?? ''
+  return form
+}
+
+export function resetSongEditFormState(form: SongEditFormState): void {
+  // 关闭编辑面板时统一清空所有可编辑字段,防止下次打开残留旧值。
+  form.title = ''
+  form.artist = ''
+  form.album = ''
+  form.lyricContent = ''
+  form.year = ''
+  form.genre = ''
+  form.track = ''
+  form.albumArtist = ''
+  form.composer = ''
+  form.lyricist = ''
+  form.comment = ''
+  form.disc = ''
+  form.imagePath = ''
+}
+
+export function applySongEditFormStateToItem(target: VideoItem | undefined, form: SongEditFormState): void {
+  if (!target) {
+    return
+  }
+
+  // 保存成功后统一把表单值回写到歌曲对象,避免多个页面各自维护同步逻辑。
+  target.name = form.title
+  target.artist = form.artist
+  target.album = form.album
+  target.lyricContent = form.lyricContent
+  target.year = form.year
+  target.genre = form.genre
+  target.track = form.track
+  target.ALBUMARTIST = form.albumArtist
+  target.COMPOSER = form.composer
+  target.LYRICIST = form.lyricist
+  target.COMMENT = form.comment
+  target.disc = form.disc
+}

+ 387 - 0
entry/src/main/ets/view/player/SongTagEditorSheet.ets

@@ -0,0 +1,387 @@
+import { fileUri } from '@kit.CoreFileKit'
+import { StrUtil } from '@pura/harmony-utils'
+import { CommonConstants } from '../../common/constants/CommonConstants'
+import { VideoItem } from '../../viewmodel/VideoItem'
+
+@Component
+export struct SongTagEditorSheet {
+  @Prop item: VideoItem | undefined = undefined
+  @Prop themeColor: string = CommonConstants.DEFAULT_THEME_COLOR
+  @Prop opacityItem: number = 1
+  @Prop tempLyricContent: string = ''
+  @Link titleStr: string
+  @Link ablumStr: string
+  @Link artistStr: string
+  @Link lyricConStr: string
+  @Link yearStr: string
+  @Link trackStr: string
+  @Link genreStr: string
+  @Link imagePathStr: string
+  @Link albumArtistStr: string
+  @Link composerStr: string
+  @Link lyricistStr: string
+  @Link commentStr: string
+  @Link discStr: string
+  onSelectCover: (item: VideoItem) => Promise<void> = async (_item: VideoItem): Promise<void> => {}
+  onFetchCover: (item: VideoItem) => Promise<void> = async (_item: VideoItem): Promise<void> => {}
+  onFetchLyric: (item: VideoItem) => Promise<void> = async (_item: VideoItem): Promise<void> => {}
+  onRepairMessy: (item: VideoItem) => Promise<void> = async (_item: VideoItem): Promise<void> => {}
+  onSelectLocalLyric: () => void = (): void => {}
+  onClearLyric: () => void = (): void => {}
+  onConvertLyricToTraditional: () => void = (): void => {}
+  onConvertLyricToSimplified: () => void = (): void => {}
+  onSave: (item: VideoItem) => Promise<void> = async (_item: VideoItem): Promise<void> => {}
+  onCancel: () => void = (): void => {}
+
+  private resolveLyricText(): string {
+    // 编辑时优先展示正在编辑的歌词,没有修改时再回退到临时歌词文本。
+    return StrUtil.isNotEmpty(this.lyricConStr) ? this.lyricConStr : this.tempLyricContent
+  }
+
+  @Builder
+  private textInputRow(label: string, value: string, onChange: (value: string) => void) {
+    // 文本类标签字段统一走一个 builder,避免多处重复布局代码。
+    Row() {
+      Text(label)
+        .fontSize(14)
+        .fontColor($r('app.color.text_color'))
+        .margin({ left: 22 })
+      TextInput({ text: value })
+        .height(40)
+        .maxLines(1)
+        .fontSize(14)
+        .layoutWeight(1)
+        .fontColor($r('app.color.text_color'))
+        .margin({ right: 20, left: 10 })
+        .onChange((text: string) => {
+          onChange(text)
+        })
+    }
+    .width('100%')
+    .margin({ top: 10, bottom: 10 })
+    .justifyContent(FlexAlign.Start)
+  }
+
+  @Builder
+  private numberInputRow(label: string, value: string, onChange: (value: string) => void) {
+    // 数值类字段单独收口,避免在通用输入行里混入条件类型判断。
+    Row() {
+      Text(label)
+        .fontSize(14)
+        .fontColor($r('app.color.text_color'))
+        .margin({ left: 22 })
+      TextInput({ text: value })
+        .height(40)
+        .maxLines(1)
+        .fontSize(14)
+        .type(InputType.Number)
+        .layoutWeight(1)
+        .fontColor($r('app.color.text_color'))
+        .margin({ right: 20, left: 10 })
+        .onChange((text: string) => {
+          onChange(text)
+        })
+    }
+    .width('100%')
+    .margin({ top: 10, bottom: 10 })
+    .justifyContent(FlexAlign.Start)
+  }
+
+  build() {
+    Scroll() {
+      Column() {
+        if (this.item) {
+          // 组件只负责 UI 和用户操作分发,真正的副作用仍留在调用方处理。
+          Row() {
+            Stack() {
+              Image(StrUtil.isNotEmpty(this.imagePathStr) ? (this.imagePathStr.startsWith('http') ?
+                this.imagePathStr : fileUri.getUriFromPath(this.imagePathStr)) :
+                (StrUtil.isEmpty(this.item?.pixelMapPath) ? $r('app.media.add_image2') : this.item?.pixelMapPath))
+                .fillColor(this.themeColor)
+                .height(88)
+                .width(88)
+                .clickEffect({ level: ClickEffectLevel.LIGHT, scale: 0.6 })
+                .borderRadius(12)
+                .clip(true)
+                .opacity(this.opacityItem)
+                .margin({ left: 25 })
+            }
+            .clickEffect({ level: ClickEffectLevel.LIGHT, scale: 0.6 })
+            .onClick(async () => {
+              await this.onSelectCover(this.item as VideoItem)
+            })
+            .width('28%')
+
+            Row() {
+              Column() {
+                Text(this.item?.name)
+                  .fontSize(17)
+                  .maxLines(1)
+                  .textOverflow({ overflow: TextOverflow.MARQUEE })
+                  .animation({
+                    duration: 555,
+                    curve: 'Linear',
+                  })
+                  .fontColor(this.themeColor)
+                  .margin({ left: 8 })
+                Row() {
+                  Text(this.item?.artist)
+                    .fontSize(14)
+                    .maxLines(1)
+                    .textOverflow({ overflow: TextOverflow.MARQUEE })
+                    .padding({ top: 8 })
+                    .fontColor(this.themeColor)
+                    .margin({ left: 8 })
+                }
+              }
+              .height('100%')
+              .width(100)
+              .layoutWeight(1)
+              .justifyContent(FlexAlign.Center)
+              .alignItems(HorizontalAlign.Start)
+
+              Column() {
+                Button({ type: ButtonType.Capsule, stateEffect: true }) {
+                  Row() {
+                    SymbolGlyph($r('sys.symbol.picture'))
+                      .fontSize(18)
+                      .fontColor([Color.White])
+                    Text('获取封面')
+                      .margin({ left: 4 })
+                      .fontSize(11)
+                      .fontColor(Color.White)
+                      .fontWeight(480)
+                      .textAlign(TextAlign.Center)
+                  }
+                  .justifyContent(FlexAlign.Center)
+                }
+                .height(38)
+                .width(100)
+                .clickEffect({ level: ClickEffectLevel.MIDDLE, scale: 0.6 })
+                .backgroundColor(this.themeColor)
+                .stateEffect(true)
+                .margin({ right: 35 })
+                .onClick(async () => {
+                  await this.onFetchCover(this.item as VideoItem)
+                })
+
+                Button({ type: ButtonType.Capsule, stateEffect: true }) {
+                  Row() {
+                    SymbolGlyph($r('sys.symbol.input_mode'))
+                      .fontSize(18)
+                      .fontColor([Color.White])
+                    Text('获取歌词')
+                      .margin({ left: 4 })
+                      .fontSize(11)
+                      .fontColor(Color.White)
+                      .fontWeight(480)
+                      .textAlign(TextAlign.Center)
+                  }
+                  .justifyContent(FlexAlign.Center)
+                }
+                .height(38)
+                .width(100)
+                .clickEffect({ level: ClickEffectLevel.MIDDLE, scale: 0.6 })
+                .backgroundColor(this.themeColor)
+                .stateEffect(true)
+                .margin({ top: 9, right: 35 })
+                .onClick(async () => {
+                  await this.onFetchLyric(this.item as VideoItem)
+                })
+              }
+              .justifyContent(FlexAlign.Start)
+            }
+            .height('100%')
+            .justifyContent(FlexAlign.Start)
+            .layoutWeight(1)
+          }
+          .width('100%')
+          .height(98)
+
+          Row() {
+            Text('标题:')
+              .fontSize(14)
+              .fontColor($r('app.color.text_color'))
+              .margin({ left: 22 })
+
+            TextInput({ text: this.titleStr })
+              .height(40)
+              .maxLines(1)
+              .fontSize(14)
+              .layoutWeight(1)
+              .fontColor($r('app.color.text_color'))
+              .margin({ right: 15, left: 10 })
+              .onChange((val: string) => {
+                this.titleStr = val
+              })
+
+            Button('修复乱码', { type: ButtonType.Capsule, stateEffect: true })
+              .clickEffect({ level: ClickEffectLevel.MIDDLE, scale: 0.6 })
+              .backgroundColor(this.themeColor)
+              .fontSize(10)
+              .margin({ right: 20 })
+              .onClick(async () => {
+                await this.onRepairMessy(this.item as VideoItem)
+              })
+          }
+          .width('100%')
+          .margin({ top: 10, bottom: 10 })
+          .justifyContent(FlexAlign.Start)
+
+          this.textInputRow('艺术家:', this.artistStr, (value: string) => {
+            this.artistStr = value
+          })
+          this.textInputRow('专辑名:', this.ablumStr, (value: string) => {
+            this.ablumStr = value
+          })
+
+          Row() {
+            Column() {
+              Column() {
+                Text('歌词:')
+                  .fontSize(14)
+                  .fontColor($r('app.color.text_color'))
+                  .margin({ top: 9, left: 22 })
+
+                Button({ type: ButtonType.Capsule, stateEffect: true }) {
+                  SymbolGlyph($r('sys.symbol.trash_fill'))
+                    .fontSize(25)
+                    .alignSelf(ItemAlign.Center)
+                    .margin({ left: 22, top: 25 })
+                }
+                .backgroundColor(Color.Transparent)
+                .visibility(StrUtil.isEmpty(this.tempLyricContent) && StrUtil.isEmpty(this.lyricConStr) ?
+                  Visibility.None : Visibility.Visible)
+                .clickEffect({ level: ClickEffectLevel.MIDDLE, scale: 0.6 })
+                .onClick(() => {
+                  this.onClearLyric()
+                })
+
+                Button({ type: ButtonType.Capsule, stateEffect: true }) {
+                  SymbolGlyph($r('sys.symbol.plus'))
+                    .fontSize(25)
+                    .alignSelf(ItemAlign.Center)
+                    .margin({ left: 22, top: 25 })
+                }
+                .backgroundColor(Color.Transparent)
+                .clickEffect({ level: ClickEffectLevel.MIDDLE, scale: 0.6 })
+                .onClick(() => {
+                  this.onSelectLocalLyric()
+                })
+
+                Button({ type: ButtonType.Capsule, stateEffect: true }) {
+                  SymbolGlyph($r('sys.symbol.traditional_square'))
+                    .fontSize(25)
+                    .alignSelf(ItemAlign.Center)
+                    .margin({ left: 22, top: 25 })
+                }
+                .backgroundColor(Color.Transparent)
+                .visibility(StrUtil.isEmpty(this.tempLyricContent) && StrUtil.isEmpty(this.lyricConStr) ?
+                  Visibility.None : Visibility.Visible)
+                .clickEffect({ level: ClickEffectLevel.MIDDLE, scale: 0.6 })
+                .onClick(() => {
+                  this.onConvertLyricToTraditional()
+                })
+
+                Button({ type: ButtonType.Capsule, stateEffect: true }) {
+                  Image($r('app.media.jianti'))
+                    .height(25)
+                    .fillColor($r('app.color.text_color'))
+                    .alignSelf(ItemAlign.Center)
+                    .margin({ left: 22, top: 25 })
+                }
+                .backgroundColor(Color.Transparent)
+                .visibility(StrUtil.isEmpty(this.tempLyricContent) && StrUtil.isEmpty(this.lyricConStr) ?
+                  Visibility.None : Visibility.Visible)
+                .clickEffect({ level: ClickEffectLevel.MIDDLE, scale: 0.6 })
+                .onClick(() => {
+                  this.onConvertLyricToSimplified()
+                })
+              }
+              .layoutWeight(1)
+            }
+
+            TextArea({ text: this.resolveLyricText() })
+              .type(TextAreaType.NORMAL)
+              .height(StrUtil.isEmpty(this.lyricConStr) && StrUtil.isEmpty(this.tempLyricContent) ? 100 : 'auto')
+              .fontSize(14)
+              .layoutWeight(1)
+              .fontColor($r('app.color.text_color'))
+              .margin({ right: 20, left: 10 })
+              .onChange((val: string) => {
+                this.lyricConStr = val
+              })
+          }
+          .width('100%')
+          .height(StrUtil.isEmpty(this.lyricConStr) && StrUtil.isEmpty(this.tempLyricContent) ? 120 : 'auto')
+          .margin({ top: 10, bottom: 10 })
+          .justifyContent(FlexAlign.Start)
+
+          this.numberInputRow('年份:', this.yearStr, (value: string) => {
+            this.yearStr = value
+          })
+          this.numberInputRow('音轨号:', this.trackStr, (value: string) => {
+            this.trackStr = value
+          })
+          this.numberInputRow('碟号:', this.discStr, (value: string) => {
+            this.discStr = value
+          })
+          this.textInputRow('风格:', this.genreStr, (value: string) => {
+            this.genreStr = value
+          })
+          this.textInputRow('专辑艺术家:', this.albumArtistStr, (value: string) => {
+            this.albumArtistStr = value
+          })
+          this.textInputRow('作曲:', this.composerStr, (value: string) => {
+            this.composerStr = value
+          })
+          this.textInputRow('作词:', this.lyricistStr, (value: string) => {
+            this.lyricistStr = value
+          })
+          this.textInputRow('注释:', this.commentStr, (value: string) => {
+            this.commentStr = value
+          })
+
+          Row() {
+            Button('保存')
+              .fontColor(Color.White)
+              .layoutWeight(1)
+              .height(50)
+              .width(100)
+              .clickEffect({ level: ClickEffectLevel.MIDDLE, scale: 0.6 })
+              .backgroundColor(this.themeColor)
+              .stateEffect(true)
+              .margin({ right: 20, bottom: 20 })
+              .onClick(async () => {
+                await this.onSave(this.item as VideoItem)
+              })
+
+            Button('取消')
+              .fontColor(Color.White)
+              .layoutWeight(1)
+              .height(50)
+              .width(100)
+              .clickEffect({ level: ClickEffectLevel.MIDDLE, scale: 0.6 })
+              .backgroundColor(this.themeColor)
+              .stateEffect(true)
+              .margin({ left: 20, bottom: 20 })
+              .onClick(() => {
+                this.onCancel()
+              })
+          }
+          .margin({
+            left: 38,
+            right: 38,
+            top: 10,
+            bottom: 10
+          })
+          .width(250)
+          .height(60)
+        }
+      }
+      .margin({ bottom: 20 })
+    }
+    .width('100%')
+    .height('100%')
+  }
+}

+ 248 - 0
entry/src/ohosTest/ets/test/MusicPlaybackController.test.ets

@@ -0,0 +1,248 @@
+import { describe, expect, it } from '@ohos/hypium'
+import { MusicPlaybackController } from '../../../main/ets/controller/MusicPlaybackController'
+
+export default function musicPlaybackControllerTest() {
+  describe('MusicPlaybackControllerTest', () => {
+    it('dispatchRegisteredActions', 0, async () => {
+      const controller = MusicPlaybackController.getInstance()
+      const calls: string[] = []
+
+      controller.clearActions()
+      controller.setActions({
+        playOrPause: () => {
+          calls.push('playOrPause')
+        },
+        playNext: () => {
+          calls.push('playNext')
+        },
+        playPrevious: () => {
+          calls.push('playPrevious')
+        },
+        setLoopMode: () => {
+          calls.push('setLoopMode')
+        },
+        seekTo: (value: string, source?: string) => {
+          calls.push(`seekTo:${value}:${source ?? ''}`)
+        }
+      })
+
+      controller.playOrPause()
+      await controller.playNext()
+      await controller.playPrevious()
+      await controller.setLoopMode()
+      await controller.seekTo('88', 'test')
+
+      expect(calls.join(',')).assertEqual('playOrPause,playNext,playPrevious,setLoopMode,seekTo:88:test')
+      controller.clearActions()
+    })
+
+    it('clearActionsStopsFurtherDispatch', 0, async () => {
+      const controller = MusicPlaybackController.getInstance()
+      let count = 0
+
+      const actions = {
+        playOrPause: () => {
+          count++
+        },
+        playNext: () => {
+          count++
+        },
+        playPrevious: () => {
+          count++
+        },
+        setLoopMode: () => {
+          count++
+        },
+        seekTo: (_value: string, _source?: string) => {
+          count++
+        }
+      }
+
+      controller.clearActions()
+      controller.setActions(actions)
+      controller.clearActions(actions)
+
+      controller.playOrPause()
+      await controller.playNext()
+      await controller.seekTo('1', 'cleared')
+
+      expect(count).assertEqual(0)
+      controller.clearActions()
+    })
+
+    it('missingActionsAreSafeNoop', 0, async () => {
+      const controller = MusicPlaybackController.getInstance()
+
+      controller.clearActions()
+      controller.playOrPause()
+      await controller.playNext()
+      await controller.playPrevious()
+      await controller.setLoopMode()
+      await controller.seekTo('50', 'noop')
+
+      expect(true).assertTrue()
+    })
+
+    it('advanceLoopModeWithBoundedCycle', 0, () => {
+      expect(MusicPlaybackController.resolveNextLoopMode(0).playType).assertEqual(1)
+      expect(MusicPlaybackController.resolveNextLoopMode(1).playType).assertEqual(2)
+      expect(MusicPlaybackController.resolveNextLoopMode(2).playType).assertEqual(3)
+      expect(MusicPlaybackController.resolveNextLoopMode(3).playType).assertEqual(4)
+      expect(MusicPlaybackController.resolveNextLoopMode(4).playType).assertEqual(0)
+    })
+
+    it('resolveLoopModeToastText', 0, () => {
+      expect(MusicPlaybackController.resolveNextLoopMode(0).toastText).assertEqual('单曲循环')
+      expect(MusicPlaybackController.resolveNextLoopMode(1).toastText).assertEqual('单曲播完')
+      expect(MusicPlaybackController.resolveNextLoopMode(2).toastText).assertEqual('随机播放')
+      expect(MusicPlaybackController.resolveNextLoopMode(3).toastText).assertEqual('连续播放不循环')
+      expect(MusicPlaybackController.resolveNextLoopMode(4).toastText).assertEqual('连续循环播放')
+    })
+
+    it('resolveSessionLoopModeMapping', 0, () => {
+      expect(MusicPlaybackController.resolveSessionLoopMode(0).playType).assertEqual(1)
+      expect(MusicPlaybackController.resolveSessionLoopMode(1).playType).assertEqual(0)
+      expect(MusicPlaybackController.resolveSessionLoopMode(2).playType).assertEqual(3)
+      expect(MusicPlaybackController.resolveSessionLoopMode(3).playType).assertEqual(2)
+    })
+
+    it('resolveAvSessionLoopModeMapping', 0, () => {
+      expect(MusicPlaybackController.resolveAvSessionLoopMode(0)).assertEqual(2)
+      expect(MusicPlaybackController.resolveAvSessionLoopMode(1)).assertEqual(1)
+      expect(MusicPlaybackController.resolveAvSessionLoopMode(2)).assertEqual(0)
+      expect(MusicPlaybackController.resolveAvSessionLoopMode(3)).assertEqual(3)
+      expect(MusicPlaybackController.resolveAvSessionLoopMode(4)).assertEqual(4)
+    })
+
+    it('resolveSeekPositionClampsByDuration', 0, () => {
+      const result = MusicPlaybackController.resolveSeekPosition({
+        requestedValue: '5000',
+        activeDuration: 3000
+      })
+
+      expect(result.canSeek).assertTrue()
+      expect(result.seekPos).assertEqual(2800)
+    })
+
+    it('resolveSeekPositionAppliesCueOffsets', 0, () => {
+      const result = MusicPlaybackController.resolveSeekPosition({
+        requestedValue: '1000',
+        activeDuration: 5000,
+        cueTrackStartOffset: 2000,
+        cueTrackEndOffset: 6000
+      })
+
+      expect(result.canSeek).assertTrue()
+      expect(result.seekPos).assertEqual(3000)
+    })
+
+    it('resolveSeekPositionBlocksUnknownDurationRemoteSeek', 0, () => {
+      const result = MusicPlaybackController.resolveSeekPosition({
+        requestedValue: '1000',
+        activeDuration: 0,
+        isRemoteSong: true
+      })
+
+      expect(result.canSeek).assertFalse()
+      expect(result.seekPos).assertEqual(1000)
+    })
+
+    it('resolveSeekValueFromPercentClampsInput', 0, () => {
+      expect(MusicPlaybackController.resolveSeekValueFromPercent(25, 4000)).assertEqual(1000)
+      expect(MusicPlaybackController.resolveSeekValueFromPercent(-10, 4000)).assertEqual(0)
+      expect(MusicPlaybackController.resolveSeekValueFromPercent(120, 4000)).assertEqual(4000)
+      expect(MusicPlaybackController.resolveSeekValueFromPercent(50, 0)).assertEqual(0)
+    })
+
+    it('resolveNextQueueIndexWrapsAtEnd', 0, () => {
+      expect(MusicPlaybackController.resolveNextQueueIndex(1, 3).nextIndex).assertEqual(2)
+      expect(MusicPlaybackController.resolveNextQueueIndex(2, 3).nextIndex).assertEqual(0)
+      expect(MusicPlaybackController.resolveNextQueueIndex(2, 3).reachedBoundary).assertTrue()
+    })
+
+    it('resolvePreviousQueueIndexWrapsAtStart', 0, () => {
+      expect(MusicPlaybackController.resolvePreviousQueueIndex(2, 3).nextIndex).assertEqual(1)
+      expect(MusicPlaybackController.resolvePreviousQueueIndex(0, 3).nextIndex).assertEqual(2)
+      expect(MusicPlaybackController.resolvePreviousQueueIndex(0, 3).reachedBoundary).assertTrue()
+    })
+
+    it('resolveQueueSongIndexUsesExistingQueuePosition', 0, () => {
+      const resolution = MusicPlaybackController.resolveQueueSongIndex(
+        ['/music/a.flac', '/music/b.flac', '/music/c.flac'],
+        '/music/b.flac'
+      )
+
+      expect(resolution.index).assertEqual(1)
+      expect(resolution.shouldAppend).assertFalse()
+    })
+
+    it('resolveQueueSongIndexAppendsMissingSong', 0, () => {
+      const resolution = MusicPlaybackController.resolveQueueSongIndex(
+        ['/music/a.flac', '/music/b.flac'],
+        '/music/c.flac'
+      )
+
+      expect(resolution.index).assertEqual(2)
+      expect(resolution.shouldAppend).assertTrue()
+    })
+
+    it('stopAtQueueEndOnlyForNonLoopSequentialMode', 0, () => {
+      expect(MusicPlaybackController.shouldStopAtQueueEnd(4, 2, 3)).assertTrue()
+      expect(MusicPlaybackController.shouldStopAtQueueEnd(4, 1, 3)).assertFalse()
+      expect(MusicPlaybackController.shouldStopAtQueueEnd(0, 2, 3)).assertFalse()
+      expect(MusicPlaybackController.shouldStopAtQueueEnd(4, 0, 0)).assertFalse()
+    })
+
+    it('resolveCompletionActionByPlayMode', 0, () => {
+      expect(MusicPlaybackController.resolveCompletionAction(0, 1, 3).action).assertEqual('play_next')
+      expect(MusicPlaybackController.resolveCompletionAction(1, 1, 3).action).assertEqual('replay_current')
+      expect(MusicPlaybackController.resolveCompletionAction(2, 1, 3).action).assertEqual('stop_current')
+      expect(MusicPlaybackController.resolveCompletionAction(3, 1, 3).action).assertEqual('random_next')
+    })
+
+    it('resolveCompletionActionStopsAtBoundaryForModeFour', 0, () => {
+      const resolution = MusicPlaybackController.resolveCompletionAction(4, 2, 3)
+
+      expect(resolution.action).assertEqual('stop_current')
+      expect(resolution.showBoundaryToast).assertTrue()
+    })
+
+    it('resolveNextPlaybackActionForRandomMode', 0, () => {
+      const resolution = MusicPlaybackController.resolveNextPlaybackAction(3, 1, 5)
+
+      expect(resolution).assertEqual('random_play')
+    })
+
+    it('resolveNextPlaybackActionStopsAtQueueBoundary', 0, () => {
+      const resolution = MusicPlaybackController.resolveNextPlaybackAction(4, 2, 3)
+
+      expect(resolution).assertEqual('stop_at_end')
+    })
+
+    it('resolveNextPlaybackActionAdvancesQueueByDefault', 0, () => {
+      const resolution = MusicPlaybackController.resolveNextPlaybackAction(0, 1, 3)
+
+      expect(resolution).assertEqual('advance_queue')
+    })
+
+    it('resolvePreviousPlaybackActionUsesHistoryOnlyForRandomMode', 0, () => {
+      expect(MusicPlaybackController.resolvePreviousPlaybackAction(3)).assertEqual('history_previous')
+      expect(MusicPlaybackController.resolvePreviousPlaybackAction(0)).assertEqual('queue_previous')
+    })
+
+    it('resolveTogglePlaybackAction', 0, () => {
+      expect(MusicPlaybackController.resolveTogglePlaybackAction(true, false)).assertEqual('pause')
+      expect(MusicPlaybackController.resolveTogglePlaybackAction(false, true)).assertEqual('resume_cast')
+      expect(MusicPlaybackController.resolveTogglePlaybackAction(false, false)).assertEqual('resume_local')
+    })
+
+    it('resolveItemPlaybackToggleAction', 0, () => {
+      expect(MusicPlaybackController.resolveItemPlaybackToggleAction('/a.flac', '/a.flac', false))
+        .assertEqual('toggle_current')
+      expect(MusicPlaybackController.resolveItemPlaybackToggleAction('/a.flac', '/b.flac', false))
+        .assertEqual('play_target')
+      expect(MusicPlaybackController.resolveItemPlaybackToggleAction('/a.flac', '/b.flac', true, true))
+        .assertEqual('toggle_current')
+    })
+  })
+}

Algunos archivos no se mostraron porque demasiados archivos cambiaron en este cambio