Prechádzať zdrojové kódy

refactor(player): 让页面消费后台宿主快照

Codex 4 mesiacov pred
rodič
commit
4cc8bc13c1

+ 57 - 3
entry/src/main/ets/playback/BackgroundAudioPlaybackHost.ets

@@ -23,6 +23,7 @@ import { setVideoUrlForSong } from '../common/util/RemotePlayerUtil'
 import { MusicPlaybackController } from '../controller/MusicPlaybackController'
 import { PlaybackRuntime } from '../controller/PlaybackCoordinator'
 import { VideoItem } from '../viewmodel/VideoItem'
+import { PlaybackSnapshotStore } from './PlaybackSnapshotStore'
 import {
   BackgroundAudioControlDecision,
   BackgroundAudioControlKind,
@@ -30,6 +31,7 @@ import {
   BackgroundAudioPlaybackHostHelper,
   BackgroundAudioRecoveredQueue
 } from './BackgroundAudioPlaybackHostHelper'
+import { PlaybackSnapshot, PlaybackSnapshotSong, resolvePlaybackSnapshotProgressValue } from './model/PlaybackSnapshot'
 
 const TAG = 'BackgroundAudioHost'
 const PLAYER_ID = 'audioIjkId'
@@ -37,6 +39,10 @@ const PROGRESS_INTERVAL_MS = 1000
 const PLAYBACK_VERIFY_DELAY_MS = 200
 const PLAYBACK_VERIFY_RETRY_DELAY_MS = 350
 
+interface PlaybackSnapshotWriter {
+  write(snapshot: PlaybackSnapshot): void
+}
+
 export class BackgroundAudioPlaybackHost {
   private static instance: BackgroundAudioPlaybackHost
 
@@ -53,6 +59,7 @@ export class BackgroundAudioPlaybackHost {
   private playbackStartVerifyToken: number = 0
   private playbackSession: avSession.AVSession | undefined = undefined
   private creatingPlaybackSession: boolean = false
+  private snapshotStore: PlaybackSnapshotWriter = new PlaybackSnapshotStore()
   private readonly runtime: PlaybackRuntime = {
     playQueue: async (queue: VideoItem[], startIndex: number, source: string, playType?: number): Promise<void> => {
       await this.playQueue(queue, startIndex, source, playType)
@@ -182,7 +189,7 @@ export class BackgroundAudioPlaybackHost {
     const persistedState: BackgroundAudioPersistedState = BackgroundAudioPlaybackHostHelper.readPersistedState(
       () => PreferencesUtil.getSync('LastMusicList', []) as VideoItem[],
       () => PreferencesUtil.getSync('LastMusicInfo', undefined) as VideoItem | undefined,
-      () => PreferencesUtil.getNumberSync('musicPlayType', 0)
+      () => PreferencesUtil.getNumberSync('LastPlayModeType', PreferencesUtil.getNumberSync('musicPlayType', 0))
     )
     if (persistedState.errorMessage) {
       Logger.error(TAG, `[MusicCast] restore persisted read failed: ${persistedState.errorMessage}`)
@@ -207,6 +214,7 @@ export class BackgroundAudioPlaybackHost {
       `[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)
+    this.persistPlaybackSnapshot(this.isPlaying)
   }
 
   private async playIndex(index: number): Promise<boolean> {
@@ -262,7 +270,7 @@ export class BackgroundAudioPlaybackHost {
       player.prepareAsync()
       player.start()
       Logger.info(TAG, `[MusicCast] prepare issued start song=${this.currentSong.name}, playerId=${PLAYER_ID}`)
-      this.syncHostStorage(PlayStatus.PLAY)
+      this.syncHostStorage(PlayStatus.PAUSE)
       this.publishSnapshot(false)
       return true
     } catch (error) {
@@ -452,6 +460,7 @@ export class BackgroundAudioPlaybackHost {
     }
     const durationMs = this.resolveDurationMs()
     const positionMs = Math.max(0, this.player.getCurrentPosition())
+    const progressValue = resolvePlaybackSnapshotProgressValue(positionMs, durationMs, 100)
     Logger.info(TAG,
       `[MusicCast] progress position=${positionMs}, duration=${durationMs}, isPlaying=${this.player.isPlaying()}, ` +
       `audioSessionId=${this.player.getAudioSessionId()}`)
@@ -464,8 +473,9 @@ export class BackgroundAudioPlaybackHost {
       this.currentSong.lyricContent,
       this.currentSong.pixelMapPath
     )
-    AppStorage.setOrCreate('progressValue', positionMs)
+    AppStorage.setOrCreate('progressValue', progressValue)
     this.syncPlaybackSessionState(this.isPlaying ? PlayStatus.PLAY : PlayStatus.PAUSE)
+    this.persistPlaybackSnapshot(this.isPlaying)
   }
 
   private async ensurePlaybackSession(): Promise<void> {
@@ -576,6 +586,7 @@ export class BackgroundAudioPlaybackHost {
   }
 
   private publishSnapshot(isPlaying: boolean): void {
+    this.persistPlaybackSnapshot(isPlaying)
     if (!this.context) {
       return
     }
@@ -592,6 +603,42 @@ export class BackgroundAudioPlaybackHost {
     )
   }
 
+  private persistPlaybackSnapshot(isPlaying: boolean): void {
+    this.snapshotStore.write(this.buildPlaybackSnapshot(isPlaying))
+  }
+
+  private buildPlaybackSnapshot(isPlaying: boolean): PlaybackSnapshot {
+    return {
+      queue: this.queue.map((item: VideoItem): PlaybackSnapshotSong => {
+        return {
+          filePath: item.filePath,
+          id: item.id,
+          type: item.type,
+          name: item.name,
+          artist: item.artist,
+          album: item.album,
+          duration: item.duration,
+          pixelMapPath: item.pixelMapPath,
+          lyricContent: item.lyricContent,
+          remote_rel_path: item.remote_rel_path,
+          webdav_account_id: item.webdav_account_id
+        }
+      }),
+      currentIndex: this.currentIndex,
+      currentSongKey: this.currentSong?.filePath ?? '',
+      currentSongName: this.currentSong?.name ?? '',
+      currentArtist: this.currentSong?.artist,
+      isPlaying,
+      positionMs: this.player ? Math.max(0, this.player.getCurrentPosition()) : 0,
+      durationMs: this.resolveDurationMs(),
+      cover: this.currentSong?.pixelMapPath,
+      playType: this.playType,
+      playlistContext: 'host_runtime',
+      updatedAt: Date.now(),
+      shouldResumeWhenActivated: isPlaying || this.isPrepared
+    }
+  }
+
   private resolveDurationMs(): number {
     if (this.player) {
       const playerDuration = this.player.getDuration()
@@ -631,8 +678,15 @@ export class BackgroundAudioPlaybackHost {
 
   private syncHostStorage(status: PlayStatus): void {
     AppStorage.setOrCreate('CONTROL_PlayStatus', status)
+    AppStorage.setOrCreate('songList', this.queue)
+    AppStorage.setOrCreate('currIndex', this.currentIndex)
     AppStorage.setOrCreate('currentSong', this.currentSong)
+    AppStorage.setOrCreate('progressValue',
+      resolvePlaybackSnapshotProgressValue(this.player ? Math.max(0, this.player.getCurrentPosition()) : 0,
+        this.resolveDurationMs(), 100))
     AppStorage.setOrCreate('cover', this.currentSong?.pixelMapPath ?? '')
+    AppStorage.setOrCreate('currentSongName', this.currentSong?.name ?? '')
+    AppStorage.setOrCreate('currentSongArtist', this.currentSong?.artist ?? '')
     Logger.info(TAG,
       `[MusicCast] syncHostStorage status=${status}, currentSong=${this.currentSong?.name ?? ''}, ` +
       `cover=${this.currentSong?.pixelMapPath ?? ''}`)

+ 95 - 2
entry/src/main/ets/playback/PlaybackSnapshotStore.ets

@@ -1,8 +1,15 @@
 import { PreferencesUtil } from '@pura/harmony-utils'
-import { PlaybackSnapshot } from './model/PlaybackSnapshot'
+import { VideoItem } from '../viewmodel/VideoItem'
+import { PlaybackSnapshot, PlaybackSnapshotSong } from './model/PlaybackSnapshot'
 
 export const PLAYBACK_SNAPSHOT_PREFERENCES_KEY = 'playback.host.snapshot'
 
+export interface RecoveredPlaybackSnapshotState {
+  queue: VideoItem[]
+  currentIndex: number
+  currentSong: VideoItem | undefined
+}
+
 export class PlaybackSnapshotStore {
   public write(snapshot: PlaybackSnapshot): void {
     PreferencesUtil.putSync(PLAYBACK_SNAPSHOT_PREFERENCES_KEY, JSON.stringify(snapshot))
@@ -14,7 +21,12 @@ export class PlaybackSnapshotStore {
       return undefined
     }
     try {
-      return JSON.parse(text) as PlaybackSnapshot
+      const parsedSnapshot = JSON.parse(text) as Object | null
+      if (!this.isValidSnapshot(parsedSnapshot)) {
+        this.clear()
+        return undefined
+      }
+      return parsedSnapshot
     } catch (_error) {
       this.clear()
       return undefined
@@ -24,4 +36,85 @@ export class PlaybackSnapshotStore {
   public clear(): void {
     PreferencesUtil.putSync(PLAYBACK_SNAPSHOT_PREFERENCES_KEY, '')
   }
+
+  public restoreQueue(snapshot: PlaybackSnapshot): VideoItem[] {
+    return this.recoverQueueState(snapshot).queue
+  }
+
+  public restoreCurrentSong(snapshot: PlaybackSnapshot): VideoItem | undefined {
+    return this.recoverQueueState(snapshot).currentSong
+  }
+
+  public recoverQueueState(snapshot: PlaybackSnapshot): RecoveredPlaybackSnapshotState {
+    const snapshotQueue = Array.isArray(snapshot.queue) ? snapshot.queue : []
+    const queue: VideoItem[] = []
+    for (let index = 0; index < snapshotQueue.length; index++) {
+      const snapshotSong = snapshotQueue[index] as Object | null
+      if (!this.isValidSnapshotSong(snapshotSong)) {
+        continue
+      }
+      queue.push(this.buildVideoItem(snapshotSong))
+    }
+    const currentIndex = this.resolveCurrentIndex(snapshot, queue)
+    const currentSong = currentIndex >= 0 ? queue[currentIndex] : undefined
+    return {
+      queue,
+      currentIndex,
+      currentSong
+    }
+  }
+
+  private resolveCurrentIndex(snapshot: PlaybackSnapshot, queue: VideoItem[]): number {
+    if (queue.length <= 0) {
+      return -1
+    }
+    if (snapshot.currentSongKey && snapshot.currentSongKey !== '') {
+      const matchedIndex = queue.findIndex((item: VideoItem): boolean => item.filePath === snapshot.currentSongKey)
+      if (matchedIndex >= 0) {
+        return matchedIndex
+      }
+    }
+    const requestedIndex = typeof snapshot.currentIndex === 'number' ? snapshot.currentIndex : 0
+    return Math.max(0, Math.min(requestedIndex, queue.length - 1))
+  }
+
+  private isValidSnapshot(snapshot: Object | null): snapshot is PlaybackSnapshot {
+    if (!snapshot || Array.isArray(snapshot)) {
+      return false
+    }
+    const candidate = snapshot as PlaybackSnapshot
+    return Array.isArray(candidate.queue) &&
+      typeof candidate.currentIndex === 'number' &&
+      typeof candidate.currentSongKey === 'string' &&
+      typeof candidate.currentSongName === 'string' &&
+      typeof candidate.isPlaying === 'boolean' &&
+      typeof candidate.positionMs === 'number' &&
+      typeof candidate.durationMs === 'number' &&
+      typeof candidate.playType === 'number' &&
+      typeof candidate.playlistContext === 'string' &&
+      typeof candidate.updatedAt === 'number' &&
+      typeof candidate.shouldResumeWhenActivated === 'boolean'
+  }
+
+  private isValidSnapshotSong(snapshotSong: Object | null): snapshotSong is PlaybackSnapshotSong {
+    if (!snapshotSong || Array.isArray(snapshotSong)) {
+      return false
+    }
+    const candidate = snapshotSong as PlaybackSnapshotSong
+    return typeof candidate.filePath === 'string' &&
+      typeof candidate.name === 'string' &&
+      typeof candidate.type === 'number'
+  }
+
+  private buildVideoItem(song: PlaybackSnapshotSong): VideoItem {
+    const item = new VideoItem(song.name, song.id ?? '', song.filePath, song.type, 0, '')
+    item.artist = song.artist
+    item.album = song.album
+    item.duration = song.duration
+    item.pixelMapPath = song.pixelMapPath
+    item.lyricContent = song.lyricContent
+    item.remote_rel_path = song.remote_rel_path
+    item.webdav_account_id = song.webdav_account_id
+    return item
+  }
 }

+ 17 - 0
entry/src/main/ets/playback/model/PlaybackSnapshot.ets

@@ -4,6 +4,10 @@ export interface PlaybackSnapshotSong {
   type: number
   name: string
   artist?: string
+  album?: string
+  duration?: string
+  pixelMapPath?: string
+  lyricContent?: string
   remote_rel_path?: string
   webdav_account_id?: string
 }
@@ -12,7 +16,12 @@ export interface PlaybackSnapshot {
   queue: PlaybackSnapshotSong[]
   currentIndex: number
   currentSongKey: string
+  currentSongName: string
+  currentArtist?: string
+  isPlaying: boolean
   positionMs: number
+  durationMs: number
+  cover?: string
   playType: number
   playlistContext: string
   updatedAt: number
@@ -25,3 +34,11 @@ export interface PendingPlaybackActivation {
   actionType: PlaybackActivationActionType
   requestedAt: number
 }
+
+export function resolvePlaybackSnapshotProgressValue(positionMs: number, durationMs: number, maxValue: number): number {
+  if (durationMs <= 0 || maxValue <= 0) {
+    return 0
+  }
+  const clampedPositionMs = Math.max(0, Math.min(positionMs, durationMs))
+  return clampedPositionMs / durationMs * maxValue
+}

+ 173 - 144
entry/src/main/ets/view/LocalMusic.ets

@@ -118,6 +118,8 @@ import { CommonConstants2 } from '../common/util/CommonConstants2';
 import { hilog } from '@kit.PerformanceAnalysisKit';
 import { LazyDataSource } from '../common/util/LazyDataSource';
 import  MediaTable, { PageQueryOptions, PageQueryResult, RandomSongQueryOptions }  from '../common/util/MediaTable';
+import { requestPlaybackPlayerOpen, setPlaybackRuntimeReady } from '../playback/PlaybackHostState';
+import { BackgroundAudioPlaybackHost } from '../playback/BackgroundAudioPlaybackHost'
 import FileDeletionWatcher from '../common/util/FileDeletionWatcher';
 import NetAxiosUtil from '../common/util/NetAxiosUtil';
 import { ringtone } from '@kit.RingtoneKit';
@@ -127,9 +129,13 @@ import { deviceInfo } from '@kit.BasicServicesKit';
 import '../common/network/RemoteCacheRegistry';
 import { shouldWrapLocalMusicMenuInScroll } from '../common/util/LocalMusicMenuScrollHelper';
 import { ServerLogUtil } from '../common/util/ServerLogUtil';
-import { MusicPlaybackController, MusicPlaybackControllerActions } from '../controller/MusicPlaybackController';
-import { PlaybackCoordinator, PlaybackRuntime } from '../controller/PlaybackCoordinator'
-import { PlaybackStateBridge } from '../controller/PlaybackStateBridge'
+import { MusicPlaybackController } from '../controller/MusicPlaybackController';
+import { PlaybackRuntime } from '../controller/PlaybackCoordinator'
+import { PlaybackSnapshot as PlaybackStateBridgeSnapshot, PlaybackStateBridge } from '../controller/PlaybackStateBridge'
+import { clearRegisteredPlaybackRuntime, registerPlaybackRuntime } from '../playback/PlaybackRuntimeRegistry';
+import { consumePendingPlaybackActivation } from '../playback/PlaybackActivationStore';
+import { PlaybackSnapshotStore } from '../playback/PlaybackSnapshotStore'
+import { resolvePlaybackSnapshotProgressValue } from '../playback/model/PlaybackSnapshot'
 import { lyricService, SongData } from '../common/service/LyricService';
 import { shouldResolveRemoteLyricOnPlay, tryResolveRemotePlaybackLyric } from '../common/util/RemotePlaybackLyricUtil';
 import { resolvePlayerDismissMorphTarget } from '../common/util/PlayerDismissHelper';
@@ -501,26 +507,9 @@ export struct LocalMusic {
   private contentNode?: ComponentContent<Object> = undefined;
   private skipNextPlaylistPersist: boolean = false
   private playbackController: MusicPlaybackController = MusicPlaybackController.getInstance()
-  private playbackCoordinator: PlaybackCoordinator = PlaybackCoordinator.getInstance()
   private playbackStateBridge: PlaybackStateBridge = PlaybackStateBridge.getInstance()
+  private playbackSnapshotStore: PlaybackSnapshotStore = new PlaybackSnapshotStore()
   private pendingInternalLoopModeReport: number = -1
-  private readonly playbackControllerActions: MusicPlaybackControllerActions = {
-    playOrPause: async () => {
-      await this.playOrPause()
-    },
-    playNext: async () => {
-      await this.playNext()
-    },
-    playPrevious: async () => {
-      await this.playPrevious()
-    },
-    setLoopMode: () => {
-      this.setLoopMode()
-    },
-    seekTo: async (value: string, source?: string) => {
-      await this.seekTo(value, source ?? 'controller')
-    }
-  }
   private playbackRuntime: PlaybackRuntime = {
     playQueue: async (queue: VideoItem[], startIndex: number, source: string, playType?: number) => {
       const safeIndex = queue.length <= 0 ? -1 : Math.max(0, Math.min(startIndex, queue.length - 1))
@@ -545,7 +534,7 @@ export struct LocalMusic {
       this.currentSong = queue[safeIndex]
       AppStorage.setOrCreate('currentSong', this.currentSong)
       if (source === 'controller-open') {
-        this.setShowPlayTrue()
+        requestPlaybackPlayerOpen()
       }
       await this.doPlay(queue[safeIndex], safeIndex, true)
       this.playbackStateBridge.replaceQueue(this.songList, this.curIndex)
@@ -654,6 +643,10 @@ export struct LocalMusic {
   @State lyricTextWeight: number = 400
   @State lyricTextWeightPip: number = 400
   @State currentSwiperIndex: number = 0
+  @StorageLink('playbackHostOpenRequestToken') @Watch('onPlaybackHostOpenRequestChange')
+  playbackHostOpenRequestToken: number = 0
+  @StorageLink('playbackHostPlaylistRequestToken') @Watch('onPlaybackHostPlaylistRequestChange')
+  playbackHostPlaylistRequestToken: number = 0
   @State blurValue: number = 0 //背景模糊
   @State bgBrightness: number = 0 //背景亮度
   private listScroller: ListScroller = new ListScroller()
@@ -708,12 +701,16 @@ export struct LocalMusic {
   private gridVisibleEnd: number = -1
   private waterVisibleStart: number = -1
   private waterVisibleEnd: number = -1
+  private lastHandledPlaybackHostOpenRequestToken: number = 0
+  private lastHandledPlaybackHostPlaylistRequestToken: number = 0
   private titleBarToggleMinDelta: number = 12
   private titleBarLastCheckTs: number = 0
   @State private coverThumbVersion: number = 0
   private coverThumbCache: CoverThumbCache = new CoverThumbCache(1, 960)
   private coverThumbCacheReady: boolean = false
   private pendingCoverThumbRefresh: boolean = false
+  private playbackSnapshotHydrated: boolean = false
+  private isHostSnapshotDisplayOnly: boolean = false
   private playQueueScope: 'view' | 'history' | 'fav' | 'playlist' | 'single' | 'find_paged_local' = 'view'
   private isHydratingPlayList: boolean = false
   private preserveExistingQueueOnNextPlay: boolean = false
@@ -882,7 +879,7 @@ export struct LocalMusic {
   // @Consume currentSong: VideoItem | undefined;
   @StorageLink('currentSong') @Watch('onHomeBgCurrentSongChange') currentSong: VideoItem | undefined = undefined;
   // @StorageLink('isPlay') isPlay: boolean = false;
-  @Consume isShowPlay: boolean ;
+  @StorageLink('isShowPlay') isShowPlay: boolean = false;
   @State isShowCoverScaleGuide: boolean = false
   @StorageLink('songList') songList: Array<VideoItem> = [];
   @State translateY: number = 0;
@@ -1111,61 +1108,53 @@ export struct LocalMusic {
     })
   }
 
-  private canConsumePendingMusicCardAction(action: string): boolean {
-    if (action === MusicCardActionConstants.ACTION_PLAY_PAUSE) {
-      if (this.CONTROL_PlayStatus === PlayStatus.PAUSE || this.CONTROL_PlayStatus === PlayStatus.PLAY) {
-        return true
-      }
-      return !!this.currentSong && StrUtil.isNotEmpty(this.videoUrl)
+  private onPlaybackHostOpenRequestChange(): void {
+    if (this.playbackHostOpenRequestToken === this.lastHandledPlaybackHostOpenRequestToken) {
+      return
     }
-    if (action === MusicCardActionConstants.ACTION_PREVIOUS || action === MusicCardActionConstants.ACTION_NEXT) {
-      return ArrayUtil.isNotEmpty(this.songList)
+    this.lastHandledPlaybackHostOpenRequestToken = this.playbackHostOpenRequestToken
+    if (!this.isShowPlay) {
+      this.setShowPlayTrue()
     }
-    return true
+    this.showPlayerView()
   }
 
-  private consumePendingMusicCardAction(): boolean {
-    const pendingAction = AppStorage.get(MusicCardActionConstants.PENDING_ACTION_STORAGE_KEY) as string | undefined
-    if (!pendingAction || pendingAction.length === 0) {
-      return false
+  private onPlaybackHostPlaylistRequestChange(): void {
+    if (this.playbackHostPlaylistRequestToken === this.lastHandledPlaybackHostPlaylistRequestToken) {
+      return
     }
-    if (!this.canConsumePendingMusicCardAction(pendingAction)) {
-      Logger.info(TAG, `skip consume pending music card action because playback not ready action=${pendingAction}`)
-      return false
+    this.lastHandledPlaybackHostPlaylistRequestToken = this.playbackHostPlaylistRequestToken
+    this.openPlayList()
+  }
+
+  private consumePendingPlaybackActivationAfterRestore(): void {
+    const activation = consumePendingPlaybackActivation()
+    if (!activation) {
+      return
     }
-    AppStorage.setOrCreate(MusicCardActionConstants.PENDING_ACTION_STORAGE_KEY, '')
-    if (pendingAction === MusicCardActionConstants.ACTION_PLAY_PAUSE) {
+    if (activation.actionType === 'resume_play') {
       void this.playbackController.playOrPause()
-      return true
+      return
     }
-    if (pendingAction === MusicCardActionConstants.ACTION_PREVIOUS) {
+    if (activation.actionType === 'play_previous') {
       void this.playbackController.playPrevious()
-      return true
+      return
     }
-    if (pendingAction === MusicCardActionConstants.ACTION_NEXT) {
+    if (activation.actionType === 'play_next') {
       void this.playbackController.playNext()
-      return true
-    }
-    return false
-  }
-
-  private consumePendingMusicCardOpenRequest(): void {
-    const pendingOpen = AppStorage.get(MusicCardActionConstants.PENDING_OPEN_STORAGE_KEY) as boolean | undefined
-    if (pendingOpen !== true) {
       return
     }
-    AppStorage.setOrCreate(MusicCardActionConstants.PENDING_OPEN_STORAGE_KEY, false)
-    if (!this.isShowPlay) {
-      this.setShowPlayTrue()
+    if (activation.actionType === 'open_player') {
+      requestPlaybackPlayerOpen()
     }
-    this.showPlayerView()
   }
   // 组件生命周期
   aboutToAppear() {
-    this.playbackCoordinator.setRuntime(this.playbackRuntime)
-    this.playbackController.setActions(this.playbackControllerActions)
-    this.consumePendingMusicCardAction()
-    this.consumePendingMusicCardOpenRequest()
+    registerPlaybackRuntime(this.playbackRuntime)
+    setPlaybackRuntimeReady(true)
+    this.playbackSnapshotHydrated = this.hydrateFromPlaybackSnapshot()
+    this.onPlaybackHostOpenRequestChange()
+    this.onPlaybackHostPlaylistRequestChange()
 
     console.info('onecold aboutToAppear sdkApiVersion = '+deviceInfo.sdkApiVersion)
     if(ArrayUtil.isNotEmpty(this.videoLocalList)&&this.modeType==0){
@@ -1392,51 +1381,6 @@ export struct LocalMusic {
         this.syncLyricPositionNow();
       }
     });
-
-    //NewIndex的博空调收到eventHub消息控制播放
-    this.getUIContext().getHostContext()!.eventHub.on('playOrPause', () => {
-      if (ArrayUtil.isNotEmpty(this.songList)) {
-        this.playbackController.playOrPause();
-      } else {
-        ToastUtil.showToast('当前播放列表为空,请先导入音乐。')
-      }
-    });
-    this.getUIContext().getHostContext()!.eventHub.on('playPrevious', () => {
-      if (ArrayUtil.isNotEmpty(this.songList)) {
-        void this.playbackController.playPrevious();
-      } else {
-        ToastUtil.showToast('当前播放列表为空,请先导入音乐。')
-      }
-    });
-    this.getUIContext().getHostContext()!.eventHub.on('playNext', () => {
-      if (ArrayUtil.isNotEmpty(this.songList)) {
-        void this.playbackController.playNext();
-      } else {
-        ToastUtil.showToast('当前播放列表为空,请先导入音乐。')
-      }
-
-    });
-    this.getUIContext().getHostContext()!.eventHub.on('showPlayerView', () => {
-      if (!this.isShowPlay) {
-        this.setShowPlayTrue()
-      }
-      this.showPlayerView();
-    });
-    this.getUIContext().getHostContext()!.eventHub.on('openPlayerViewFromMiniBar', () => {
-      if (ArrayUtil.isEmpty(this.songList)) {
-        ToastUtil.showToast('当前播放列表为空,请先导入音乐。');
-        return
-      }
-      this.setShowPlayTrue()
-      this.showPlayerView();
-    });
-    this.getUIContext().getHostContext()!.eventHub.on('dismissPlayerView', () => {
-      this.setShowPlayFalse()
-    });
-    this.getUIContext().getHostContext()!.eventHub.on('openPlayList', () => {
-      this.openPlayList();
-    });
-
   }
   //载入媒体库,艺术家,专辑等缓存
   initLoadCache(){
@@ -2313,33 +2257,39 @@ export struct LocalMusic {
       // this.loadBannerAd(CSJUtil.getBannerID())
 
       this.restoreLastPlayContext();
-      this.songList = PreferencesUtil.getSync('LastMusicList', []) as Array<VideoItem>
-      this.currentSong = PreferencesUtil.getSync('LastMusicInfo', undefined) as VideoItem
-      if (ArrayUtil.isEmpty(this.songList)) {
-        this.songList = this.videoLocalList.filter(item =>
-        item.type === CommonConstants.TYPE_LOCAL && !this.isIsoLocalFile(item))
-      } else {
-        this.curIndex = Utility.getIndexFromList(this.songList, this.currentSong.filePath)
-      }
-      if (ArrayUtil.isNotEmpty(this.songList)) {
-        this.isFirstStartPlay = true
-        this.sonDataSource.pushArrayData(this.songList)
-        if (this.currentSong === undefined) {
-          this.isFirstStartPlay = false
-          this.currentSong = this.songList[0]
+      const hydratedFromSnapshot = this.playbackSnapshotHydrated || this.hydrateFromPlaybackSnapshot()
+      if (!hydratedFromSnapshot) {
+        this.songList = PreferencesUtil.getSync('LastMusicList', []) as Array<VideoItem>
+        this.currentSong = PreferencesUtil.getSync('LastMusicInfo', undefined) as VideoItem
+        if (ArrayUtil.isEmpty(this.songList)) {
+          this.songList = this.videoLocalList.filter(item =>
+          item.type === CommonConstants.TYPE_LOCAL && !this.isIsoLocalFile(item))
+        } else if (this.currentSong !== undefined) {
+          this.curIndex = Utility.getIndexFromList(this.songList, this.currentSong.filePath)
+        } else {
+          this.curIndex = 0
         }
-        const restoredUrl = await this.resolvePlaybackUrlForCurrentSong('restore-last-song');
-        if (!restoredUrl) {
-          return;
+        if (ArrayUtil.isNotEmpty(this.songList)) {
+          this.isFirstStartPlay = true
+          this.sonDataSource.pushArrayData(this.songList)
+          if (this.currentSong === undefined) {
+            this.isFirstStartPlay = false
+            this.currentSong = this.songList[0]
+          }
+          const restoredUrl = await this.resolvePlaybackUrlForCurrentSong('restore-last-song');
+          if (!restoredUrl) {
+            return;
+          }
+          this.videoUrl = restoredUrl;
+          Logger.info(TAG, `WebDAV URL同步构建完成: ${this.videoUrl}`);
+          this.name = this.currentSong.name
+          this.cover = this.currentSong.pixelMapPath
+          this.playbackStateBridge.replaceQueue(this.songList, this.curIndex)
+          AppStorage.setOrCreate('currentSong',this.currentSong) ;
+          this.consumePendingPlaybackActivationAfterRestore()
+        } else {
+          this.name = '空空如也'
         }
-        this.videoUrl = restoredUrl;
-        Logger.info(TAG, `WebDAV URL同步构建完成: ${this.videoUrl}`);
-        this.name = this.currentSong.name
-        this.cover = this.currentSong.pixelMapPath
-        AppStorage.setOrCreate('currentSong',this.currentSong) ;
-        this.consumePendingMusicCardAction()
-      } else {
-        this.name = '空空如也'
       }
     })
 
@@ -2375,8 +2325,9 @@ export struct LocalMusic {
   // 组件消失生命周期
   aboutToDisappear() {
     console.info('LifeCycleComponent aboutToDisappear');
-    this.playbackCoordinator.clearRuntime(this.playbackRuntime)
-    this.playbackController.clearActions(this.playbackControllerActions)
+    clearRegisteredPlaybackRuntime(this.playbackRuntime)
+    setPlaybackRuntimeReady(false)
+    this.playbackSnapshotHydrated = false
     this.pendingInternalLoopModeReport = -1
     if (this.alphaBetHideTimer) {
       clearTimeout(this.alphaBetHideTimer);
@@ -2425,9 +2376,10 @@ export struct LocalMusic {
     this.mDestroyPage = true;
     this.audioInterruptResumePending = false;
     this.mIjkMediaPlayer.setScreenOnWhilePlaying(false);
-    if (this.CONTROL_PlayStatus != PlayStatus.INIT) {
+    if (this.CONTROL_PlayStatus != PlayStatus.INIT && !this.isHostSnapshotDisplayOnly) {
       this.stop();
     }
+    this.isHostSnapshotDisplayOnly = false;
     this.mIjkMediaPlayer.off('audioInterrupt');
     this.mIjkMediaPlayer.off('deviceChange');
     if (this.audioRoutingManager && this.outputDeviceChangeCallback) {
@@ -2445,13 +2397,6 @@ export struct LocalMusic {
 
     this.stopPip();
     this.destroyPipController()
-    this.getUIContext().getHostContext()!.eventHub.off('playOrPause');
-    this.getUIContext().getHostContext()!.eventHub.off('playPrevious');
-    this.getUIContext().getHostContext()!.eventHub.off('playNext');
-    this.getUIContext().getHostContext()!.eventHub.off('showPlayerView');
-    this.getUIContext().getHostContext()!.eventHub.off('openPlayerViewFromMiniBar');
-    this.getUIContext().getHostContext()!.eventHub.off('dismissPlayerView');
-    this.getUIContext().getHostContext()!.eventHub.off('openPlayList');
     this.eventHub.off('onStateChange');
   }
 
@@ -2928,6 +2873,67 @@ export struct LocalMusic {
     }
   }
 
+  private hydrateFromPlaybackSnapshot(): boolean {
+    if (this.playbackSnapshotHydrated) {
+      return true
+    }
+    const snapshot = this.playbackSnapshotStore.read()
+    if (!snapshot) {
+      return false
+    }
+    const recovered = this.playbackSnapshotStore.recoverQueueState(snapshot)
+    if (ArrayUtil.isEmpty(recovered.queue) || recovered.currentSong === undefined) {
+      return false
+    }
+
+    this.songList = recovered.queue
+    this.currentSongList = [...recovered.queue]
+    this.curIndex = recovered.currentIndex
+    this.currentSong = recovered.currentSong
+    if (snapshot.cover && StrUtil.isEmpty(this.currentSong.pixelMapPath)) {
+      this.currentSong.pixelMapPath = snapshot.cover
+    }
+    this.playType = snapshot.playType
+    this.isFirstStartPlay = true
+    this.videoUrl = this.currentSong.filePath
+    this.name = snapshot.currentSongName !== '' ? snapshot.currentSongName : this.currentSong.name
+    this.artist = snapshot.currentArtist ?? this.currentSong.artist ?? ''
+    this.cover = snapshot.cover ?? this.currentSong.pixelMapPath
+    this.CONTROL_PlayStatus = snapshot.isPlaying ? PlayStatus.PLAY : PlayStatus.PAUSE
+    this.progressValue = resolvePlaybackSnapshotProgressValue(
+      snapshot.positionMs,
+      snapshot.durationMs,
+      this.PROGRESS_MAX_VALUE
+    )
+    this.slideEnable = snapshot.durationMs > 0
+    this.currentTime = this.stringForTime(snapshot.positionMs)
+    this.totalTime = this.stringForTime(snapshot.durationMs)
+    this.duration = snapshot.durationMs
+    this.durationTime = Math.floor(snapshot.durationMs / 1000)
+    this.sonDataSource.pushArrayData(this.songList)
+    const bridgeSnapshot: PlaybackStateBridgeSnapshot = {
+      queue: this.songList,
+      currentIndex: this.curIndex,
+      currentSong: this.currentSong,
+      isPlaying: snapshot.isPlaying,
+      positionMs: snapshot.positionMs,
+      durationMs: snapshot.durationMs
+    }
+    this.playbackStateBridge.restoreSnapshot(bridgeSnapshot)
+    AppStorage.setOrCreate('songList', this.songList)
+    AppStorage.setOrCreate('currIndex', this.curIndex)
+    AppStorage.setOrCreate('currentSong', this.currentSong)
+    AppStorage.setOrCreate('CONTROL_PlayStatus', this.CONTROL_PlayStatus)
+    AppStorage.setOrCreate('progressValue', this.progressValue)
+    AppStorage.setOrCreate('cover', this.cover ?? '')
+    this.playbackSnapshotHydrated = true
+    this.isHostSnapshotDisplayOnly = true
+    Logger.info(TAG,
+      `从后台宿主快照恢复页面状态 song=${this.currentSong.name}, index=${this.curIndex}, ` +
+      `isPlaying=${snapshot.isPlaying}, position=${snapshot.positionMs}`)
+    return true
+  }
+
   private resetFindPagedLocalQueueState(): void {
     // 退出“随心所欲”分页队列时,避免旧分页状态污染后续普通播放队列。
     this.findPagedLocalQueuePageIndex = 0;
@@ -12955,7 +12961,7 @@ export struct LocalMusic {
     })
   }
 
-  @Consume progressValue: number ;
+  @StorageLink('progressValue') progressValue: number = 0;
   @State currentTime: string = "00:00";
   @State totalTime: string = "00:00";
   @State loadingVisible: Visibility = Visibility.None;
@@ -12978,7 +12984,7 @@ export struct LocalMusic {
   private last: number = 0;
   @State videoParentAspectRatio: number = this.initAspectRatio;
   private mIjkMediaPlayer = IjkMediaPlayer.getInstance();
-  @Consume CONTROL_PlayStatus: number ;
+  @StorageLink('CONTROL_PlayStatus') CONTROL_PlayStatus: number = PlayStatus.INIT;
   @State PROGRESS_MAX_VALUE: number = 100;
   @State updateProgressTimer: number = 0;
   private readonly progressUpdateIntervalMs: number = 120;
@@ -13045,7 +13051,7 @@ export struct LocalMusic {
   // @State isBgPlayOpen:boolean = true   //是否启用后台播放
   @State rotateAngle2: number = -9
   @StorageLink('imageColor') imageColor: string = 'rgba(0, 0, 2, 1.00)';
-  @Consume cover: string | undefined ;
+  @StorageLink('cover') cover: string | undefined = '';
   @State artist: string | undefined = ''
   // 1.初始化controller
   private lyricController: LyricController = new LyricController()
@@ -17239,6 +17245,7 @@ export struct LocalMusic {
 
   //startOffset 从cue分轨时间开始播
   private startPlayOrResumePlay(startOffset?:number) {
+    this.isHostSnapshotDisplayOnly = false
     let finalStartOffset = startOffset
     if ((finalStartOffset === undefined || finalStartOffset === null) && this.currentSong && isCueSplitItem(this.currentSong)) {
       finalStartOffset = 0
@@ -19345,6 +19352,10 @@ export struct LocalMusic {
   };
 
   private async playOrPause() {
+    if (this.isHostSnapshotDisplayOnly) {
+      await BackgroundAudioPlaybackHost.getInstance().getRuntime().playOrPause()
+      return
+    }
     if (!this.debounce()) {
       return;
     }
@@ -19591,6 +19602,12 @@ export struct LocalMusic {
   }
 
   private async pause(clearInterruptResumeFlag: boolean = true) {
+    if (this.isHostSnapshotDisplayOnly) {
+      if (this.CONTROL_PlayStatus === PlayStatus.PLAY) {
+        await BackgroundAudioPlaybackHost.getInstance().getRuntime().playOrPause()
+      }
+      return
+    }
     if (clearInterruptResumeFlag) {
       this.audioInterruptResumePending = false;
     }
@@ -19758,6 +19775,10 @@ export struct LocalMusic {
   }
 
   private async seekTo(value: string, source: string = 'internal') {
+    if (this.isHostSnapshotDisplayOnly) {
+      await BackgroundAudioPlaybackHost.getInstance().getRuntime().seekTo(value, source)
+      return
+    }
     // if (StrUtil.isNotEmpty(this.videoUrl) && this.videoUrl.toLowerCase().endsWith('.wma')) {
     //   ToastUtil.showToast('wma格式不支持拖动快进。')
     //   return
@@ -19924,6 +19945,10 @@ export struct LocalMusic {
 
   //下一个
   private async playNext() {
+    if (this.isHostSnapshotDisplayOnly) {
+      await BackgroundAudioPlaybackHost.getInstance().getRuntime().playNext()
+      return
+    }
     this.lyricController.setLyric(null)
     this.lyricControllerXF.setLyric(null)
     this.lyricControllerSingle.setLyric(null)
@@ -20331,6 +20356,10 @@ export struct LocalMusic {
 
   //上一个
   private async playPrevious() {
+    if (this.isHostSnapshotDisplayOnly) {
+      await BackgroundAudioPlaybackHost.getInstance().getRuntime().playPrevious()
+      return
+    }
 
     if (!this.debounce()) {
       return;

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

@@ -1,17 +1,31 @@
 import { describe, expect, it } from '@ohos/hypium'
 import { BackgroundAudioPlaybackHost } from '../../../main/ets/playback/BackgroundAudioPlaybackHost'
 import { MusicCardActionConstants } from '../../../main/ets/common/player/MusicCardActionConstants'
+import {
+  PlaybackSnapshot,
+  PlaybackSnapshotSong,
+  resolvePlaybackSnapshotProgressValue
+} from '../../../main/ets/playback/model/PlaybackSnapshot'
+import { PlaybackSnapshotStore } from '../../../main/ets/playback/PlaybackSnapshotStore'
 import { VideoItem } from '../../../main/ets/viewmodel/VideoItem'
 
+type PlaybackSnapshotStoreForTest = {
+  write: (snapshot: PlaybackSnapshot) => void
+}
+
 type BackgroundAudioPlaybackHostForTest = BackgroundAudioPlaybackHost & {
   playQueue: (queue: VideoItem[], startIndex: number, source: string, playType?: number) => Promise<void>
   playIndex: (index: number) => Promise<boolean>
   persistCurrentQueue: () => void
+  persistPlaybackSnapshot: (isPlaying: boolean) => void
+  buildPlaybackSnapshot: (isPlaying: boolean) => PlaybackSnapshot
   handleControlAction: (action: string, seekPositionMs?: string) => Promise<boolean>
+  snapshotStore: PlaybackSnapshotStoreForTest
   queue: VideoItem[]
   currentIndex: number
   currentSong: VideoItem | undefined
   playType: number
+  player: { getCurrentPosition: () => number; getDuration: () => number } | undefined
 }
 
 export default function backgroundAudioPlaybackHostRuntimeTest() {
@@ -102,5 +116,140 @@ export default function backgroundAudioPlaybackHostRuntimeTest() {
           `${MusicCardActionConstants.ACTION_SEEK_TO}:1234`
       )
     })
+
+    it('persistPlaybackSnapshotWritesHydrationFieldsForPage', 0, () => {
+      const host = BackgroundAudioPlaybackHost.getInstance() as unknown as BackgroundAudioPlaybackHostForTest
+      const songA = new VideoItem('Song A', '1', '/music/a.flac', 0, 0, '2026-04-04')
+      songA.artist = 'Artist A'
+      songA.pixelMapPath = '/cover/a.png'
+      songA.duration = '03:21'
+      const songB = new VideoItem('Song B', '2', '/music/b.flac', 0, 0, '2026-04-04')
+      songB.artist = 'Artist B'
+      songB.pixelMapPath = '/cover/b.png'
+      songB.duration = '04:56'
+      const originalQueue = host.queue
+      const originalCurrentIndex = host.currentIndex
+      const originalCurrentSong = host.currentSong
+      const originalPlayType = host.playType
+      const originalPlayer = host.player
+      const originalSnapshotStore = host.snapshotStore
+
+      const writtenSnapshots: PlaybackSnapshot[] = []
+      host.snapshotStore = {
+        write: (snapshot: PlaybackSnapshot): void => {
+          writtenSnapshots.push(snapshot)
+        }
+      }
+      host.queue = [songA, songB]
+      host.currentIndex = 1
+      host.currentSong = songB
+      host.playType = 3
+      host.player = {
+        getCurrentPosition: (): number => 145000,
+        getDuration: (): number => 296000
+      }
+
+      try {
+        host.persistPlaybackSnapshot(true)
+      } finally {
+        host.snapshotStore = originalSnapshotStore
+        host.queue = originalQueue
+        host.currentIndex = originalCurrentIndex
+        host.currentSong = originalCurrentSong
+        host.playType = originalPlayType
+        host.player = originalPlayer
+      }
+
+      expect(writtenSnapshots.length).assertEqual(1)
+      const snapshot = writtenSnapshots[0]
+      expect(snapshot.queue.length).assertEqual(2)
+      expect(snapshot.currentIndex).assertEqual(1)
+      expect(snapshot.currentSongKey).assertEqual('/music/b.flac')
+      expect(snapshot.currentSongName).assertEqual('Song B')
+      expect(snapshot.currentArtist ?? '').assertEqual('Artist B')
+      expect(snapshot.isPlaying).assertEqual(true)
+      expect(snapshot.positionMs).assertEqual(145000)
+      expect(snapshot.durationMs).assertEqual(296000)
+      expect(snapshot.cover ?? '').assertEqual('/cover/b.png')
+      expect(snapshot.playType).assertEqual(3)
+      expect(snapshot.playlistContext).assertEqual('host_runtime')
+      expect(snapshot.shouldResumeWhenActivated).assertEqual(true)
+      expect(snapshot.updatedAt > 0).assertEqual(true)
+    })
+
+    it('snapshotStoreRestoresQueueAndCurrentSongFromSnapshot', 0, () => {
+      const store = new PlaybackSnapshotStore()
+      const snapshotSongs: PlaybackSnapshotSong[] = [{
+        filePath: '/music/a.flac',
+        id: '1',
+        type: 0,
+        name: 'Song A',
+        artist: 'Artist A'
+      }, {
+        filePath: '/music/b.flac',
+        id: '2',
+        type: 0,
+        name: 'Song B',
+        artist: 'Artist B'
+      }]
+      const snapshot: PlaybackSnapshot = {
+        queue: snapshotSongs,
+        currentIndex: 0,
+        currentSongKey: '/music/b.flac',
+        currentSongName: 'Song B',
+        currentArtist: 'Artist B',
+        isPlaying: false,
+        positionMs: 8000,
+        durationMs: 296000,
+        cover: '/cover/b.png',
+        playType: 4,
+        playlistContext: 'host_runtime',
+        updatedAt: 1713000000000,
+        shouldResumeWhenActivated: false
+      }
+
+      const recovered = store.recoverQueueState(snapshot)
+      expect(recovered.queue.length).assertEqual(2)
+      expect(recovered.currentIndex).assertEqual(1)
+      expect(recovered.currentSong?.filePath ?? '').assertEqual('/music/b.flac')
+      expect(recovered.currentSong?.name ?? '').assertEqual('Song B')
+      expect(recovered.currentSong?.artist ?? '').assertEqual('Artist B')
+    })
+
+    it('resolvePlaybackSnapshotProgressValueNormalizesToPercent', 0, () => {
+      expect(resolvePlaybackSnapshotProgressValue(1500, 3000, 100)).assertEqual(50)
+      expect(resolvePlaybackSnapshotProgressValue(9999, 3000, 100)).assertEqual(100)
+      expect(resolvePlaybackSnapshotProgressValue(1000, 0, 100)).assertEqual(0)
+    })
+
+    it('snapshotStoreRecoverQueueStateHandlesInvalidLegacyShape', 0, () => {
+      const store = new PlaybackSnapshotStore()
+      const invalidSnapshot = JSON.parse(
+        '{"currentIndex":0,"currentSongKey":"","currentSongName":"","currentArtist":"","isPlaying":false,' +
+        '"positionMs":0,"durationMs":0,"cover":"","playType":0,"playlistContext":"legacy",' +
+        '"updatedAt":1,"shouldResumeWhenActivated":false}'
+      ) as PlaybackSnapshot
+
+      const recovered = store.recoverQueueState(invalidSnapshot)
+
+      expect(recovered.queue.length).assertEqual(0)
+      expect(recovered.currentIndex).assertEqual(-1)
+      expect(recovered.currentSong).assertUndefined()
+    })
+
+    it('snapshotStoreRecoverQueueStateSkipsInvalidSongs', 0, () => {
+      const store = new PlaybackSnapshotStore()
+      const invalidSongSnapshot = JSON.parse(
+        '{"queue":[{}],"currentIndex":0,"currentSongKey":"","currentSongName":"","currentArtist":"","isPlaying":false,' +
+        '"positionMs":0,"durationMs":0,"cover":"","playType":0,"playlistContext":"legacy",' +
+        '"updatedAt":1,"shouldResumeWhenActivated":false}'
+      ) as PlaybackSnapshot
+
+      const recovered = store.recoverQueueState(invalidSongSnapshot)
+
+      expect(recovered.queue.length).assertEqual(0)
+      expect(recovered.currentIndex).assertEqual(-1)
+      expect(recovered.currentSong).assertUndefined()
+    })
   })
 }