Bladeren bron

docs(player): 补充统一后台播放宿主设计

Codex 4 maanden geleden
bovenliggende
commit
157e85d213

+ 402 - 0
docs/superpowers/specs/2026-04-04-unified-background-playback-design.md

@@ -0,0 +1,402 @@
+# 统一后台播放宿主设计
+
+## 背景
+
+当前项目同时存在两条独立的音乐播放链路:
+
+- `LocalMusic.ets` 页面内播放器链路
+  - 页面持有 `mIjkMediaPlayer`
+  - 页面直接负责起播、暂停、切歌、进度控制
+  - 页面直接负责 AVSession 更新
+- `BackgroundAudioPlaybackHost.ets` 后台宿主链路
+  - 卡片冷启动控播走后台宿主
+  - 强杀后卡片播放由后台宿主恢复队列并起播
+
+这两条链路同时存在,直接导致以下问题:
+
+- 进入 app 后,页面播放条和后台宿主状态不一致
+- `LocalMusic` 本地播放和卡片后台播放会双播
+- AVSession / 卡片 / 页面显示不是同一真源
+- “强杀后卡片播放可用但不拉前台”和“进入 app 后页面无缝接管”无法同时满足
+
+## 目标
+
+统一为唯一播放链路,满足以下行为:
+
+1. 唯一播放器永远是后台独立宿主,不再由 `LocalMusic` 持有另一套本地播放器。
+2. 音乐卡片、后台控播、进入 app 后的页面控播,全部走同一条播放路径。
+3. 强杀 app 后,点击卡片播放可以直接播放,不拉起 app 前台。
+4. 点击卡片进入 app 时,页面只能“接管显示和控制”,不能重新 `doPlay()`、不能重建播放器。
+5. 页面上的当前歌曲、播放状态、进度、时长、封面、AVSession 播放条,都以后台宿主为唯一真源。
+6. 彻底消除双播。
+
+## 非目标
+
+本次第一轮统一不追求一次性重构所有复杂播放分支。
+
+以下内容不作为第一阶段必须完成项:
+
+- 所有投播分支的彻底统一
+- 所有特殊格式的完整播放能力迁移
+- 所有歌词、均衡器、HiCar、画中画等衍生能力的一次性全量迁移
+
+第一阶段优先解决“普通音乐主链路”的统一,包括:
+
+- 卡片控播
+- 后台起播
+- 进入 app 后状态同步
+- 页面控播
+- AVSession / 播放条
+- 双播消除
+
+## 现状结论
+
+当前架构中:
+
+- `PlaybackCoordinator` 只是一个运行时转发器
+- 真正的播放 runtime 仍然由 `LocalMusic` 页面注册
+- `EntryAbility` 在无 runtime 时,才退回 `BackgroundAudioPlaybackHost`
+
+因此系统实际是:
+
+- 卡片冷启动时走后台宿主
+- 页面活着时优先走 `LocalMusic` runtime
+
+这不是“同一路径的不同入口”,而是“两个播放器并存”。双播是架构必然结果,不是单点 bug。
+
+## 方案选择
+
+### 方案 A:彻底单宿主化
+
+做法:
+
+- `BackgroundAudioPlaybackHost` 成为唯一播放器和唯一 runtime
+- `LocalMusic` 只做 UI 与状态展示,不再自己持有和控制播放器
+
+优点:
+
+- 从根上消除双播
+- 卡片、后台、页面全部统一
+- AVSession、卡片、页面状态可天然统一
+
+缺点:
+
+- 改动范围较大
+- 需要拆出页面里混杂的播放器职责
+
+### 方案 B:双播放器保留,增加接管/互斥
+
+做法:
+
+- 保留 `LocalMusic` 和后台宿主
+- 在进入 app 时尝试从后台宿主迁移到页面播放器
+
+优点:
+
+- 短期改动较小
+
+缺点:
+
+- 本质仍有两条链路
+- 容易继续出现双播、状态漂移、AVSession 不一致
+- 不符合“真正完全独立且统一路径”的目标
+
+### 方案 C:卡片走后台,进入 app 后迁回页面播放器
+
+做法:
+
+- 后台宿主仅服务卡片冷启动
+- 页面打开后重新交回 `LocalMusic`
+
+优点:
+
+- 最容易做
+
+缺点:
+
+- 直接违背目标
+- 仍是两套播放器
+
+### 结论
+
+采用方案 A:彻底单宿主化。
+
+## 目标架构
+
+### 1. 唯一播放内核
+
+`BackgroundAudioPlaybackHost` 成为整个应用唯一播放内核,负责:
+
+- 持有唯一 `IjkMediaPlayer`
+- 起播 / 暂停 / 停止 / 上一首 / 下一首 / seek
+- 持有当前播放队列、当前索引、当前歌曲、播放模式
+- 统一记忆播放恢复
+- 统一 AVSession 更新
+- 统一卡片更新
+- 统一后台任务管理
+- 统一发布当前播放快照
+
+### 2. 统一控制入口
+
+`MusicPlaybackController` / `PlaybackCoordinator` 继续作为控制入口,但只保留“转发到唯一宿主”的职责:
+
+- UI 点击播放/暂停
+- 卡片按钮控制
+- AVSession 回调控制
+- 页面快捷键和其他入口控制
+
+所有控制命令最终都落到后台宿主,不再由 `LocalMusic` 自己直控播放器。
+
+### 3. 页面层降为纯 UI 壳
+
+`LocalMusic` 改为播放界面层:
+
+- 读取后台宿主发布的播放快照
+- 展示当前歌曲、进度、时长、播放状态、封面、歌词
+- 将页面上的播放/暂停/上一首/下一首/seek 操作转发给控制层
+- 保留页面展示逻辑和非播放器 UI 逻辑
+
+`LocalMusic` 不再:
+
+- 创建本地 `IjkMediaPlayer`
+- 直接 `doPlay()`
+- 直接 `startPlayOrResumePlay()`
+- 直接 `playNext()` / `playPrevious()` 驱动底层播放器
+- 直接写 AVSession
+
+### 4. 单一状态真源
+
+后台宿主维护唯一播放状态,并通过统一状态桥暴露给页面消费。
+
+状态真源至少包括:
+
+- 当前队列
+- 当前索引
+- 当前歌曲
+- 是否播放中
+- 当前进度
+- 总时长
+- 封面
+- 播放模式
+
+页面打开时先拉取一次完整快照,再持续监听状态变化。
+
+## 关键职责划分
+
+### BackgroundAudioPlaybackHost
+
+新增或收拢职责:
+
+- 对外暴露完整 `PlaybackRuntime`
+- 对外暴露当前播放快照
+- 对外提供状态变化通知
+- 成为唯一 AVSession 写入点
+- 成为唯一卡片同步点
+
+### PlaybackCoordinator
+
+调整为:
+
+- 默认 runtime 直接绑定后台宿主
+- 不再依赖 `LocalMusic` 注册 runtime
+- 全局始终只有一个 runtime
+
+### MusicPlaybackController
+
+保持:
+
+- 播放逻辑入口
+- 纯逻辑解析
+- 命令分发
+
+变化:
+
+- 所有动作只走统一 runtime
+
+### LocalMusic
+
+拆除职责:
+
+- 自己持有播放器
+- 自己写 AVSession
+- 自己维护另一套播放状态
+
+保留职责:
+
+- 页面展示
+- 页面交互
+- 歌单展示
+- 歌词展示
+- 各类 UI 面板和辅助功能
+
+## 数据流
+
+统一后的数据流如下:
+
+1. 用户通过卡片 / 页面 / AVSession 发起控制命令。
+2. 命令进入 `MusicPlaybackController`。
+3. `MusicPlaybackController` 转发给 `PlaybackCoordinator`。
+4. `PlaybackCoordinator` 将命令转发给 `BackgroundAudioPlaybackHost`。
+5. `BackgroundAudioPlaybackHost` 执行实际播放器操作。
+6. `BackgroundAudioPlaybackHost` 更新:
+   - 卡片
+   - AVSession
+   - 全局播放快照
+7. `LocalMusic` 订阅并渲染该快照。
+
+页面不再反向拥有播放状态,也不再反向驱动播放器。
+
+## 进入 app 的行为设计
+
+当后台宿主已经在播,用户从卡片进入 app 时:
+
+- 只能打开页面 UI
+- 页面必须直接读取后台宿主当前快照
+- 不允许再次 `doPlay()`
+- 不允许重建 `IjkMediaPlayer`
+- 不允许重新起播同一首歌
+
+也就是说,进入 app 是“显示接管”,不是“播放器迁移”。
+
+## AVSession / 播放条设计
+
+AVSession 的写入必须统一收口到后台宿主。
+
+这样可保证:
+
+- 卡片播放时已有 AVSession
+- 进入 app 后系统播放条不会断链
+- 页面显示与系统播放条一致
+
+`LocalMusic` 页面内部原有的 AVSession 初始化、状态更新、回调绑定逻辑,需要拆成两类:
+
+- 必须保留的控制入口:转发到统一宿主
+- 不再允许保留的状态写入:页面自己的 AVSession 更新
+
+## 双播消除原则
+
+双播的根因是存在两套底层播放器。
+
+要彻底消除双播,必须同时满足:
+
+1. 页面层不再自行持有播放器并起播。
+2. 所有播放命令只走后台宿主。
+3. 进入页面时只同步状态,不重新起播。
+
+只做其中任意一项都不够。
+
+## 分阶段迁移策略
+
+### 阶段 1:统一运行时入口
+
+目标:
+
+- `PlaybackCoordinator` 永远绑定后台宿主
+- 卡片和页面的播放控制都进入后台宿主
+
+结果:
+
+- 所有新的播放命令先统一到一个 runtime
+- 先把“入口分叉”问题收口
+
+### 阶段 2:页面改为消费宿主状态
+
+目标:
+
+- `LocalMusic` 页面上的播放状态、进度、歌曲、封面改为读取宿主快照
+- 页面进入时不再自己补建状态
+
+结果:
+
+- 进 app 后能看到正确播放条
+- 页面与卡片、AVSession 状态一致
+
+### 阶段 3:移除页面底层播放器职责
+
+目标:
+
+- 删除 `LocalMusic` 内部本地播放器核心控制逻辑
+- 删除页面自己的 AVSession 写入路径
+
+结果:
+
+- 从代码结构上彻底消除双播放器
+
+### 阶段 4:逐项迁回复杂能力
+
+目标:
+
+- 将歌词、随机播放、CUE、记忆播放、均衡器等能力逐步接回唯一宿主
+
+原则:
+
+- 先保证主链路统一
+- 再逐项恢复复杂分支
+
+## 文件级改造范围
+
+第一阶段主要涉及:
+
+- `entry/src/main/ets/playback/BackgroundAudioPlaybackHost.ets`
+- `entry/src/main/ets/controller/PlaybackCoordinator.ets`
+- `entry/src/main/ets/controller/MusicPlaybackController.ets`
+- `entry/src/main/ets/view/LocalMusic.ets`
+- 必要时新增:
+  - `entry/src/main/ets/playback/BackgroundPlaybackStateStore.ets`
+  - `entry/src/main/ets/playback/BackgroundPlaybackSnapshot.ets`
+
+## 风险与控制
+
+### 风险 1:页面状态空白
+
+原因:
+
+- 页面原本依赖自己内部播放器状态
+
+控制:
+
+- 先建立宿主快照和订阅机制,再切 UI 读取路径
+
+### 风险 2:复杂播放分支回归
+
+原因:
+
+- `LocalMusic` 内部聚合了大量特殊播放逻辑
+
+控制:
+
+- 第一阶段只统一主链路
+- 复杂分支分批迁移
+
+### 风险 3:AVSession 回调回流冲突
+
+原因:
+
+- 页面和后台宿主同时写 AVSession 时容易互相打架
+
+控制:
+
+- AVSession 写入只允许后台宿主负责
+
+## 测试策略
+
+第一阶段至少验证以下场景:
+
+1. 强杀 app 后点击卡片播放,可直接播放,不拉前台。
+2. 播放中点击卡片进入 app,页面直接显示当前歌曲、进度、播放条。
+3. 进入 app 后点击播放/暂停、上一首、下一首,实际控制同一后台宿主。
+4. 页面播放过程中不会双播。
+5. 系统播放条状态与页面状态一致。
+6. 卡片状态、页面状态、AVSession 状态一致。
+
+## 成功标准
+
+当满足以下标准时,认为第一阶段完成:
+
+- 代码中只存在一套实际音乐播放器主链路
+- 卡片和页面控播落到同一宿主
+- 强杀后卡片播放可用
+- 进入 app 不重新起播
+- 不出现双播
+- 页面播放条与 AVSession 正常显示
+

