Преглед изворни кода

refactor(player): 统一后台宿主运行时入口

Codex пре 4 месеци
родитељ
комит
85f62acf6d

+ 27 - 6
entry/src/main/ets/controller/PlaybackCoordinator.ets

@@ -18,6 +18,7 @@ export interface PlaybackRuntime {
 export class PlaybackCoordinator {
 export class PlaybackCoordinator {
   private static instance?: PlaybackCoordinator
   private static instance?: PlaybackCoordinator
   private runtime?: PlaybackRuntime
   private runtime?: PlaybackRuntime
+  private defaultRuntime?: PlaybackRuntime
   private stateBridge: PlaybackStateBridge = PlaybackStateBridge.getInstance()
   private stateBridge: PlaybackStateBridge = PlaybackStateBridge.getInstance()
 
 
   /**
   /**
@@ -46,6 +47,21 @@ export class PlaybackCoordinator {
     this.runtime = runtime
     this.runtime = runtime
   }
   }
 
 
+  /**
+   * 绑定默认运行时。若当前没有显式运行时,则由默认运行时接管。
+   *
+   * @param runtime 默认运行时对象。
+   */
+  public bindDefaultRuntime(runtime: PlaybackRuntime): void {
+    this.defaultRuntime = runtime
+  }
+
+  public clearDefaultRuntime(runtime?: PlaybackRuntime): void {
+    if (!runtime || this.defaultRuntime === runtime) {
+      this.defaultRuntime = undefined
+    }
+  }
+
   /**
   /**
    * 清理已注册的运行时;传入参数时仅在匹配当前实例时才清理。
    * 清理已注册的运行时;传入参数时仅在匹配当前实例时才清理。
    *
    *
@@ -61,6 +77,10 @@ export class PlaybackCoordinator {
     return this.runtime !== undefined
     return this.runtime !== undefined
   }
   }
 
 
+  private resolveRuntime(): PlaybackRuntime | undefined {
+    return this.runtime ?? this.defaultRuntime
+  }
+
   /**
   /**
    * 请求运行时播放指定队列,并在失败时恢复之前的播放状态。
    * 请求运行时播放指定队列,并在失败时恢复之前的播放状态。
    *
    *
@@ -71,13 +91,14 @@ export class PlaybackCoordinator {
    * @returns 播放请求完成后的 Promise。
    * @returns 播放请求完成后的 Promise。
    */
    */
   public async playQueue(queue: VideoItem[], startIndex: number, source: string, playType?: number): Promise<void> {
   public async playQueue(queue: VideoItem[], startIndex: number, source: string, playType?: number): Promise<void> {
-    if (!this.runtime || queue.length <= 0) {
+    const runtime = this.resolveRuntime()
+    if (!runtime || queue.length <= 0) {
       return
       return
     }
     }
     const previousSnapshot = this.stateBridge.getSnapshot()
     const previousSnapshot = this.stateBridge.getSnapshot()
     this.stateBridge.replaceQueue(queue, startIndex)
     this.stateBridge.replaceQueue(queue, startIndex)
     try {
     try {
-      await this.runtime.playQueue(queue, this.stateBridge.getSnapshot().currentIndex, source, playType)
+      await runtime.playQueue(queue, this.stateBridge.getSnapshot().currentIndex, source, playType)
     } catch (error) {
     } catch (error) {
       this.stateBridge.restoreSnapshot(previousSnapshot)
       this.stateBridge.restoreSnapshot(previousSnapshot)
       const err = error as Error
       const err = error as Error
@@ -103,7 +124,7 @@ export class PlaybackCoordinator {
    * @returns 切换动作完成后的 Promise。
    * @returns 切换动作完成后的 Promise。
    */
    */
   public async playOrPause(): Promise<void> {
   public async playOrPause(): Promise<void> {
-    await this.runtime?.playOrPause()
+    await this.resolveRuntime()?.playOrPause()
   }
   }
 
 
   /**
   /**
@@ -112,7 +133,7 @@ export class PlaybackCoordinator {
    * @returns 切歌动作完成后的 Promise。
    * @returns 切歌动作完成后的 Promise。
    */
    */
   public async playNext(): Promise<void> {
   public async playNext(): Promise<void> {
-    await this.runtime?.playNext()
+    await this.resolveRuntime()?.playNext()
   }
   }
 
 
   /**
   /**
@@ -121,7 +142,7 @@ export class PlaybackCoordinator {
    * @returns 切歌动作完成后的 Promise。
    * @returns 切歌动作完成后的 Promise。
    */
    */
   public async playPrevious(): Promise<void> {
   public async playPrevious(): Promise<void> {
-    await this.runtime?.playPrevious()
+    await this.resolveRuntime()?.playPrevious()
   }
   }
 
 
   /**
   /**
@@ -132,6 +153,6 @@ export class PlaybackCoordinator {
    * @returns 跳转动作完成后的 Promise。
    * @returns 跳转动作完成后的 Promise。
    */
    */
   public async seekTo(value: string, source?: string): Promise<void> {
   public async seekTo(value: string, source?: string): Promise<void> {
-    await this.runtime?.seekTo(value, source)
+    await this.resolveRuntime()?.seekTo(value, source)
   }
   }
 }
 }

+ 51 - 8
entry/src/main/ets/pages/NewIndex.ets

@@ -71,6 +71,17 @@ import {
   resolveMiniPlayerOrbTapAction,
   resolveMiniPlayerOrbTapAction,
   shouldTriggerMiniPlayerOrbSingleTap
   shouldTriggerMiniPlayerOrbSingleTap
 } from '../common/util/MiniPlayerOrbTapHelper';
 } from '../common/util/MiniPlayerOrbTapHelper';
+import { MusicPlaybackController, MusicPlaybackHostActions } from '../controller/MusicPlaybackController';
+import { PlaybackCoordinator } from '../controller/PlaybackCoordinator';
+import {
+  ensurePlaybackHostStorageDefaults,
+  requestPlaybackPlayerOpen,
+  requestPlaybackPlaylistOpen,
+  setPlaybackRuntimeReady,
+  showPlaybackPlayer
+} from '../playback/PlaybackHostState';
+import { getRegisteredPlaybackRuntime } from '../playback/PlaybackRuntimeRegistry';
+import { BackgroundAudioPlaybackHost } from '../playback/BackgroundAudioPlaybackHost';
 
 
 // import { FFmpeg, FFProgressMessageParser } from '@sj/ffmpeg';
 // import { FFmpeg, FFProgressMessageParser } from '@sj/ffmpeg';
 
 
@@ -122,9 +133,13 @@ struct NewIndex {
   @Provide cover: string | undefined = '';
   @Provide cover: string | undefined = '';
   // @Provide currentSong: VideoItem | undefined = undefined;
   // @Provide currentSong: VideoItem | undefined = undefined;
   @StorageLink('currentSong') currentSong: VideoItem | undefined = undefined;
   @StorageLink('currentSong') currentSong: VideoItem | undefined = undefined;
+  @StorageLink('playbackRuntimeReady') @Watch('syncPlaybackRuntimeBinding')
+  playbackRuntimeReady: boolean = false;
   @Provide @Watch('syncMiniPlayerVisibility') isMultiSelect: boolean = false;
   @Provide @Watch('syncMiniPlayerVisibility') isMultiSelect: boolean = false;
   /** 页面上下文 */
   /** 页面上下文 */
   context = this.getUIContext().getHostContext() as common.UIAbilityContext
   context = this.getUIContext().getHostContext() as common.UIAbilityContext
+  private playbackController: MusicPlaybackController = MusicPlaybackController.getInstance()
+  private playbackCoordinator: PlaybackCoordinator = PlaybackCoordinator.getInstance()
   @Provide currentSongListName:string = '' //当前歌单名称
   @Provide currentSongListName:string = '' //当前歌单名称
   @Provide  currentSongListID:string='' //当前歌单ID
   @Provide  currentSongListID:string='' //当前歌单ID
   @State isDarkMode: boolean = false
   @State isDarkMode: boolean = false
@@ -220,6 +235,27 @@ struct NewIndex {
   private readonly miniPlayerModeBounceDuration: number = 220;
   private readonly miniPlayerModeBounceDuration: number = 220;
   private readonly miniPlayerOrbDoubleTapWindowMs: number = 240;
   private readonly miniPlayerOrbDoubleTapWindowMs: number = 240;
   private isMiniPlayerModeTransitioning: boolean = false;
   private isMiniPlayerModeTransitioning: boolean = false;
+  private readonly playbackHostActions: MusicPlaybackHostActions = {
+    showPlayerView: (): void => {
+      requestPlaybackPlayerOpen()
+    },
+    dismissPlayerView: (): void => {
+      showPlaybackPlayer(false)
+    },
+    openPlayList: (): void => {
+      requestPlaybackPlaylistOpen()
+    }
+  }
+
+  private syncPlaybackRuntimeBinding(): void {
+    this.playbackCoordinator.bindDefaultRuntime(BackgroundAudioPlaybackHost.getInstance().getRuntime())
+    const runtime = getRegisteredPlaybackRuntime()
+    if (this.playbackRuntimeReady && runtime) {
+      this.playbackCoordinator.setRuntime(runtime)
+      return
+    }
+    this.playbackCoordinator.clearRuntime()
+  }
   //当胶囊按钮的选择发生变化时调用此函数
   //当胶囊按钮的选择发生变化时调用此函数
   tabSelectedIndexesChanged() {
   tabSelectedIndexesChanged() {
     if(this.tabSelectedIndexes[0]==1){
     if(this.tabSelectedIndexes[0]==1){
@@ -256,7 +292,7 @@ struct NewIndex {
     console.info('onecold onBackPress isDetailView = '+ this.isDetailView);
     console.info('onecold onBackPress isDetailView = '+ this.isDetailView);
     console.info('onecold onBackPress mType = '+  this.mType);
     console.info('onecold onBackPress mType = '+  this.mType);
     if (this.isShowPlay) {
     if (this.isShowPlay) {
-      this.getUIContext().getHostContext()!.eventHub.emit('dismissPlayerView');
+      void this.playbackController.dismissPlayerView();
       return true
       return true
     }
     }
     if (this.mType === 4) {
     if (this.mType === 4) {
@@ -357,6 +393,9 @@ struct NewIndex {
    */
    */
 
 
   async aboutToAppear() {
   async aboutToAppear() {
+    ensurePlaybackHostStorageDefaults()
+    this.playbackController.setHostActions(this.playbackHostActions)
+    this.syncPlaybackRuntimeBinding()
     this.defalut_home_type = PreferencesUtil.getNumberSync('defalut_home_type', 0)
     this.defalut_home_type = PreferencesUtil.getNumberSync('defalut_home_type', 0)
     this.isShowPrecious = PreferencesUtil.getBooleanSync('isShowPrecious', false)
     this.isShowPrecious = PreferencesUtil.getBooleanSync('isShowPrecious', false)
     ReqPermissionUtil.reqPermissionsFromUser(ReqPermissionUtil.permissions, this.context);
     ReqPermissionUtil.reqPermissionsFromUser(ReqPermissionUtil.permissions, this.context);
@@ -607,6 +646,10 @@ struct NewIndex {
    */
    */
   aboutToDisappear() {
   aboutToDisappear() {
     console.info('NewIndex aboutToDisappear');
     console.info('NewIndex aboutToDisappear');
+    this.playbackController.clearHostActions(this.playbackHostActions)
+    this.playbackCoordinator.clearRuntime()
+    this.playbackCoordinator.clearDefaultRuntime(BackgroundAudioPlaybackHost.getInstance().getRuntime())
+    setPlaybackRuntimeReady(false)
     this.clearMiniPlayerAnimationTimer();
     this.clearMiniPlayerAnimationTimer();
     this.breakpointSystem.unregister();
     this.breakpointSystem.unregister();
     emitter.off(EventConstants.EVENT_SWIPE_BACK_UPDATE);
     emitter.off(EventConstants.EVENT_SWIPE_BACK_UPDATE);
@@ -1136,16 +1179,16 @@ struct NewIndex {
         this.handleMiniPlayerOrbTap()
         this.handleMiniPlayerOrbTap()
       },
       },
       onPlayPrevious: (): void => {
       onPlayPrevious: (): void => {
-        this.getUIContext().getHostContext()!.eventHub.emit('playPrevious');
+        void this.playbackController.playPrevious()
       },
       },
       onPlayOrPause: (): void => {
       onPlayOrPause: (): void => {
-        this.getUIContext().getHostContext()!.eventHub.emit('playOrPause');
+        void this.playbackController.playOrPause()
       },
       },
       onPlayNext: (): void => {
       onPlayNext: (): void => {
-        this.getUIContext().getHostContext()!.eventHub.emit('playNext');
+        void this.playbackController.playNext()
       },
       },
       onOpenPlayList: (): void => {
       onOpenPlayList: (): void => {
-        this.getUIContext().getHostContext()!.eventHub.emit('openPlayList');
+        void this.playbackController.openPlayList()
       }
       }
     })
     })
   }
   }
@@ -1215,7 +1258,7 @@ struct NewIndex {
     if (this.isShowPlay || this.isMiniPlayerModeTransitioning) {
     if (this.isShowPlay || this.isMiniPlayerModeTransitioning) {
       return
       return
     }
     }
-    this.getUIContext().getHostContext()!.eventHub.emit('openPlayerViewFromMiniBar');
+    void this.playbackController.showPlayerView()
   }
   }
 
 
   // 右侧圆球内部内容:封面、暗罩、白色环形进度和中心播放状态指示。
   // 右侧圆球内部内容:封面、暗罩、白色环形进度和中心播放状态指示。
@@ -1632,7 +1675,7 @@ struct NewIndex {
           if (this.isMiniPlayerModeTransitioning) {
           if (this.isMiniPlayerModeTransitioning) {
             return
             return
           }
           }
-          this.getUIContext().getHostContext()!.eventHub.emit('openPlayList');
+          void this.playbackController.openPlayList()
         })
         })
     }
     }
     .margin({left:5})
     .margin({left:5})
@@ -1716,7 +1759,7 @@ struct NewIndex {
           if (this.isMiniPlayerModeTransitioning) {
           if (this.isMiniPlayerModeTransitioning) {
             return
             return
           }
           }
-          this.getUIContext().getHostContext()!.eventHub.emit('openPlayList');
+          void this.playbackController.openPlayList()
         })
         })
     }
     }
     .margin({ left: 16 })
     .margin({ left: 16 })

+ 640 - 0
entry/src/main/ets/playback/BackgroundAudioPlaybackHost.ets

@@ -0,0 +1,640 @@
+import { common, Context } from '@kit.AbilityKit'
+import { BusinessError } from '@kit.BasicServicesKit'
+import { avSession } from '@kit.AVSessionKit'
+import { PreferencesUtil } from '@pura/harmony-utils'
+import {
+  DeviceChangeReason,
+  InterruptEvent,
+  InterruptHintType,
+  IjkMediaPlayer
+} from '@ohos/ijkplayer'
+import {
+  ImplOnCompletionListener,
+  ImplOnErrorListener,
+  ImplOnPreparedListener,
+  ImplOnSeekCompleteListener
+} from '../common/IjkPlayerListenerImpls'
+import { PlayStatus } from '../common/PlayStatus'
+import { MusicCardActionConstants } from '../common/player/MusicCardActionConstants'
+import { MusicCardManager, MusicCardPlaybackStateOptions } from '../common/player/MusicCardManager'
+import { BackgroundTaskManager } from '../common/util/BackgroundTaskManager'
+import Logger from '../common/util/Logger'
+import { setVideoUrlForSong } from '../common/util/RemotePlayerUtil'
+import { MusicPlaybackController } from '../controller/MusicPlaybackController'
+import { PlaybackRuntime } from '../controller/PlaybackCoordinator'
+import { VideoItem } from '../viewmodel/VideoItem'
+import {
+  BackgroundAudioControlDecision,
+  BackgroundAudioControlKind,
+  BackgroundAudioPersistedState,
+  BackgroundAudioPlaybackHostHelper,
+  BackgroundAudioRecoveredQueue
+} from './BackgroundAudioPlaybackHostHelper'
+
+const TAG = 'BackgroundAudioHost'
+const PLAYER_ID = 'audioIjkId'
+const PROGRESS_INTERVAL_MS = 1000
+const PLAYBACK_VERIFY_DELAY_MS = 200
+const PLAYBACK_VERIFY_RETRY_DELAY_MS = 350
+
+export class BackgroundAudioPlaybackHost {
+  private static instance: BackgroundAudioPlaybackHost
+
+  private context: common.Context | undefined = undefined
+  private player: IjkMediaPlayer | undefined = undefined
+  private queue: VideoItem[] = []
+  private currentIndex: number = -1
+  private currentSong: VideoItem | undefined = undefined
+  private playType: number = 0
+  private isPlaying: boolean = false
+  private isPrepared: boolean = false
+  private currentUrl: string = ''
+  private progressTimerId: number = -1
+  private playbackStartVerifyToken: number = 0
+  private playbackSession: avSession.AVSession | undefined = undefined
+  private creatingPlaybackSession: boolean = false
+  private readonly runtime: PlaybackRuntime = {
+    playQueue: async (queue: VideoItem[], startIndex: number, source: string, playType?: number): Promise<void> => {
+      await this.playQueue(queue, startIndex, source, playType)
+    },
+    playOrPause: async (): Promise<void> => {
+      await this.playOrPause()
+    },
+    playNext: async (): Promise<void> => {
+      await this.playNext()
+    },
+    playPrevious: async (): Promise<void> => {
+      await this.playPrevious()
+    },
+    seekTo: async (value: string): Promise<void> => {
+      await this.seekTo(value)
+    }
+  }
+
+  public static getInstance(): BackgroundAudioPlaybackHost {
+    if (!BackgroundAudioPlaybackHost.instance) {
+      BackgroundAudioPlaybackHost.instance = new BackgroundAudioPlaybackHost()
+    }
+    return BackgroundAudioPlaybackHost.instance
+  }
+
+  public setContext(context: common.Context | undefined): void {
+    this.context = context
+    Logger.info(TAG, `[MusicCast] setContext contextReady=${context !== undefined}`)
+  }
+
+  public getRuntime(): PlaybackRuntime {
+    return this.runtime
+  }
+
+  public async playQueue(queue: VideoItem[], startIndex: number, source: string, playType?: number): Promise<void> {
+    if (queue.length <= 0) {
+      Logger.warn(TAG, `[MusicCast] playQueue ignored empty queue source=${source}`)
+      return
+    }
+    const safeIndex = Math.max(0, Math.min(startIndex, queue.length - 1))
+    this.queue = [...queue]
+    this.currentIndex = safeIndex
+    this.currentSong = this.queue[safeIndex]
+    if (playType !== undefined) {
+      this.playType = playType
+    }
+    Logger.info(TAG,
+      `[MusicCast] playQueue source=${source}, queueLength=${this.queue.length}, startIndex=${startIndex}, ` +
+      `safeIndex=${safeIndex}, playType=${this.playType}`)
+    this.persistCurrentQueue()
+    await this.playIndex(safeIndex)
+  }
+
+  public async playOrPause(): Promise<void> {
+    this.restorePersistedQueueIfNeeded()
+    await this.handleControlAction(MusicCardActionConstants.ACTION_PLAY_PAUSE)
+  }
+
+  public async playNext(): Promise<void> {
+    this.restorePersistedQueueIfNeeded()
+    await this.handleControlAction(MusicCardActionConstants.ACTION_NEXT)
+  }
+
+  public async playPrevious(): Promise<void> {
+    this.restorePersistedQueueIfNeeded()
+    await this.handleControlAction(MusicCardActionConstants.ACTION_PREVIOUS)
+  }
+
+  public async seekTo(value: string, _source?: string): Promise<void> {
+    this.restorePersistedQueueIfNeeded()
+    await this.handleControlAction(MusicCardActionConstants.ACTION_SEEK_TO, value)
+  }
+
+  public async handleControlAction(action: string, seekPositionMs: string = ''): Promise<boolean> {
+    try {
+      Logger.info(TAG,
+        `[MusicCast] handleControlAction action=${action}, seek=${seekPositionMs}, queueLength=${this.queue.length}, ` +
+        `currentIndex=${this.currentIndex}, isPlaying=${this.isPlaying}, isPrepared=${this.isPrepared}`)
+      this.restorePersistedQueueIfNeeded()
+      if (action === MusicCardActionConstants.ACTION_SEEK_TO) {
+        const handled = this.seekToInternal(seekPositionMs)
+        Logger.info(TAG, `[MusicCast] seek decision handled=${handled}, seek=${seekPositionMs}`)
+        return handled
+      }
+      const decision = BackgroundAudioPlaybackHostHelper.resolveControlDecision(
+        action,
+        this.currentIndex,
+        this.queue.length,
+        this.isPlaying,
+        this.playType
+      )
+      Logger.info(TAG,
+        `[MusicCast] decision kind=${decision.kind}, targetIndex=${decision.targetIndex}, ` +
+        `queueLength=${this.queue.length}, currentIndex=${this.currentIndex}, playType=${this.playType}`)
+      return this.executeDecision(decision)
+    } catch (error) {
+      Logger.error(TAG, `[MusicCast] handleControlAction failed: ${(error as Error).message}`)
+      return false
+    }
+  }
+
+  private async executeDecision(decision: BackgroundAudioControlDecision): Promise<boolean> {
+    if (decision.kind === BackgroundAudioControlKind.PAUSE) {
+      this.pause()
+      return true
+    }
+    if (decision.kind === BackgroundAudioControlKind.STOP) {
+      this.stop()
+      return true
+    }
+    if (decision.kind === BackgroundAudioControlKind.PLAY_INDEX) {
+      if (!this.isPlaying && this.isPrepared && decision.targetIndex === this.currentIndex) {
+        this.resume()
+        return true
+      }
+      return this.playIndex(decision.targetIndex)
+    }
+    return false
+  }
+
+  private restorePersistedQueueIfNeeded(): void {
+    if (this.queue.length > 0 && this.currentIndex >= 0 && this.currentIndex < this.queue.length) {
+      Logger.info(TAG,
+        `[MusicCast] skip restore because queue already ready queueLength=${this.queue.length}, currentIndex=${this.currentIndex}`)
+      return
+    }
+    const persistedState: BackgroundAudioPersistedState = BackgroundAudioPlaybackHostHelper.readPersistedState(
+      () => PreferencesUtil.getSync('LastMusicList', []) as VideoItem[],
+      () => PreferencesUtil.getSync('LastMusicInfo', undefined) as VideoItem | undefined,
+      () => PreferencesUtil.getNumberSync('musicPlayType', 0)
+    )
+    if (persistedState.errorMessage) {
+      Logger.error(TAG, `[MusicCast] restore persisted read failed: ${persistedState.errorMessage}`)
+    }
+    Logger.info(TAG,
+      `[MusicCast] restore persisted queueLength=${persistedState.queue.length}, currentSong=${persistedState.currentSong?.name ?? ''}, ` +
+      `path=${persistedState.currentSong?.filePath ?? ''}, playType=${persistedState.playType}`)
+    const restored = BackgroundAudioPlaybackHostHelper.restorePersistedQueue(
+      persistedState.queue,
+      persistedState.currentSong,
+      persistedState.playType
+    )
+    this.applyRecoveredQueue(restored)
+  }
+
+  private applyRecoveredQueue(restored: BackgroundAudioRecoveredQueue): void {
+    this.queue = restored.queue
+    this.currentIndex = restored.currentIndex
+    this.currentSong = restored.currentSong
+    this.playType = restored.playType
+    Logger.info(TAG,
+      `[MusicCast] applyRecoveredQueue queueLength=${this.queue.length}, currentIndex=${this.currentIndex}, ` +
+      `currentSong=${this.currentSong?.name ?? ''}, path=${this.currentSong?.filePath ?? ''}, playType=${this.playType}`)
+    this.syncHostStorage(this.isPlaying ? PlayStatus.PLAY : PlayStatus.PAUSE)
+  }
+
+  private async playIndex(index: number): Promise<boolean> {
+    if (index < 0 || index >= this.queue.length) {
+      Logger.warn(TAG, `[MusicCast] playIndex ignored invalid index=${index}, queueLength=${this.queue.length}`)
+      return false
+    }
+    this.currentIndex = index
+    this.currentSong = this.queue[index]
+    Logger.info(TAG,
+      `[MusicCast] playIndex index=${index}, song=${this.currentSong?.name ?? ''}, path=${this.currentSong?.filePath ?? ''}, type=${this.currentSong?.type ?? -1}`)
+    this.persistCurrentQueue()
+    return this.prepareAndStartCurrentSong()
+  }
+
+  private async prepareAndStartCurrentSong(): Promise<boolean> {
+    if (!this.currentSong) {
+      Logger.warn(TAG, '[MusicCast] prepare skipped because currentSong missing')
+      return false
+    }
+    if (!this.context) {
+      Logger.warn(TAG, '[MusicCast] prepare skipped because context missing')
+      return false
+    }
+    try {
+      await this.ensurePlaybackSession()
+      const player = this.ensurePlayer()
+      this.playbackStartVerifyToken++
+      this.isPrepared = false
+      this.isPlaying = false
+      this.currentUrl = await setVideoUrlForSong(this.currentSong, {
+        context: this.context as Context,
+        extractAudioInfo: false,
+        extractCover: false,
+        extractLyric: false
+      })
+      Logger.info(TAG,
+        `[MusicCast] prepare resolved url=${this.currentUrl}, song=${this.currentSong.name}, path=${this.currentSong.filePath}`)
+
+      player.reset()
+      player.setAudioId(PLAYER_ID)
+      player.native_setup()
+      player.setOption(IjkMediaPlayer.OPT_CATEGORY_PLAYER, 'start-on-prepared', '1')
+      player.setOption(IjkMediaPlayer.OPT_CATEGORY_PLAYER, 'packet-buffering', '0')
+      player.setOption(IjkMediaPlayer.OPT_CATEGORY_PLAYER, 'soundtouch', '1')
+      player.setVolume('1', '1')
+      player.setDataSource(this.currentUrl)
+      const headers = new Map<string, string>()
+      headers.set('User-Agent', 'TTMusic-Widget/1.0')
+      headers.set('Accept', '*/*')
+      player.setDataSourceHeader(headers)
+      void this.syncPlaybackSessionMetadata()
+      player.prepareAsync()
+      player.start()
+      Logger.info(TAG, `[MusicCast] prepare issued start song=${this.currentSong.name}, playerId=${PLAYER_ID}`)
+      this.syncHostStorage(PlayStatus.PLAY)
+      this.publishSnapshot(false)
+      return true
+    } catch (error) {
+      Logger.error(TAG, `[MusicCast] prepare failed: ${(error as Error).message}`)
+      this.isPrepared = false
+      this.isPlaying = false
+      this.syncHostStorage(PlayStatus.PAUSE)
+      this.publishSnapshot(false)
+      return false
+    }
+  }
+
+  private ensurePlayer(): IjkMediaPlayer {
+    if (this.player) {
+      return this.player
+    }
+    const player = new IjkMediaPlayer()
+    player.setAudioId(PLAYER_ID)
+    player.native_setup()
+    player.setOnPreparedListener(new ImplOnPreparedListener(() => {
+      this.isPrepared = true
+      player.start()
+      this.isPlaying = player.isPlaying()
+      Logger.info(TAG,
+        `[MusicCast] onPrepared song=${this.currentSong?.name ?? ''}, path=${this.currentSong?.filePath ?? ''}, ` +
+        `duration=${player.getDuration()}, isPlaying=${player.isPlaying()}, position=${player.getCurrentPosition()}, ` +
+        `audioSessionId=${player.getAudioSessionId()}`)
+      this.startProgressTimer()
+      this.verifyPlaybackStarted(this.playbackStartVerifyToken, 'onPrepared')
+      this.syncHostStorage(PlayStatus.PLAY)
+      this.syncPlaybackSessionState(PlayStatus.PLAY)
+      this.publishSnapshot(true)
+    }))
+    player.setOnCompletionListener(new ImplOnCompletionListener(() => {
+      Logger.info(TAG,
+        `[MusicCast] onCompletion currentIndex=${this.currentIndex}, queueLength=${this.queue.length}, playType=${this.playType}`)
+      this.handleCompletion()
+    }))
+    player.setOnErrorListener(new ImplOnErrorListener((what: number, extra: number) => {
+      Logger.error(TAG, `[MusicCast] player error what=${what}, extra=${extra}, url=${this.currentUrl}`)
+      this.isPrepared = false
+      this.isPlaying = false
+      this.stopProgressTimer()
+      this.syncHostStorage(PlayStatus.PAUSE)
+      this.syncPlaybackSessionState(PlayStatus.PAUSE)
+      this.publishSnapshot(false)
+    }))
+    player.setOnSeekCompleteListener(new ImplOnSeekCompleteListener(() => {
+      Logger.info(TAG, `[MusicCast] onSeekComplete position=${player.getCurrentPosition()}`)
+      this.publishProgress()
+    }))
+    player.on('audioInterrupt', (event: InterruptEvent) => {
+      this.handleAudioInterrupt(player, event)
+    })
+    player.on('deviceChange', (event: InterruptEvent) => {
+      Logger.info(TAG, `[MusicCast] deviceChange reason=${event.reason ?? DeviceChangeReason.REASON_UNKNOWN}`)
+    })
+    player.setMessageListener()
+    this.player = player
+    Logger.info(TAG, `[MusicCast] ensurePlayer created playerId=${PLAYER_ID}`)
+    return player
+  }
+
+  private handleCompletion(): void {
+    const completion = MusicPlaybackController.resolveCompletionAction(this.playType, this.currentIndex, this.queue.length)
+    if (completion.action === 'replay_current') {
+      void this.playIndex(this.currentIndex)
+      return
+    }
+    if (completion.action === 'stop_current') {
+      this.stop()
+      return
+    }
+    const nextIndex = MusicPlaybackController.resolveNextQueueIndex(this.currentIndex, this.queue.length).nextIndex
+    void this.playIndex(nextIndex)
+  }
+
+  private pause(): void {
+    const player = this.player
+    if (!player) {
+      Logger.warn(TAG, '[MusicCast] pause ignored because player missing')
+      return
+    }
+    player.pause()
+    this.isPlaying = false
+    this.stopProgressTimer()
+    Logger.info(TAG, `[MusicCast] pause position=${player.getCurrentPosition()}, song=${this.currentSong?.name ?? ''}`)
+    this.syncHostStorage(PlayStatus.PAUSE)
+    this.syncPlaybackSessionState(PlayStatus.PAUSE)
+    this.publishSnapshot(false)
+  }
+
+  private resume(): void {
+    const player = this.player
+    if (!player) {
+      Logger.warn(TAG, '[MusicCast] resume ignored because player missing')
+      return
+    }
+    player.start()
+    this.isPlaying = true
+    this.startProgressTimer()
+    Logger.info(TAG, `[MusicCast] resume position=${player.getCurrentPosition()}, song=${this.currentSong?.name ?? ''}`)
+    this.syncHostStorage(PlayStatus.PLAY)
+    this.syncPlaybackSessionState(PlayStatus.PLAY)
+    this.publishSnapshot(true)
+  }
+
+  private verifyPlaybackStarted(token: number, source: string): void {
+    setTimeout(() => {
+      if (token !== this.playbackStartVerifyToken || !this.player) {
+        return
+      }
+      const player = this.player
+      const isPlayingNow = player.isPlaying()
+      const currentPosition = player.getCurrentPosition()
+      Logger.info(TAG,
+        `[MusicCast] verifyPlaybackStarted source=${source}, token=${token}, isPlaying=${isPlayingNow}, position=${currentPosition}`)
+      if (isPlayingNow && currentPosition > 0) {
+        this.isPlaying = true
+        this.syncPlaybackSessionState(PlayStatus.PLAY)
+        return
+      }
+      Logger.warn(TAG,
+        `[MusicCast] verifyPlaybackStarted retry start source=${source}, token=${token}, ` +
+        `isPlaying=${isPlayingNow}, position=${currentPosition}`)
+      player.start()
+      setTimeout(() => {
+        if (token !== this.playbackStartVerifyToken || !this.player) {
+          return
+        }
+        const retryPlaying = this.player.isPlaying()
+        const retryPosition = this.player.getCurrentPosition()
+        this.isPlaying = retryPlaying
+        Logger.info(TAG,
+          `[MusicCast] verifyPlaybackStarted afterRetry source=${source}, token=${token}, ` +
+          `isPlaying=${retryPlaying}, position=${retryPosition}`)
+        this.syncPlaybackSessionState(retryPlaying ? PlayStatus.PLAY : PlayStatus.PAUSE)
+      }, PLAYBACK_VERIFY_RETRY_DELAY_MS)
+    }, PLAYBACK_VERIFY_DELAY_MS)
+  }
+
+  private stop(): void {
+    const player = this.player
+    const abilityContext = this.context as common.UIAbilityContext | undefined
+    if (player) {
+      Logger.info(TAG, `[MusicCast] stop position=${player.getCurrentPosition()}, song=${this.currentSong?.name ?? ''}`)
+      player.stop()
+    }
+    this.isPlaying = false
+    this.isPrepared = false
+    this.stopProgressTimer()
+    this.syncHostStorage(PlayStatus.INIT)
+    this.syncPlaybackSessionState(PlayStatus.INIT)
+    this.publishSnapshot(false)
+    BackgroundTaskManager.stopContinuousTask(abilityContext)
+  }
+
+  private seekToInternal(value: string): boolean {
+    const player = this.player
+    if (!player || !this.currentSong || value === '') {
+      Logger.warn(TAG,
+        `[MusicCast] seek ignored playerReady=${player !== undefined}, songReady=${this.currentSong !== undefined}, value=${value}`)
+      return false
+    }
+    Logger.info(TAG, `[MusicCast] seekTo value=${value}, song=${this.currentSong.name}`)
+    player.seekTo(value)
+    return true
+  }
+
+  private startProgressTimer(): void {
+    this.stopProgressTimer()
+    this.progressTimerId = setInterval(() => {
+      this.publishProgress()
+    }, PROGRESS_INTERVAL_MS)
+  }
+
+  private stopProgressTimer(): void {
+    if (this.progressTimerId >= 0) {
+      clearInterval(this.progressTimerId)
+      this.progressTimerId = -1
+    }
+  }
+
+  private publishProgress(): void {
+    if (!this.player || !this.currentSong || !this.context) {
+      return
+    }
+    const durationMs = this.resolveDurationMs()
+    const positionMs = Math.max(0, this.player.getCurrentPosition())
+    Logger.info(TAG,
+      `[MusicCast] progress position=${positionMs}, duration=${durationMs}, isPlaying=${this.player.isPlaying()}, ` +
+      `audioSessionId=${this.player.getAudioSessionId()}`)
+    MusicCardManager.getInstance().notifyProgressTick(
+      this.context,
+      this.currentSong,
+      this.isPlaying,
+      positionMs,
+      durationMs,
+      this.currentSong.lyricContent,
+      this.currentSong.pixelMapPath
+    )
+    AppStorage.setOrCreate('progressValue', positionMs)
+    this.syncPlaybackSessionState(this.isPlaying ? PlayStatus.PLAY : PlayStatus.PAUSE)
+  }
+
+  private async ensurePlaybackSession(): Promise<void> {
+    const abilityContext = this.context as common.UIAbilityContext | undefined
+    if (!abilityContext) {
+      Logger.warn(TAG, '[MusicCast] ensurePlaybackSession skipped because abilityContext missing')
+      return
+    }
+    BackgroundTaskManager.startContinuousTask(abilityContext)
+    if (this.playbackSession || this.creatingPlaybackSession) {
+      return
+    }
+    this.creatingPlaybackSession = true
+    try {
+      const session = await avSession.createAVSession(abilityContext, 'music_card_background_audio', 'audio')
+      this.playbackSession = session
+      Logger.info(TAG, `[MusicCast] playbackSession created sessionId=${session.sessionId}`)
+      try {
+        await session.activate()
+        Logger.info(TAG, '[MusicCast] playbackSession activated')
+      } catch (error) {
+        const err = error as BusinessError
+        Logger.error(TAG, `[MusicCast] playbackSession activate failed code=${err.code}, message=${err.message}`)
+      }
+      await this.syncPlaybackSessionMetadata()
+      this.syncPlaybackSessionState(this.isPlaying ? PlayStatus.PLAY : PlayStatus.PAUSE)
+    } catch (error) {
+      const err = error as BusinessError
+      Logger.error(TAG, `[MusicCast] playbackSession create failed code=${err.code}, message=${err.message}`)
+    } finally {
+      this.creatingPlaybackSession = false
+    }
+  }
+
+  private async syncPlaybackSessionMetadata(): Promise<void> {
+    if (!this.playbackSession || !this.currentSong) {
+      return
+    }
+    try {
+      let metadata: avSession.AVMetadata = {
+        assetId: this.currentSong.filePath ?? this.currentSong.name ?? '',
+        title: this.currentSong.name,
+        artist: this.currentSong.artist,
+        duration: this.resolveDurationMs()
+      }
+      await this.playbackSession.setAVMetadata(metadata)
+      Logger.info(TAG,
+        `[MusicCast] playbackSession metadata updated title=${this.currentSong.name}, duration=${metadata.duration ?? 0}`)
+    } catch (error) {
+      const err = error as BusinessError
+      Logger.error(TAG, `[MusicCast] playbackSession metadata failed code=${err.code}, message=${err.message}`)
+    }
+  }
+
+  private syncPlaybackSessionState(status: number): void {
+    if (!this.playbackSession || !this.player) {
+      return
+    }
+    let state: avSession.PlaybackState = avSession.PlaybackState.PLAYBACK_STATE_INITIAL
+    if (status === PlayStatus.PLAY) {
+      state = avSession.PlaybackState.PLAYBACK_STATE_PLAY
+    } else if (status === PlayStatus.PAUSE) {
+      state = avSession.PlaybackState.PLAYBACK_STATE_PAUSE
+    } else if (status === PlayStatus.INIT) {
+      state = avSession.PlaybackState.PLAYBACK_STATE_STOP
+    }
+    let playbackState: avSession.AVPlaybackState = {
+      state: state,
+      position: {
+        elapsedTime: Math.max(0, this.player.getCurrentPosition()),
+        updateTime: Date.now()
+      }
+    }
+    this.playbackSession.setAVPlaybackState(playbackState).then(() => {
+      Logger.info(TAG,
+        `[MusicCast] playbackSession state=${state}, position=${playbackState.position?.elapsedTime ?? 0}`)
+    }).catch((error: BusinessError) => {
+      Logger.error(TAG, `[MusicCast] playbackSession state failed code=${error.code}, message=${error.message}`)
+    })
+  }
+
+  private handleAudioInterrupt(player: IjkMediaPlayer, event: InterruptEvent): void {
+    Logger.info(TAG,
+      `[MusicCast] audioInterrupt forceType=${event.forceType ?? -1}, hintType=${event.hintType ?? -1}, ` +
+      `isPlaying=${player.isPlaying()}, position=${player.getCurrentPosition()}`)
+    switch (event.hintType) {
+      case InterruptHintType.INTERRUPT_HINT_PAUSE:
+      case InterruptHintType.INTERRUPT_HINT_STOP:
+        this.isPlaying = false
+        this.stopProgressTimer()
+        this.syncHostStorage(PlayStatus.PAUSE)
+        this.syncPlaybackSessionState(PlayStatus.PAUSE)
+        this.publishSnapshot(false)
+        break
+      case InterruptHintType.INTERRUPT_HINT_RESUME:
+        if (this.isPrepared && !player.isPlaying()) {
+          player.start()
+          this.isPlaying = true
+          this.startProgressTimer()
+          this.syncHostStorage(PlayStatus.PLAY)
+          this.syncPlaybackSessionState(PlayStatus.PLAY)
+          this.publishSnapshot(true)
+        }
+        break
+      default:
+        break
+    }
+  }
+
+  private publishSnapshot(isPlaying: boolean): void {
+    if (!this.context) {
+      return
+    }
+    const options = new MusicCardPlaybackStateOptions()
+    options.currentSong = this.currentSong
+    options.isPlaying = isPlaying
+    options.positionMs = this.player ? Math.max(0, this.player.getCurrentPosition()) : 0
+    options.durationMs = this.resolveDurationMs()
+    options.lyricText = this.currentSong?.lyricContent
+    options.coverPath = this.currentSong?.pixelMapPath
+    MusicCardManager.getInstance().notifyPlaybackStateChanged(
+      this.context,
+      MusicCardManager.getInstance().buildSnapshotFromPlaybackState(options)
+    )
+  }
+
+  private resolveDurationMs(): number {
+    if (this.player) {
+      const playerDuration = this.player.getDuration()
+      if (playerDuration > 0) {
+        return playerDuration
+      }
+    }
+    return this.parseDurationText(this.currentSong?.duration)
+  }
+
+  private parseDurationText(durationText: string | undefined): number {
+    if (!durationText || durationText === '') {
+      return 0
+    }
+    const parts = durationText.split(':')
+    if (parts.length < 2) {
+      return 0
+    }
+    const numbers = parts.map((part: string): number => Number.parseInt(part, 10))
+    if (numbers.some((value: number): boolean => Number.isNaN(value))) {
+      return 0
+    }
+    if (numbers.length === 2) {
+      return (numbers[0] * 60 + numbers[1]) * 1000
+    }
+    return (numbers[0] * 3600 + numbers[1] * 60 + numbers[2]) * 1000
+  }
+
+  private persistCurrentQueue(): void {
+    PreferencesUtil.putSync('LastMusicInfo', this.currentSong)
+    PreferencesUtil.putSync('LastMusicList', this.queue)
+    PreferencesUtil.putSync('LastPlayModeType', this.playType)
+    Logger.info(TAG,
+      `[MusicCast] persist queueLength=${this.queue.length}, currentIndex=${this.currentIndex}, ` +
+      `currentSong=${this.currentSong?.name ?? ''}, path=${this.currentSong?.filePath ?? ''}, playType=${this.playType}`)
+  }
+
+  private syncHostStorage(status: PlayStatus): void {
+    AppStorage.setOrCreate('CONTROL_PlayStatus', status)
+    AppStorage.setOrCreate('currentSong', this.currentSong)
+    AppStorage.setOrCreate('cover', this.currentSong?.pixelMapPath ?? '')
+    Logger.info(TAG,
+      `[MusicCast] syncHostStorage status=${status}, currentSong=${this.currentSong?.name ?? ''}, ` +
+      `cover=${this.currentSong?.pixelMapPath ?? ''}`)
+  }
+}

+ 106 - 0
entry/src/ohosTest/ets/test/BackgroundAudioPlaybackHostRuntime.test.ets

@@ -0,0 +1,106 @@
+import { describe, expect, it } from '@ohos/hypium'
+import { BackgroundAudioPlaybackHost } from '../../../main/ets/playback/BackgroundAudioPlaybackHost'
+import { MusicCardActionConstants } from '../../../main/ets/common/player/MusicCardActionConstants'
+import { VideoItem } from '../../../main/ets/viewmodel/VideoItem'
+
+type BackgroundAudioPlaybackHostForTest = BackgroundAudioPlaybackHost & {
+  playQueue: (queue: VideoItem[], startIndex: number, source: string, playType?: number) => Promise<void>
+  playIndex: (index: number) => Promise<boolean>
+  persistCurrentQueue: () => void
+  handleControlAction: (action: string, seekPositionMs?: string) => Promise<boolean>
+  queue: VideoItem[]
+  currentIndex: number
+  currentSong: VideoItem | undefined
+  playType: number
+}
+
+export default function backgroundAudioPlaybackHostRuntimeTest() {
+  describe('BackgroundAudioPlaybackHostRuntimeTest', () => {
+    it('getRuntimePlayQueueDelegatesToHostPlayQueue', 0, async () => {
+      const host = BackgroundAudioPlaybackHost.getInstance() as unknown as BackgroundAudioPlaybackHostForTest
+      const originalPlayQueue = host.playQueue
+      const song = new VideoItem('Song A', '1', '/music/a.flac', 0, 0, '2026-04-04')
+      const calls: string[] = []
+
+      host.playQueue = async (queue: VideoItem[], startIndex: number, source: string, playType?: number): Promise<void> => {
+        calls.push(`${queue.length}:${startIndex}:${source}:${playType ?? -1}`)
+      }
+
+      try {
+        await host.getRuntime().playQueue([song], 0, 'runtime-test', 4)
+      } finally {
+        host.playQueue = originalPlayQueue
+      }
+
+      expect(calls.join(',')).assertEqual('1:0:runtime-test:4')
+    })
+
+    it('playQueueUpdatesQueueStateAndStartsAtSafeIndex', 0, async () => {
+      const host = BackgroundAudioPlaybackHost.getInstance() as unknown as BackgroundAudioPlaybackHostForTest
+      const songA = new VideoItem('Song A', '1', '/music/a.flac', 0, 0, '2026-04-04')
+      const songB = new VideoItem('Song B', '2', '/music/b.flac', 0, 0, '2026-04-04')
+      const songC = new VideoItem('Song C', '3', '/music/c.flac', 0, 0, '2026-04-04')
+
+      const originalPlayIndex = host.playIndex
+      const originalPersistCurrentQueue = host.persistCurrentQueue
+      const originalQueue = host.queue
+      const originalCurrentIndex = host.currentIndex
+      const originalCurrentSong = host.currentSong
+      const originalPlayType = host.playType
+
+      let capturedIndex = -1
+      let persistCalled = false
+      host.playIndex = async (index: number): Promise<boolean> => {
+        capturedIndex = index
+        return true
+      }
+      host.persistCurrentQueue = (): void => {
+        persistCalled = true
+      }
+
+      try {
+        await host.playQueue([songA, songB, songC], 99, 'coordinator', 3)
+        expect(persistCalled).assertEqual(true)
+        expect(capturedIndex).assertEqual(2)
+        expect(host.currentIndex).assertEqual(2)
+        expect(host.currentSong?.filePath ?? '').assertEqual('/music/c.flac')
+        expect(host.playType).assertEqual(3)
+      } finally {
+        host.playIndex = originalPlayIndex
+        host.persistCurrentQueue = originalPersistCurrentQueue
+        host.queue = originalQueue
+        host.currentIndex = originalCurrentIndex
+        host.currentSong = originalCurrentSong
+        host.playType = originalPlayType
+      }
+    })
+
+    it('runtimeMethodsDelegateToControlActions', 0, async () => {
+      const host = BackgroundAudioPlaybackHost.getInstance() as unknown as BackgroundAudioPlaybackHostForTest
+      const originalHandleControlAction = host.handleControlAction
+      const calls: string[] = []
+
+      host.handleControlAction = async (action: string, seekPositionMs: string = ''): Promise<boolean> => {
+        calls.push(`${action}:${seekPositionMs}`)
+        return true
+      }
+
+      try {
+        const runtime = host.getRuntime()
+        await runtime.playOrPause()
+        await runtime.playNext()
+        await runtime.playPrevious()
+        await runtime.seekTo('1234', 'slider')
+      } finally {
+        host.handleControlAction = originalHandleControlAction
+      }
+
+      expect(calls.join(',')).assertEqual(
+        `${MusicCardActionConstants.ACTION_PLAY_PAUSE}:,` +
+          `${MusicCardActionConstants.ACTION_NEXT}:,` +
+          `${MusicCardActionConstants.ACTION_PREVIOUS}:,` +
+          `${MusicCardActionConstants.ACTION_SEEK_TO}:1234`
+      )
+    })
+  })
+}

+ 16 - 0
entry/src/ohosTest/ets/test/List.test.ets

@@ -22,6 +22,14 @@ import playbackStateBridgeTest from './PlaybackStateBridge.test'
 import remoteMusicViewModeHelperTest from './RemoteMusicViewModeHelper.test'
 import remoteMusicViewModeHelperTest from './RemoteMusicViewModeHelper.test'
 import musicCardSnapshotTest from './MusicCardSnapshot.test'
 import musicCardSnapshotTest from './MusicCardSnapshot.test'
 import musicCardManagerTest from './MusicCardManager.test'
 import musicCardManagerTest from './MusicCardManager.test'
+import musicPlayerWidgetHelperTest from './MusicPlayerWidgetHelper.test'
+import musicCardActionRouteHelperTest from './MusicCardActionRouteHelper.test'
+import playbackPendingActionCoordinatorTest from './PlaybackPendingActionCoordinator.test'
+import playbackRestoreCoordinatorTest from './PlaybackRestoreCoordinator.test'
+import playbackRuntimeRegistryTest from './PlaybackRuntimeRegistry.test'
+import backgroundAudioPlaybackHostHelperTest from './BackgroundAudioPlaybackHostHelper.test'
+import backgroundAudioPlaybackHostRuntimeTest from './BackgroundAudioPlaybackHostRuntime.test'
+import playbackCoordinatorBindingTest from './PlaybackCoordinatorBinding.test'
 
 
 export default function testsuite() {
 export default function testsuite() {
   abilityTest()
   abilityTest()
@@ -33,4 +41,12 @@ export default function testsuite() {
   remoteMusicViewModeHelperTest()
   remoteMusicViewModeHelperTest()
   musicCardSnapshotTest()
   musicCardSnapshotTest()
   musicCardManagerTest()
   musicCardManagerTest()
+  musicPlayerWidgetHelperTest()
+  musicCardActionRouteHelperTest()
+  playbackPendingActionCoordinatorTest()
+  playbackRestoreCoordinatorTest()
+  playbackRuntimeRegistryTest()
+  backgroundAudioPlaybackHostHelperTest()
+  backgroundAudioPlaybackHostRuntimeTest()
+  playbackCoordinatorBindingTest()
 }
 }

+ 93 - 0
entry/src/ohosTest/ets/test/PlaybackCoordinatorBinding.test.ets

@@ -0,0 +1,93 @@
+import { describe, expect, it } from '@ohos/hypium'
+import { PlaybackCoordinator, PlaybackRuntime } from '../../../main/ets/controller/PlaybackCoordinator'
+
+export default function playbackCoordinatorBindingTest() {
+  describe('PlaybackCoordinatorBindingTest', () => {
+    it('defaultRuntimeActsAsFallbackButDoesNotCountAsRegisteredRuntime', 0, async () => {
+      const coordinator = PlaybackCoordinator.getInstance()
+      const calls: string[] = []
+      const defaultRuntime: PlaybackRuntime = {
+        playQueue: async () => {},
+        playOrPause: async () => {
+          calls.push('default')
+        },
+        playNext: async () => {},
+        playPrevious: async () => {},
+        seekTo: async () => {}
+      }
+
+      coordinator.clearRuntime()
+      coordinator.clearDefaultRuntime()
+      coordinator.bindDefaultRuntime(defaultRuntime)
+      expect(coordinator.hasRuntime()).assertFalse()
+      await coordinator.playOrPause()
+
+      expect(calls.join(',')).assertEqual('default')
+      coordinator.clearRuntime()
+      coordinator.clearDefaultRuntime()
+    })
+
+    it('clearDefaultRuntimeRemovesFallbackDispatch', 0, async () => {
+      const coordinator = PlaybackCoordinator.getInstance()
+      let playOrPauseCount = 0
+      const defaultRuntime: PlaybackRuntime = {
+        playQueue: async () => {},
+        playOrPause: async () => {
+          playOrPauseCount++
+        },
+        playNext: async () => {},
+        playPrevious: async () => {},
+        seekTo: async () => {}
+      }
+
+      coordinator.clearRuntime()
+      coordinator.clearDefaultRuntime()
+      coordinator.bindDefaultRuntime(defaultRuntime)
+      coordinator.clearDefaultRuntime(defaultRuntime)
+      expect(coordinator.hasRuntime()).assertFalse()
+      await coordinator.playOrPause()
+
+      expect(playOrPauseCount).assertEqual(0)
+      coordinator.clearRuntime()
+      coordinator.clearDefaultRuntime()
+    })
+
+    it('explicitRuntimeOverridesDefaultAndClearingRestoresDefault', 0, async () => {
+      const coordinator = PlaybackCoordinator.getInstance()
+      const calls: string[] = []
+      const defaultRuntime: PlaybackRuntime = {
+        playQueue: async () => {},
+        playOrPause: async () => {
+          calls.push('default')
+        },
+        playNext: async () => {},
+        playPrevious: async () => {},
+        seekTo: async () => {}
+      }
+      const explicitRuntime: PlaybackRuntime = {
+        playQueue: async () => {},
+        playOrPause: async () => {
+          calls.push('explicit')
+        },
+        playNext: async () => {},
+        playPrevious: async () => {},
+        seekTo: async () => {}
+      }
+
+      coordinator.clearRuntime()
+      coordinator.clearDefaultRuntime()
+      coordinator.bindDefaultRuntime(defaultRuntime)
+      expect(coordinator.hasRuntime()).assertFalse()
+      coordinator.setRuntime(explicitRuntime)
+      expect(coordinator.hasRuntime()).assertTrue()
+      await coordinator.playOrPause()
+      coordinator.clearRuntime()
+      expect(coordinator.hasRuntime()).assertFalse()
+      await coordinator.playOrPause()
+
+      expect(calls.join(',')).assertEqual('explicit,default')
+      coordinator.clearRuntime()
+      coordinator.clearDefaultRuntime()
+    })
+  })
+}