+ 29 - 0
entry/src/main/ets/playback/PlaybackActivationStore.ets

@@ -0,0 +1,29 @@
+import { PendingPlaybackActivation, PlaybackActivationActionType } from './model/PlaybackSnapshot'
+
+const PENDING_PLAYBACK_ACTIVATION_KEY = 'pendingPlaybackActivation'
+
+export function savePendingPlaybackActivation(actionType: PlaybackActivationActionType): void {
+  const payload: PendingPlaybackActivation = {
+    actionType,
+    requestedAt: Date.now()
+  }
+  AppStorage.setOrCreate(PENDING_PLAYBACK_ACTIVATION_KEY, JSON.stringify(payload))
+}
+
+export function peekPendingPlaybackActivation(): PendingPlaybackActivation | undefined {
+  const text = (AppStorage.get(PENDING_PLAYBACK_ACTIVATION_KEY) as string | undefined) ?? ''
+  if (text.length === 0) {
+    return undefined
+  }
+  return JSON.parse(text) as PendingPlaybackActivation
+}
+
+export function consumePendingPlaybackActivation(): PendingPlaybackActivation | undefined {
+  const payload = peekPendingPlaybackActivation()
+  AppStorage.setOrCreate(PENDING_PLAYBACK_ACTIVATION_KEY, '')
+  return payload
+}
+
+export function clearPendingPlaybackActivation(): void {
+  AppStorage.setOrCreate(PENDING_PLAYBACK_ACTIVATION_KEY, '')
+}

+ 47 - 0
entry/src/main/ets/playback/PlaybackHostState.ets

@@ -0,0 +1,47 @@
+import { PlayStatus } from '../common/PlayStatus'
+
+export function ensurePlaybackHostStorageDefaults(): void {
+  if (AppStorage.get('isShowPlay') === undefined) {
+    AppStorage.setOrCreate('isShowPlay', false)
+  }
+  if (AppStorage.get('playbackRuntimeReady') === undefined) {
+    AppStorage.setOrCreate('playbackRuntimeReady', false)
+  }
+  if (AppStorage.get('playbackHostOpenRequestToken') === undefined) {
+    AppStorage.setOrCreate('playbackHostOpenRequestToken', 0)
+  }
+  if (AppStorage.get('playbackHostPlaylistRequestToken') === undefined) {
+    AppStorage.setOrCreate('playbackHostPlaylistRequestToken', 0)
+  }
+  if (AppStorage.get('CONTROL_PlayStatus') === undefined) {
+    AppStorage.setOrCreate('CONTROL_PlayStatus', PlayStatus.INIT)
+  }
+  if (AppStorage.get('progressValue') === undefined) {
+    AppStorage.setOrCreate('progressValue', 0)
+  }
+  if (AppStorage.get('cover') === undefined) {
+    AppStorage.setOrCreate('cover', '')
+  }
+  if (AppStorage.get('currentSong') === undefined) {
+    AppStorage.setOrCreate('currentSong', undefined)
+  }
+}
+
+export function showPlaybackPlayer(show: boolean): void {
+  AppStorage.setOrCreate('isShowPlay', show)
+}
+
+export function setPlaybackRuntimeReady(ready: boolean): void {
+  AppStorage.setOrCreate('playbackRuntimeReady', ready)
+}
+
+export function requestPlaybackPlayerOpen(): void {
+  const currentToken = AppStorage.get<number>('playbackHostOpenRequestToken') ?? 0
+  AppStorage.setOrCreate('isShowPlay', true)
+  AppStorage.setOrCreate('playbackHostOpenRequestToken', currentToken + 1)
+}
+
+export function requestPlaybackPlaylistOpen(): void {
+  const currentToken = AppStorage.get<number>('playbackHostPlaylistRequestToken') ?? 0
+  AppStorage.setOrCreate('playbackHostPlaylistRequestToken', currentToken + 1)
+}

+ 31 - 0
entry/src/main/ets/playback/PlaybackPendingActionCoordinator.ets

@@ -0,0 +1,31 @@
+import { PlayStatus } from '../common/PlayStatus'
+import { MusicCardActionConstants } from '../common/player/MusicCardActionConstants'
+
+export type PendingMusicCardActionRoute = 'dispatch' | 'wait' | 'ignore'
+
+export class PlaybackPendingActionCoordinator {
+  public static resolvePendingActionRoute(action: string, hasRuntime: boolean, hasCurrentSong: boolean,
+    queueLength: number, playStatus: number): PendingMusicCardActionRoute {
+    if (action !== MusicCardActionConstants.ACTION_PLAY_PAUSE
+      && action !== MusicCardActionConstants.ACTION_PREVIOUS
+      && action !== MusicCardActionConstants.ACTION_NEXT) {
+      return 'ignore'
+    }
+
+    if (!hasRuntime) {
+      return 'wait'
+    }
+
+    if (action === MusicCardActionConstants.ACTION_PLAY_PAUSE) {
+      if (playStatus === PlayStatus.PLAY || playStatus === PlayStatus.PAUSE || hasCurrentSong) {
+        return 'dispatch'
+      }
+      return 'wait'
+    }
+
+    if (queueLength > 0) {
+      return 'dispatch'
+    }
+    return 'wait'
+  }
+}

+ 10 - 0
entry/src/main/ets/playback/PlaybackRestoreCoordinator.ets

@@ -0,0 +1,10 @@
+export type MusicCardOpenRoute = 'show_player_now' | 'request_host_open'
+
+export class PlaybackRestoreCoordinator {
+  public static resolveMusicCardOpenRoute(hasRuntime: boolean): MusicCardOpenRoute {
+    if (hasRuntime) {
+      return 'show_player_now'
+    }
+    return 'request_host_open'
+  }
+}

+ 17 - 0
entry/src/main/ets/playback/PlaybackRuntimeRegistry.ets

@@ -0,0 +1,17 @@
+import { PlaybackRuntime } from '../controller/PlaybackCoordinator'
+
+let registeredPlaybackRuntime: PlaybackRuntime | undefined = undefined
+
+export function registerPlaybackRuntime(runtime: PlaybackRuntime): void {
+  registeredPlaybackRuntime = runtime
+}
+
+export function getRegisteredPlaybackRuntime(): PlaybackRuntime | undefined {
+  return registeredPlaybackRuntime
+}
+
+export function clearRegisteredPlaybackRuntime(runtime?: PlaybackRuntime): void {
+  if (!runtime || registeredPlaybackRuntime === runtime) {
+    registeredPlaybackRuntime = undefined
+  }
+}

+ 27 - 0
entry/src/main/ets/playback/PlaybackSnapshotStore.ets

@@ -0,0 +1,27 @@
+import { PreferencesUtil } from '@pura/harmony-utils'
+import { PlaybackSnapshot } from './model/PlaybackSnapshot'
+
+export const PLAYBACK_SNAPSHOT_PREFERENCES_KEY = 'playback.host.snapshot'
+
+export class PlaybackSnapshotStore {
+  public write(snapshot: PlaybackSnapshot): void {
+    PreferencesUtil.putSync(PLAYBACK_SNAPSHOT_PREFERENCES_KEY, JSON.stringify(snapshot))
+  }
+
+  public read(): PlaybackSnapshot | undefined {
+    const text = PreferencesUtil.getStringSync(PLAYBACK_SNAPSHOT_PREFERENCES_KEY, '')
+    if (text.length === 0) {
+      return undefined
+    }
+    try {
+      return JSON.parse(text) as PlaybackSnapshot
+    } catch (_error) {
+      this.clear()
+      return undefined
+    }
+  }
+
+  public clear(): void {
+    PreferencesUtil.putSync(PLAYBACK_SNAPSHOT_PREFERENCES_KEY, '')
+  }
+}

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

@@ -0,0 +1,27 @@
+export interface PlaybackSnapshotSong {
+  filePath: string
+  id?: string
+  type: number
+  name: string
+  artist?: string
+  remote_rel_path?: string
+  webdav_account_id?: string
+}
+
+export interface PlaybackSnapshot {
+  queue: PlaybackSnapshotSong[]
+  currentIndex: number
+  currentSongKey: string
+  positionMs: number
+  playType: number
+  playlistContext: string
+  updatedAt: number
+  shouldResumeWhenActivated: boolean
+}
+
+export type PlaybackActivationActionType = 'resume_play' | 'open_player' | 'play_previous' | 'play_next'
+
+export interface PendingPlaybackActivation {
+  actionType: PlaybackActivationActionType
+  requestedAt: number
+}