7 Commit-ok a0690f8b34 ... cc42c2d344

Szerző SHA1 Üzenet Dátum
  onecold cc42c2d344 音乐卡片和本地音乐走同样的播放路线,解决双播问题 4 hónapja
  Codex 4cc8bc13c1 refactor(player): 让页面消费后台宿主快照 4 hónapja
  Codex 85f62acf6d refactor(player): 统一后台宿主运行时入口 4 hónapja
  Codex 157e85d213 docs(player): 补充统一后台播放宿主设计 4 hónapja
  Codex 76a2303514 docs(player): 设计音乐卡片后台控播链路 4 hónapja
  onecold 1f51317bff chore(git): ignore local worktree directory 4 hónapja
  onecold 5bb1c15599 docs(player): 补充播放宿主完全独立设计 4 hónapja
62 módosított fájl, 3876 hozzáadás és 527 törlés
  1. 1 0
      .gitignore
  2. 416 0
      docs/superpowers/specs/2026-04-03-playback-host-full-independence-design.md
  3. 269 0
      docs/superpowers/specs/2026-04-04-music-card-background-call-design.md
  4. 402 0
      docs/superpowers/specs/2026-04-04-unified-background-playback-design.md
  5. 0 1
      entry/src/main/ets/common/player/MusicCardActionConstants.ets
  6. 11 0
      entry/src/main/ets/common/player/MusicCardActionRouteHelper.ets
  7. 4 0
      entry/src/main/ets/common/player/MusicCardManager.ets
  8. 1 1
      entry/src/main/ets/common/player/MusicCardSnapshotStore.ets
  9. 5 0
      entry/src/main/ets/common/player/PlaybackControlStateHelper.ets
  10. 1 1
      entry/src/main/ets/common/util/LyricUtil.ets
  11. 3 0
      entry/src/main/ets/common/util/MiniPlayerHostViewHelper.ets
  12. 14 0
      entry/src/main/ets/common/util/MiniPlayerMirrorStateHelper.ets
  13. 27 0
      entry/src/main/ets/common/util/PlayerLyricUpdateTargetsHelper.ets
  14. 3 0
      entry/src/main/ets/common/util/PlayerPageOpenDispatchHelper.ets
  15. 52 0
      entry/src/main/ets/controller/AvSessionController.ets
  16. 42 3
      entry/src/main/ets/controller/MusicPlaybackController.ets
  17. 37 6
      entry/src/main/ets/controller/PlaybackCoordinator.ets
  18. 59 17
      entry/src/main/ets/entryability/EntryAbility.ets
  19. 0 0
      entry/src/main/ets/lyric/LyricController.ets
  20. 0 0
      entry/src/main/ets/lyric/LyricHelper.ets
  21. 0 0
      entry/src/main/ets/lyric/bean/Lyric.ts
  22. 0 0
      entry/src/main/ets/lyric/bean/LyricLine.ts
  23. 0 0
      entry/src/main/ets/lyric/bean/LyricLineMeasureResult.ets
  24. 0 0
      entry/src/main/ets/lyric/bean/LyricLineWrapper.ts
  25. 0 0
      entry/src/main/ets/lyric/bean/LyricWord.ts
  26. 0 0
      entry/src/main/ets/lyric/extensions/ListAdapter.ets
  27. 4 1
      entry/src/main/ets/lyric/parse/FileParser.ets
  28. 0 0
      entry/src/main/ets/lyric/parse/LyricParser.ts
  29. 0 0
      entry/src/main/ets/lyric/view/CcLyricView.ets
  30. 0 29
      entry/src/main/ets/lyric/view/LyricView2.ets
  31. 137 14
      entry/src/main/ets/pages/NewIndex.ets
  32. 863 0
      entry/src/main/ets/playback/BackgroundAudioPlaybackHost.ets
  33. 137 0
      entry/src/main/ets/playback/BackgroundAudioPlaybackHostHelper.ets
  34. 29 0
      entry/src/main/ets/playback/PlaybackActivationStore.ets
  35. 62 0
      entry/src/main/ets/playback/PlaybackHostState.ets
  36. 31 0
      entry/src/main/ets/playback/PlaybackPendingActionCoordinator.ets
  37. 29 0
      entry/src/main/ets/playback/PlaybackRestoreCoordinator.ets
  38. 17 0
      entry/src/main/ets/playback/PlaybackRuntimeRegistry.ets
  39. 14 0
      entry/src/main/ets/playback/PlaybackSnapshotHydrationHelper.ets
  40. 132 0
      entry/src/main/ets/playback/PlaybackSnapshotStore.ets
  41. 45 0
      entry/src/main/ets/playback/model/PlaybackSnapshot.ets
  42. 7 3
      entry/src/main/ets/view/FindView.ets
  43. 438 233
      entry/src/main/ets/view/LocalMusic.ets
  44. 3 2
      entry/src/main/ets/view/MiniPlayerBar.ets
  45. 2 1
      entry/src/main/ets/view/PipLyricTextBuilder.ets
  46. 2 1
      entry/src/main/ets/view/player/PlayerControls.ets
  47. 13 1
      entry/src/main/ets/view/spectrum/SpectrumView.ets
  48. 25 0
      entry/src/main/ets/widget/common/MusicPlayerWidgetHelper.ets
  49. 7 7
      entry/src/main/ets/widget/pages/MusicPlayerWidgetCard.ets
  50. 7 7
      entry/src/main/ets/widget/pages/MusicPlayerWidgetDynamicLyricCard.ets
  51. 7 7
      entry/src/main/ets/widget/pages/MusicPlayerWidgetLyricCard.ets
  52. 7 7
      entry/src/main/ets/widget/pages/MusicPlayerWidgetMiniCard.ets
  53. 7 7
      entry/src/main/ets/widget/pages/MusicPlayerWidgetWideCard.ets
  54. 255 0
      entry/src/ohosTest/ets/test/BackgroundAudioPlaybackHostRuntime.test.ets
  55. 16 0
      entry/src/ohosTest/ets/test/List.test.ets
  56. 93 0
      entry/src/ohosTest/ets/test/PlaybackCoordinatorBinding.test.ets
  57. 0 17
      lib/Index.ets
  58. 0 6
      lib/hvigorfile.ts
  59. 0 30
      lib/src/main/ets/extensions/Extension.ts
  60. 0 7
      lib/src/main/ets/parse/IParser.ts
  61. 0 13
      lib/src/main/resources/base/media/cclyric_play.svg
  62. 140 105
      oh_modules/.ohpm/lock.json5

+ 1 - 0
.gitignore

@@ -30,3 +30,4 @@ stash_output.txt
 .cursor/
 .history/
 .spectra/
+.worktrees/

+ 416 - 0
docs/superpowers/specs/2026-04-03-playback-host-full-independence-design.md

@@ -0,0 +1,416 @@
+# 播放宿主完全独立设计
+
+## 背景
+
+当前项目虽然已经引入了 `MusicPlaybackController` 与 `PlaybackCoordinator`,但真实播放运行时仍然绑定在 [LocalMusic.ets](/mnt/d/harmony/2025/qimeng/TTMusic_Card/TTMusic/entry/src/main/ets/view/LocalMusic.ets):
+
+- `IjkMediaPlayer` 由 `LocalMusic` 持有。
+- `PlaybackRuntime` 与 controller actions 由 `LocalMusic` 在页面生命周期里注册。
+- 当前歌曲、播放队列、索引、进度、歌词、AVSession、卡片同步、播放页显隐等关键状态仍以 `LocalMusic` 为主状态源。
+- `EntryAbility` 打开播放页时,仍然依赖 `PlaybackCoordinator.hasRuntime()`,本质上还是在等 `LocalMusic` 先成为宿主。
+
+这意味着当前播放能力只是“入口抽象了一层”,并没有真正脱离 `LocalMusic`。只要 `LocalMusic` 还是运行时宿主,播放就不算完全独立。
+
+## 本次目标
+
+本轮目标是把“真实播放 runtime + 播放状态源 + 播放器宿主 UI”从 `LocalMusic` 中完整迁出,建立独立播放宿主。
+
+完成后必须满足:
+
+- `LocalMusic` 只负责内容展示与发起播放请求,不再持有真实播放器。
+- `NewIndex` 挂载独立 `PlaybackHost`,成为唯一播放器宿主。
+- `PlaybackCoordinator` 的 runtime 来源是 `PlaybackHost`,不再是 `LocalMusic`。
+- 迷你播放条与全屏播放页的数据来源是独立宿主,不反向依赖 `LocalMusic`。
+- 强杀应用后,音乐卡片点击播放会先拉起应用,再由独立宿主恢复“上次整条播放队列 + 当前索引 + 当前进度”并开始播放。
+
+## 用户确认的约束
+
+- 首轮优先实现“完全独立”,不主动重做现有播放行为和 UI 视觉。
+- 强杀应用后不要求后台自动继续播放。
+- 只有用户点击音乐卡片播放时,才触发恢复播放。
+- 恢复范围不是单曲,而是“上次整条播放队列 + 当前索引 + 当前进度”。
+- 首轮不重写底层 Ijk 播放内核。
+
+## 非目标
+
+- 不重做现有播放器视觉设计。
+- 不重构所有远程源的 URL 解析实现,只调整归属和调用链。
+- 不顺手重构 `LocalMusic` 的本地列表结构、搜索结构、歌单结构。
+- 不实现“应用被系统杀死后自动后台恢复播放”。
+- 不追求一轮消灭所有 `AppStorage` 兼容字段,允许保留必要镜像。
+
+## 推荐方案
+
+采用“独立宿主 + 快照恢复”的轻量宿主方案。
+
+核心思想:
+
+- 新增独立 `PlaybackHost`,挂在 [NewIndex.ets](/mnt/d/harmony/2025/qimeng/TTMusic_Card/TTMusic/entry/src/main/ets/pages/NewIndex.ets)。
+- `PlaybackHost` 接管真实播放运行时、核心播放状态、恢复链路、播放器页面开关和卡片联动。
+- `MusicPlaybackController` 继续作为统一播放入口。
+- `PlaybackCoordinator` 继续作为运行时协调层,但 runtime 改由 `PlaybackHost` 注册。
+- `LocalMusic`、`WebDavMainPage`、`FindView`、`PlaylistDetailPage`、`ChartsCount` 等页面全部退化成内容页,只负责组装队列并发起请求。
+
+不采用以下方案:
+
+- 继续让 `LocalMusic` 作为隐藏宿主常驻。这样只是“藏起来的耦合”,不是完全独立。
+- 直接把所有播放逻辑堆回 `MusicPlaybackController`。这样会把 controller 演化成新的超大类,结构上只是把问题平移。
+- 首轮同时做 store、runtime service、全量 UI 重建。改动面过大,验证成本太高,不符合“先完全独立,再保持行为稳定”的要求。
+
+## 目标架构
+
+### 1. MusicPlaybackController
+
+职责保持为统一入口层:
+
+- 对外暴露统一播放 API。
+- 负责少量纯逻辑解析。
+- 把动作转发给 `PlaybackCoordinator`。
+- 对外提供“宿主激活恢复播放”的统一入口。
+
+它不再承担:
+
+- 真实播放器生命周期。
+- 播放页显隐状态源。
+- 强杀恢复状态的主存储。
+
+### 2. PlaybackCoordinator
+
+继续作为协调层存在:
+
+- 持有当前唯一 runtime 引用。
+- 负责把 controller 请求转发给 runtime。
+- 负责在起播队列失败时回滚基础桥接状态。
+
+它不再依赖 `LocalMusic` 生命周期,而是只认 `PlaybackHost` 注册的 runtime。
+
+### 3. PlaybackHost
+
+新增独立播放宿主,挂在 `NewIndex`。
+
+它是本轮的核心新增组件,负责:
+
+- 持有 `IjkMediaPlayer`。
+- 持有当前歌曲、当前队列、当前索引。
+- 处理播放、暂停、上一首、下一首、seek。
+- 处理起播、切歌、恢复播放、记忆进度。
+- 处理歌词解析与同步。
+- 处理 AVSession、卡片同步、播放状态桥接。
+- 处理播放页显隐与迷你播放条数据来源。
+- 注册 `PlaybackRuntime` 与 controller actions。
+
+拆完后,`PlaybackHost` 是唯一真实播放宿主。
+
+### 4. 内容页
+
+包括但不限于:
+
+- [LocalMusic.ets](/mnt/d/harmony/2025/qimeng/TTMusic_Card/TTMusic/entry/src/main/ets/view/LocalMusic.ets)
+- [WebDavMainPage.ets](/mnt/d/harmony/2025/qimeng/TTMusic_Card/TTMusic/entry/src/main/ets/pages/WebDavMainPage.ets)
+- [FindView.ets](/mnt/d/harmony/2025/qimeng/TTMusic_Card/TTMusic/entry/src/main/ets/view/FindView.ets)
+- [PlaylistDetailPage.ets](/mnt/d/harmony/2025/qimeng/TTMusic_Card/TTMusic/entry/src/main/ets/pages/PlaylistDetailPage.ets)
+- [ChartsCount.ets](/mnt/d/harmony/2025/qimeng/TTMusic_Card/TTMusic/entry/src/main/ets/pages/ChartsCount.ets)
+
+这些页面迁移后只负责:
+
+- 展示内容。
+- 组织队列。
+- 调用 `MusicPlaybackController.playQueue/playSong`。
+
+这些页面迁移后不再负责:
+
+- 持有播放器实例。
+- 维护当前播放状态源。
+- 决定播放页是否可打开。
+- 执行真实播放控制动作。
+
+## 硬边界
+
+本轮完成后必须满足以下硬边界:
+
+- `LocalMusic` 不能再持有 `IjkMediaPlayer`。
+- `LocalMusic` 不能再注册 `PlaybackRuntime` 或 `MusicPlaybackControllerActions`。
+- `LocalMusic` 不能再作为 `showPlayerView`、播放页开关、切歌逻辑、进度推进、当前歌曲、歌词、AVSession、卡片同步的主状态源。
+- 打开播放页的逻辑不能再依赖 `LocalMusic` 是否已挂载。
+- 迷你播放条和全屏播放页都必须从 `PlaybackHost` 读取状态。
+- 强杀恢复不能再依赖 `LocalMusic` 生命周期。
+
+## 强杀恢复设计
+
+### 1. 恢复原则
+
+强杀后的恢复语义明确如下:
+
+- 应用被强杀后不自动播放。
+- 只有用户点击音乐卡片播放时,才会拉起应用并恢复播放。
+- 恢复的是“上次整条播放队列 + 当前索引 + 当前进度”,不是单曲。
+- 恢复成功后直接开始播放,不严格还原“上次是暂停还是播放”的状态。
+
+最后一点的原因是:在应用已死场景下,用户从卡片点击播放的意图更接近“恢复并开始播放”,而不是“恢复到暂停态”。
+
+### 2. PlaybackSnapshot
+
+新增独立快照模型 [PlaybackSnapshot.ets](/mnt/d/harmony/2025/qimeng/TTMusic_Card/TTMusic/entry/src/main/ets/playback/model/PlaybackSnapshot.ets)。
+
+快照至少包含:
+
+- `queue`: 当前播放队列的精简快照。
+- `currentIndex`: 当前播放索引。
+- `currentSongKey`: 当前歌曲稳定标识。
+- `positionMs`: 当前播放进度。
+- `playType`: 当前播放模式。
+- `playlistContext`: 队列来源上下文。
+- `updatedAt`: 最近更新时间。
+- `shouldResumeWhenActivated`: 是否存在待恢复播放意图。
+
+队列内每首歌的快照项只保留恢复所需字段,例如:
+
+- `filePath`
+- `id`
+- `type`
+- `name`
+- `artist`
+- `remote_rel_path`
+- `webdav_account_id`
+- 其他当前远程源重建播放地址所需的稳定字段
+
+明确不持久化:
+
+- 实时解析出来的播放 URL
+- `IjkMediaPlayer` 内部状态
+- UI 动画状态
+- 临时回调与运行时对象引用
+
+### 3. PlaybackSnapshotStore
+
+新增 [PlaybackSnapshotStore.ets](/mnt/d/harmony/2025/qimeng/TTMusic_Card/TTMusic/entry/src/main/ets/playback/PlaybackSnapshotStore.ets),职责如下:
+
+- 写入当前快照
+- 读取最近快照
+- 清理无效快照
+- 兼容损坏数据回退
+
+写入时机由 `PlaybackHost` 统一控制:
+
+- 歌曲切换
+- 队列切换
+- 播放进度推进到节流点
+- 播放模式变化
+- 应用即将进入不可见状态
+
+### 4. 强杀后的恢复流程
+
+统一恢复流程如下:
+
+1. `PlaybackHost` 正常播放过程中持续写入 `PlaybackSnapshot`。
+2. 应用被强杀后,音乐卡片触发一个“播放/恢复播放”激活动作。
+3. [EntryAbility.ets](/mnt/d/harmony/2025/qimeng/TTMusic_Card/TTMusic/entry/src/main/ets/entryability/EntryAbility.ets) 不直接播放,只写入一个待执行的激活动作。
+4. 应用启动后,`NewIndex` 先挂载 `PlaybackHost`。
+5. `PlaybackHost` 初始化时读取 `PlaybackSnapshot`,恢复队列、当前索引、当前歌曲和进度到内存,但先不自动播放。
+6. `PlaybackHost` 检测到待执行激活动作后,再开始恢复播放:
+   - 恢复整条队列
+   - 修正当前索引
+   - 为当前歌曲重新解析播放 URL
+   - 从 `positionMs` 开始播放
+7. 若当前歌曲恢复失败,则优先尝试队列中的下一首可播歌曲。
+8. 若整条队列失效,则清空快照并提示恢复失败。
+
+## 文件边界
+
+### 新增文件
+
+- [PlaybackHost.ets](/mnt/d/harmony/2025/qimeng/TTMusic_Card/TTMusic/entry/src/main/ets/playback/PlaybackHost.ets)
+  独立播放宿主组件,挂在 `NewIndex`,负责 runtime 注册和宿主生命周期。
+- [PlaybackHostState.ets](/mnt/d/harmony/2025/qimeng/TTMusic_Card/TTMusic/entry/src/main/ets/playback/PlaybackHostState.ets)
+  收口宿主共享状态定义。
+- [PlaybackSnapshotStore.ets](/mnt/d/harmony/2025/qimeng/TTMusic_Card/TTMusic/entry/src/main/ets/playback/PlaybackSnapshotStore.ets)
+  读写恢复快照。
+- [PlaybackRestoreCoordinator.ets](/mnt/d/harmony/2025/qimeng/TTMusic_Card/TTMusic/entry/src/main/ets/playback/PlaybackRestoreCoordinator.ets)
+  负责快照恢复与激活动作编排。
+- [PlaybackSnapshot.ets](/mnt/d/harmony/2025/qimeng/TTMusic_Card/TTMusic/entry/src/main/ets/playback/model/PlaybackSnapshot.ets)
+  定义恢复快照和精简队列项结构。
+
+### 主要修改文件
+
+- [NewIndex.ets](/mnt/d/harmony/2025/qimeng/TTMusic_Card/TTMusic/entry/src/main/ets/pages/NewIndex.ets)
+  挂载 `PlaybackHost`,让迷你播放条和全屏播放页从宿主取状态。
+- [LocalMusic.ets](/mnt/d/harmony/2025/qimeng/TTMusic_Card/TTMusic/entry/src/main/ets/view/LocalMusic.ets)
+  收缩为内容页,删除宿主职责。
+- [MusicPlaybackController.ets](/mnt/d/harmony/2025/qimeng/TTMusic_Card/TTMusic/entry/src/main/ets/controller/MusicPlaybackController.ets)
+  保持统一入口定位,补激活动作入口。
+- [PlaybackCoordinator.ets](/mnt/d/harmony/2025/qimeng/TTMusic_Card/TTMusic/entry/src/main/ets/controller/PlaybackCoordinator.ets)
+  保持协调层定位,runtime 改由宿主注册。
+- [EntryAbility.ets](/mnt/d/harmony/2025/qimeng/TTMusic_Card/TTMusic/entry/src/main/ets/entryability/EntryAbility.ets)
+  音乐卡片点击后改为写入待执行宿主激活动作,而不是等待 `LocalMusic`。
+- [PlayerPage.ets](/mnt/d/harmony/2025/qimeng/TTMusic_Card/TTMusic/entry/src/main/ets/view/player/PlayerPage.ets)
+  继续做 UI 壳层,但状态来源换成 `PlaybackHost`。
+- [PlayerControls.ets](/mnt/d/harmony/2025/qimeng/TTMusic_Card/TTMusic/entry/src/main/ets/view/player/PlayerControls.ets)
+  保持纯组件定位,不新增业务状态。
+
+## 分阶段迁移顺序
+
+### 阶段 1:建立独立宿主
+
+目标:
+
+- 新增 `PlaybackHost`
+- 迁出 `IjkMediaPlayer`
+- 迁出 runtime 注册和 controller actions 注册
+- 迁出基础播放状态源
+- 让 `NewIndex` 挂载独立宿主
+
+验收:
+
+- 即使 `LocalMusic` 不可见,播放器也能存活
+- `PlaybackCoordinator` 的 runtime 来源是 `PlaybackHost`
+
+### 阶段 2:迁出播放器页面和迷你条状态源
+
+目标:
+
+- 迷你播放条和全屏播放页改为从宿主读状态
+- 打开播放页不再依赖 `LocalMusic`
+- `showPlayerView/openPlayerViewFromMiniBar/dismissPlayerView` 等入口统一收敛到宿主
+
+验收:
+
+- 不打开 `LocalMusic` 页面,也能打开播放页并正常播控
+
+### 阶段 3:迁出恢复链路
+
+目标:
+
+- 新增 `PlaybackSnapshotStore`
+- 新增 `PlaybackRestoreCoordinator`
+- 打通“强杀后卡片点击播放 -> 应用拉起 -> 恢复整条队列并播放”
+
+验收:
+
+- 强杀后点击卡片可以恢复整条队列、当前索引和当前进度
+
+### 阶段 4:内容页彻底退化
+
+目标:
+
+- `LocalMusic` 只保留内容页逻辑
+- 其他内容页统一只发请求
+- 删除 `LocalMusic` 中剩余宿主级播放状态写入
+
+验收:
+
+- `LocalMusic` 不再是播放状态源
+- 理论上删除 `LocalMusic` 不应影响播放器主链路,只会影响本地内容页功能
+
+## 错误处理策略
+
+错误处理统一收口在 `PlaybackHost` 与 `PlaybackRestoreCoordinator`,不再分散在 `LocalMusic`。
+
+### 1. 快照读取失败
+
+- 视为无可恢复状态
+- 不阻塞应用启动
+- 记录日志并清理损坏快照
+
+### 2. 队列为空或全部无效
+
+- 清空恢复状态
+- 给出“暂无可恢复播放内容”或“恢复播放失败”的提示
+
+### 3. 当前索引越界
+
+- 自动修正到安全范围
+- 修正后若无有效歌曲,则清空快照
+
+### 4. 当前歌曲恢复失败
+
+- 优先尝试队列中的下一首可播放歌曲
+- 全部失败后再提示恢复失败
+
+### 5. 远程播放 URL 失效
+
+- 绝不复用旧 URL
+- 恢复时统一重新解析
+- 解析失败则本次播放失败,但保留队列状态
+
+### 6. 本地文件不存在
+
+- 跳过当前项尝试下一首
+- 必要时提示文件已失效
+
+### 7. 宿主未就绪时收到动作
+
+- 动作进入待执行队列
+- `PlaybackHost` ready 后统一消费
+- 避免卡片动作、迷你条动作、页面动作打空
+
+## 日志与可观测性
+
+本轮必须新增统一日志前缀,便于排查恢复链路:
+
+- `[playback-host]`
+- `[playback-snapshot]`
+- `[playback-restore]`
+- `[playback-activation]`
+
+至少覆盖以下事件:
+
+- 宿主初始化完成
+- runtime 注册与注销
+- 快照保存与读取
+- 卡片激活动作入队与消费
+- 队列恢复成功与失败
+- 当前歌曲 URL 重建成功与失败
+- 从哪个进度开始恢复播放
+
+## 自动化测试建议
+
+优先覆盖纯逻辑与恢复决策,不强求首轮就完整自动化播放器真机链路。
+
+建议新增 Hypium 或纯逻辑测试:
+
+- `PlaybackSnapshotStore`
+  - 写入后可正确读回
+  - 损坏数据能安全回退
+  - 空快照返回空结果
+- `PlaybackRestoreCoordinator`
+  - 当前索引越界时能修正
+  - 当前歌曲失效时能尝试下一首
+  - 空队列与全失效队列能正确失败
+- `MusicPlaybackController`
+  - 宿主未就绪时动作不会丢失
+  - 激活动作可以被宿主消费
+
+若播放器行为难以自动化,至少把“恢复决策”“快照解析”“索引修正”拆成纯函数做单测。
+
+## 手工验收清单
+
+必须手工验证以下场景:
+
+- 本地歌曲播放后强杀,点击音乐卡片播放,能恢复整条队列与当前进度
+- WebDAV 歌曲播放后强杀,点击音乐卡片播放,能重新解析地址并恢复
+- 当前歌曲失效时,能自动跳到下一首可播歌曲
+- 不进入 `LocalMusic` 页面,也能从排行榜、发现页、歌单详情发起播放
+- 迷你播放条和全屏播放页在 `LocalMusic` 不可见时仍正常
+- `LocalMusic` 打开后不会重复注册 runtime,也不会重新抢占宿主状态
+- 无有效快照时,点击卡片只提示无可恢复内容,不崩溃
+
+## 完成定义
+
+只有以下条件全部成立,才算完成“播放完全独立”:
+
+- 播放 runtime 不再属于 `LocalMusic`
+- `LocalMusic` 不再是播放状态源
+- `NewIndex + PlaybackHost` 成为唯一播放器宿主
+- 强杀后通过音乐卡片可以恢复“整条队列 + 当前索引 + 当前进度”的播放
+- 播放主链路不依赖先进入 `LocalMusic`
+
+## 与既有设计的关系
+
+本设计是对已有两份设计文档的收束与推进:
+
+- [2026-03-29-localmusic2-player-refactor-design.md](/mnt/d/harmony/2025/qimeng/TTMusic_Card/TTMusic/docs/superpowers/specs/2026-03-29-localmusic2-player-refactor-design.md)
+  已提出“播放器宿主应独立于内容页”的方向,但当时仍偏向新入口与大范围文件重组。
+- [2026-04-02-playback-controller-decouple-localmusic-design.md](/mnt/d/harmony/2025/qimeng/TTMusic_Card/TTMusic/docs/superpowers/specs/2026-04-02-playback-controller-decouple-localmusic-design.md)
+  已把 controller/coordinator 入口抽出来,但 runtime 仍未真正脱离 `LocalMusic`。
+
+本次设计的定位是:在现有 controller/coordinator 基础上,补齐“独立宿主 + 强杀恢复 + 内容页彻底退化”这三个关键缺口,完成真正意义上的完全独立。

+ 269 - 0
docs/superpowers/specs/2026-04-04-music-card-background-call-design.md

@@ -0,0 +1,269 @@
+# 音乐卡片后台控播改造设计
+
+## 背景
+
+当前 `TTMusic` 的音乐卡片播放控制没有完全独立:
+
+- 卡片 `play_pause/prev/next/seek_to` 当前优先走 `call` 到 `EntryAbility`
+- 当应用进程已被强杀时,`call` 的接收方不存在,按钮无响应
+- `FormExtensionAbility` 当前只能把事件转发给 `eventHub`,或兜底拉起 `EntryAbility`
+- 一旦走 `EntryAbility` 冷启动兜底,就会把应用前台界面拉起,不符合目标
+
+用户目标已经明确:
+
+- 强杀后,点击卡片 `播放/暂停/上一首/下一首` 仍然可用
+- 这些控制动作不能把应用前台界面拉起
+- 只有明确点击“打开播放器”的动作,才允许进入前台页面
+
+## 平台依据
+
+根据用户提供的鸿蒙官方文档《卡片拉起应用UIAbility到后台(call事件)》:
+
+- 卡片 `postCardAction(... action: 'call')` 可以将指定 `UIAbility` 拉到后台
+- `call` 事件支持指定方法名和参数
+- 该能力依赖 `ohos.permission.KEEP_BACKGROUND_RUNNING`
+- 该路径不要求 `ohos.permission.START_INVISIBLE_ABILITY`
+
+这意味着本项目不必走 `AppServiceExtensionAbility` 或 invisible ability 路线,可以直接采用:
+
+`卡片 -> call -> 后台 UIAbility -> 独立播放控制入口`
+
+## 现状问题
+
+### 1. 控制入口仍然绑在 `EntryAbility`
+
+`TTMusic` 当前卡片控制动作的接收方是 `EntryAbility.callee`。这在应用存活时可用,但在进程被强杀后没有稳定的后台控制入口。
+
+### 2. `FormAbility` 不能直接控播
+
+`MusicCardFormAbility` 当前只负责:
+
+- 更新卡片数据
+- 将消息转发到 `eventHub`
+- 兜底拉起 `EntryAbility`
+
+它不是实际的冷启动控播执行者。
+
+### 3. 播放控制仍依赖页面运行态
+
+当前播放能力虽然已经在做拆分,但仍然与页面侧运行态、页面事件和 `LocalMusic` 生命周期耦合较深。结果是:
+
+- 热态控播能工作
+- 冷态后台控播缺少一个真正独立的宿主
+
+## 设计目标
+
+本次改造只解决音乐卡片后台控播,不做无关重构。
+
+成功标准:
+
+1. 应用未强杀时,卡片 `播放/暂停/上一首/下一首/进度跳转` 可正常工作
+2. 应用被强杀后,卡片 `播放/暂停/上一首/下一首` 可直接生效
+3. 上述控制动作不会拉起前台页面
+4. 卡片“打开播放器”动作仍然可以进入前台播放器页面
+5. 卡片状态在控播后能继续刷新
+
+## 方案对比
+
+### 方案 A:卡片控制统一走后台 `UIAbility`
+
+路径:
+
+`卡片 -> call -> 后台控播 UIAbility -> 独立播放控制器`
+
+优点:
+
+- 与官方文档能力完全一致
+- 不需要 `START_INVISIBLE_ABILITY`
+- 能天然满足“强杀后可控播但不拉前台”
+- 冷启动路径和热启动路径可以统一到同一个控制接口
+
+缺点:
+
+- 需要把现有控播逻辑进一步从页面侧拆出来
+
+### 方案 B:卡片走 `FormAbility.message`
+
+路径:
+
+`卡片 -> message -> FormAbility -> 独立播放控制器`
+
+优点:
+
+- 保持卡片提供方逻辑集中在 `FormAbility`
+
+缺点:
+
+- 与官方推荐的“后台拉起 `UIAbility` 执行 call”路径不一致
+- 当前项目里 `FormAbility` 已经承担卡片数据职责,再承担后台播放器宿主会混杂两类生命周期
+
+### 方案 C:继续复用 `EntryAbility`
+
+路径:
+
+`卡片 -> call/router -> EntryAbility -> 播放控制器`
+
+优点:
+
+- 表面改动最少
+
+缺点:
+
+- 很难稳定保证“不拉前台”
+- 会继续把卡片后台控播和主界面生命周期绑在一起
+
+## 推荐方案
+
+采用方案 A。
+
+具体做法:
+
+- 新增一个专门承接音乐卡片后台 `call` 的 `UIAbility`
+- 该 `UIAbility` 不负责页面展示,只负责注册 `callee.on(...)`
+- 卡片上的 `播放/暂停/上一首/下一首/进度跳转` 全部发送到这个后台控播 `UIAbility`
+- 卡片上的“打开播放器”仍然走 `router` 到 `EntryAbility`
+- 新增一个真正独立的“卡片后台播放宿主”,它不依赖 `LocalMusic` 页面实例
+
+## 目标架构
+
+### 卡片事件分流
+
+- `play_pause` -> `call` -> `MusicCardControlAbility`
+- `prev_song` -> `call` -> `MusicCardControlAbility`
+- `next_song` -> `call` -> `MusicCardControlAbility`
+- `seek_to` -> `call` -> `MusicCardControlAbility`
+- `open_player` -> `router` -> `EntryAbility`
+
+### 后台控播宿主职责
+
+新增后台控播宿主后,它需要负责:
+
+- 接收卡片 `call` 方法和参数
+- 解析动作、表单 ID、跳转位置
+- 恢复已持久化的播放快照和播放队列
+- 在没有页面实例时独立执行 `play/pause/prev/next/seek`
+- 执行后刷新卡片快照和卡片 UI
+
+### 页面侧职责
+
+`LocalMusic` 和页面运行态继续负责:
+
+- 前台播放器 UI
+- 正常播放中的实时状态上报
+- 将最新播放状态、队列和当前歌曲写入持久化存储
+
+页面侧不再承担“卡片强杀后冷启动时的唯一执行入口”。
+
+## 关键设计细节
+
+### 1. 新增后台控播 `UIAbility`
+
+建议新增单独能力,例如:
+
+- `MusicCardControlAbility`
+
+职责:
+
+- 在 `onCreate` 中注册 `callee.on(MusicCardActionConstants.CALL_METHOD_HANDLE_ACTION, ...)`
+- 接收卡片参数后分发给后台控播宿主
+- 不做页面路由,不创建播放器页面
+- 生命周期结束时执行 `callee.off(...)`
+
+该能力在 `module.json5` 中新增 `abilities` 配置。
+
+### 2. 卡片控制动作不再发往 `EntryAbility`
+
+所有卡片页面中的控制按钮都改成:
+
+- `abilityName: 'MusicCardControlAbility'`
+- `action: 'call'`
+
+只有“打开播放器”保留:
+
+- `abilityName: 'EntryAbility'`
+- `action: 'router'`
+
+### 3. 新增后台独立播放控制入口
+
+建议新增一个面向后台卡片控播的控制器,例如:
+
+- `MusicCardBackgroundPlaybackController`
+
+职责:
+
+- 从现有持久化快照中恢复当前歌曲
+- 从现有持久化队列中恢复播放队列和当前下标
+- 执行 `toggle/previous/next/seekTo`
+- 将执行结果写回快照存储
+- 触发 `MusicCardManager.updateAllForms(...)`
+
+该控制器的目标不是替代现有前台控制器,而是提供“无页面实例时也能运行”的最小控播闭环。
+
+### 4. 播放恢复源
+
+后台控播所需的数据优先来自现有持久化层:
+
+- 当前播放快照
+- 当前播放队列
+- 当前下标
+- 当前文件路径
+- 当前播放进度
+
+如果快照存在但队列缺失:
+
+- `play_pause` 至少应支持恢复当前歌曲
+- `prev/next` 在没有有效队列时退化为保持当前歌曲不切换,并记录日志
+
+### 5. 热态与冷态统一接口
+
+最终控制接口应统一为:
+
+- 热态:页面/前台宿主调用同一组控制方法
+- 冷态:后台控播 `UIAbility` 也调用同一组控制方法
+
+这样可以避免出现两套播放切换逻辑不一致的问题。
+
+## 错误处理
+
+- 没有任何可恢复的快照时,`play_pause` 不做前台拉起,只记录日志
+- 队列不可恢复时,`prev/next` 不做前台拉起,只记录日志
+- `seek_to` 参数非法时忽略
+- 后台控播失败时,必须确保不会误触发页面路由
+
+## 测试策略
+
+### 自动化
+
+新增或补充以下测试:
+
+- 卡片动作参数解析测试
+- 后台控播宿主在只有快照时的恢复测试
+- 后台控播宿主在有队列时的上一首/下一首切换测试
+- 控制动作不会落到 `EntryAbility` 的路由测试
+
+### 手工验证
+
+1. 应用前台运行时,卡片 `播放/暂停/上一首/下一首` 正常
+2. 应用切后台但未强杀时,卡片控制正常且不拉前台
+3. 强杀应用后,点击卡片播放可直接播放,不拉前台
+4. 强杀应用后,点击卡片上一首/下一首可切歌,不拉前台
+5. 点击卡片封面或“打开播放器”时,前台页面正常打开
+
+## 实施顺序
+
+1. 新增后台控播 `UIAbility` 配置与空实现
+2. 将卡片控制按钮的 `call` 目标切换到新能力
+3. 提炼后台独立播放控制入口
+4. 接入快照/队列恢复逻辑
+5. 接入卡片刷新
+6. 补测试并验证强杀场景
+
+## 范围外
+
+本次不处理:
+
+- 全量重构现有前台播放器架构
+- 远程歌曲缓存策略重做
+- 卡片 UI 视觉调整
+- 非音乐卡片场景的后台唤起能力复用
+

+ 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 正常显示
+

+ 0 - 1
entry/src/main/ets/common/player/MusicCardActionConstants.ets

@@ -13,5 +13,4 @@ export class MusicCardActionConstants {
   static readonly ACTION_SEEK_TO: string = 'seek_to'
 
   static readonly PENDING_ACTION_STORAGE_KEY: string = 'musicCardPendingAction'
-  static readonly PENDING_OPEN_STORAGE_KEY: string = 'musicCardPendingOpenPlayer'
 }

+ 11 - 0
entry/src/main/ets/common/player/MusicCardActionRouteHelper.ets

@@ -0,0 +1,11 @@
+import { MusicCardActionConstants } from './MusicCardActionConstants'
+
+export function shouldIgnoreWidgetWantAction(action: string, source: string): boolean {
+  if (source !== MusicCardActionConstants.ACTION_SOURCE_WIDGET) {
+    return false
+  }
+  return action === MusicCardActionConstants.ACTION_PLAY_PAUSE
+    || action === MusicCardActionConstants.ACTION_PREVIOUS
+    || action === MusicCardActionConstants.ACTION_NEXT
+    || action === MusicCardActionConstants.ACTION_SEEK_TO
+}

+ 4 - 0
entry/src/main/ets/common/player/MusicCardManager.ets

@@ -81,6 +81,10 @@ export class MusicCardManager {
     return MusicCardManager.instance
   }
 
+  static shouldPagePublishPlaybackState(isHostSnapshotDisplayOnly: boolean): boolean {
+    return !isHostSnapshotDisplayOnly
+  }
+
   static buildSnapshotForTest(
     song: VideoItem | undefined,
     isPlaying: boolean,

+ 1 - 1
entry/src/main/ets/common/player/MusicCardSnapshotStore.ets

@@ -97,7 +97,7 @@ export class MusicCardSnapshotStore {
     try {
       const payload = JSON.stringify(snapshot ?? createEmptyMusicCardSnapshot())
       preferences.putSync(SNAPSHOT_KEY, payload)
-      preferences.flush()
+      preferences.flushSync()
       return true
     } catch (error) {
       Logger.error(TAG, `writeSnapshot failed: ${formatError(error as Object)}`)

+ 5 - 0
entry/src/main/ets/common/player/PlaybackControlStateHelper.ets

@@ -0,0 +1,5 @@
+import { PlayStatus } from '../PlayStatus'
+
+export function isPlaybackControlPlaying(status: number): boolean {
+  return status === PlayStatus.PLAY
+}

+ 1 - 1
entry/src/main/ets/common/util/LyricUtil.ets

@@ -1,7 +1,7 @@
 import fs from '@ohos.file.fs';
 import common from '@ohos.app.ability.common';
 import fileIo from '@ohos.file.fs';
-import { LyricWord } from '@seagazer/cclyric/src/main/ets/bean/LyricWord';
+import { LyricWord } from '../../lyric/bean/LyricWord';
 
 // 定义接口来描述返回值的类型
 interface ParseResult {

+ 3 - 0
entry/src/main/ets/common/util/MiniPlayerHostViewHelper.ets

@@ -0,0 +1,3 @@
+export function shouldShowMiniPlayerBar(isMultiSelect: boolean, mType: number): boolean {
+  return !(isMultiSelect || (mType > 0 && mType < 6))
+}

+ 14 - 0
entry/src/main/ets/common/util/MiniPlayerMirrorStateHelper.ets

@@ -0,0 +1,14 @@
+export class MiniPlayerMirrorState {
+  controlPlayStatus: number = 0
+  progressValue: number = 0
+  cover: string = ''
+}
+
+export function resolveMiniPlayerMirrorState(controlPlayStatus: number, progressValue: number,
+  cover: string | undefined): MiniPlayerMirrorState {
+  const mirror = new MiniPlayerMirrorState()
+  mirror.controlPlayStatus = controlPlayStatus
+  mirror.progressValue = progressValue
+  mirror.cover = cover ?? ''
+  return mirror
+}

+ 27 - 0
entry/src/main/ets/common/util/PlayerLyricUpdateTargetsHelper.ets

@@ -0,0 +1,27 @@
+export interface PlayerLyricUpdateTargets {
+  main: boolean
+  pip: boolean
+  singleLine: boolean
+}
+
+export function resolvePlayerLyricUpdateTargets(
+  isAppForeground: boolean,
+  isPageVisible: boolean,
+  showPipLyric: boolean,
+  isShowSingleLineLyric: boolean
+): PlayerLyricUpdateTargets {
+  if (!isAppForeground || !isPageVisible) {
+    return {
+      main: false,
+      pip: false,
+      singleLine: false
+    }
+  }
+
+  return {
+    // 主歌词面板需要持续跟随播放时钟,避免切回歌词页后停在旧位置。
+    main: true,
+    pip: showPipLyric,
+    singleLine: isShowSingleLineLyric
+  }
+}

+ 3 - 0
entry/src/main/ets/common/util/PlayerPageOpenDispatchHelper.ets

@@ -0,0 +1,3 @@
+export function resolvePlayerPageOpenDelayMs(mType: number): number {
+  return mType === 0 ? 0 : 32
+}

+ 52 - 0
entry/src/main/ets/controller/AvSessionController.ets

@@ -25,9 +25,24 @@ import { SettingPage } from '../pages/SettingPage';
 import { Utility } from '../common/util/Utility';
 import LyricUtil from '../common/util/LyricUtil';
 import { imagePathToPixelMap } from '../common/util/CommUtils';
+import { PlaybackSnapshot, resolvePlaybackSnapshotProgressValue } from '../playback/model/PlaybackSnapshot';
 
 const TAG = 'onecold AvSessionController';
 
+export interface SnapshotPlaybackBarState {
+  isPlaying: boolean
+  progressValue: number
+}
+
+export interface SnapshotPlaybackClockState {
+  positionMs: number
+  durationMs: number
+  currentTimeText: string
+  totalTimeText: string
+  durationTime: number
+  slideEnable: boolean
+}
+
 export class AvSessionController {
   private static instance: AvSessionController | null;
   private context: common.UIAbilityContext | undefined = undefined;
@@ -45,6 +60,43 @@ export class AvSessionController {
     return AvSessionController.instance;
   }
 
+  public static shouldPageWritePlaybackSession(isHostSnapshotDisplayOnly: boolean): boolean {
+    return !isHostSnapshotDisplayOnly
+  }
+
+  public static resolvePlaybackBarStateFromSnapshot(snapshot: PlaybackSnapshot,
+    maxValue: number): SnapshotPlaybackBarState {
+    return {
+      isPlaying: snapshot.isPlaying,
+      progressValue: resolvePlaybackSnapshotProgressValue(snapshot.positionMs, snapshot.durationMs, maxValue)
+    }
+  }
+
+  public static resolvePlaybackClockState(positionMs: number, durationMs: number): SnapshotPlaybackClockState {
+    const safeDurationMs = Math.max(0, durationMs)
+    const safePositionMs = Math.max(0, safeDurationMs > 0 ? Math.min(positionMs, safeDurationMs) : positionMs)
+    return {
+      positionMs: safePositionMs,
+      durationMs: safeDurationMs,
+      currentTimeText: AvSessionController.formatPlaybackClock(safePositionMs),
+      totalTimeText: AvSessionController.formatPlaybackClock(safeDurationMs),
+      durationTime: Math.floor(safeDurationMs / 1000),
+      slideEnable: safeDurationMs > 0
+    }
+  }
+
+  private static formatPlaybackClock(timeMs: number): string {
+    const totalSeconds = Math.max(0, Math.floor(timeMs / 1000))
+    const hours = Math.floor(totalSeconds / 3600)
+    const minutes = Math.floor((totalSeconds % 3600) / 60)
+    const seconds = totalSeconds % 60
+    if (hours > 0) {
+      return `${hours.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}:` +
+        `${seconds.toString().padStart(2, '0')}`
+    }
+    return `${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`
+  }
+
   public initAvSession(isVideo:boolean) {
     if(isVideo){
       let isBgPlayOpen = PreferencesUtil.getBooleanSync(SettingPage.IS_BGPLAY_OPEN,true)

+ 42 - 3
entry/src/main/ets/controller/MusicPlaybackController.ets

@@ -1,8 +1,9 @@
 import { emitter } from '@kit.BasicServicesKit'
 import { EventConstants } from '../common/constants/EventConstants'
+import { MusicCardActionConstants } from '../common/player/MusicCardActionConstants'
 import { buildPlaylistDispatchFilePaths } from '../common/util/PlaylistPlayDispatchHelper'
 import { PlaylistPlayRequest, savePendingPlaylistPlay } from '../common/util/PlaylistPlayRequestStore'
-import { setFindPlaylist } from '../common/util/FindPlaylistStore'
+import { FindPlaylistMeta, setFindPlaylist } from '../common/util/FindPlaylistStore'
 import { PlaybackCoordinator } from './PlaybackCoordinator'
 import { VideoItem } from '../viewmodel/VideoItem'
 
@@ -17,6 +18,12 @@ export interface MusicPlaybackControllerActions {
   seekTo: (value: string, source?: string) => void | Promise<void>
 }
 
+export interface MusicPlaybackHostActions {
+  showPlayerView: () => void | Promise<void>
+  dismissPlayerView: () => void | Promise<void>
+  openPlayList: () => void | Promise<void>
+}
+
 /**
  * 内存歌单直接播放时所需的参数集合。
  */
@@ -27,6 +34,7 @@ export interface InMemoryPlaylistPlaybackOptions {
   startIndex: number
   isJump?: boolean
   playType?: number
+  findPlaylistMeta?: FindPlaylistMeta
 }
 
 /**
@@ -100,6 +108,7 @@ export type ItemPlaybackToggleAction = 'toggle_current' | 'play_target'
 export class MusicPlaybackController {
   private static instance?: MusicPlaybackController
   private actions?: MusicPlaybackControllerActions
+  private hostActions?: MusicPlaybackHostActions
   private playbackCoordinator: PlaybackCoordinator = PlaybackCoordinator.getInstance()
 
   /**
@@ -186,6 +195,10 @@ export class MusicPlaybackController {
     return 2
   }
 
+  public static shouldOpenPlayerForMusicCardAction(action: string): boolean {
+    return action === MusicCardActionConstants.ACTION_OPEN_PLAYER
+  }
+
   /**
    * 判断 AVSession 的 setLoopMode 回调是否是由应用内部刚上报的同值模式触发。
    *
@@ -446,6 +459,16 @@ export class MusicPlaybackController {
     }
   }
 
+  public setHostActions(actions: MusicPlaybackHostActions): void {
+    this.hostActions = actions
+  }
+
+  public clearHostActions(actions?: MusicPlaybackHostActions): void {
+    if (!actions || this.hostActions === actions) {
+      this.hostActions = undefined
+    }
+  }
+
   /**
    * 直接请求协调器播放单曲,不依赖页面事件宿主。
    *
@@ -544,7 +567,11 @@ export class MusicPlaybackController {
    * @returns 模式切换完成后的 Promise。
    */
   public async setLoopMode(): Promise<void> {
-    await this.actions?.setLoopMode?.()
+    if (this.actions?.setLoopMode) {
+      await this.actions.setLoopMode()
+      return
+    }
+    await this.playbackCoordinator.setLoopMode()
   }
 
   /**
@@ -562,6 +589,18 @@ export class MusicPlaybackController {
     await this.playbackCoordinator.seekTo(value, source)
   }
 
+  public async showPlayerView(): Promise<void> {
+    await this.hostActions?.showPlayerView?.()
+  }
+
+  public async dismissPlayerView(): Promise<void> {
+    await this.hostActions?.dismissPlayerView?.()
+  }
+
+  public async openPlayList(): Promise<void> {
+    await this.hostActions?.openPlayList?.()
+  }
+
   /**
    * 通过事件总线发起一组内存歌曲的统一播放,并预先写入歌单上下文。
    *
@@ -573,7 +612,7 @@ export class MusicPlaybackController {
     }
 
     const request = MusicPlaybackController.buildInMemoryPlaylistPlayRequest(options)
-    setFindPlaylist(request.playlistId, request.playlistName, options.songs, request.startIndex)
+    setFindPlaylist(request.playlistId, request.playlistName, options.songs, request.startIndex, options.findPlaylistMeta)
     savePendingPlaylistPlay(request)
     emitter.emit({ eventId: EventConstants.EVENT_PLAYLIST_PLAY }, { data: request })
   }

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

@@ -9,6 +9,7 @@ export interface PlaybackRuntime {
   playOrPause: () => Promise<void>
   playNext: () => Promise<void>
   playPrevious: () => Promise<void>
+  setLoopMode?: () => Promise<void>
   seekTo: (value: string, source?: string) => Promise<void>
 }
 
@@ -18,6 +19,7 @@ export interface PlaybackRuntime {
 export class PlaybackCoordinator {
   private static instance?: PlaybackCoordinator
   private runtime?: PlaybackRuntime
+  private defaultRuntime?: PlaybackRuntime
   private stateBridge: PlaybackStateBridge = PlaybackStateBridge.getInstance()
 
   /**
@@ -46,6 +48,21 @@ export class PlaybackCoordinator {
     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 +78,10 @@ export class PlaybackCoordinator {
     return this.runtime !== undefined
   }
 
+  private resolveRuntime(): PlaybackRuntime | undefined {
+    return this.runtime ?? this.defaultRuntime
+  }
+
   /**
    * 请求运行时播放指定队列,并在失败时恢复之前的播放状态。
    *
@@ -71,13 +92,14 @@ export class PlaybackCoordinator {
    * @returns 播放请求完成后的 Promise。
    */
   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
     }
     const previousSnapshot = this.stateBridge.getSnapshot()
     this.stateBridge.replaceQueue(queue, startIndex)
     try {
-      await this.runtime.playQueue(queue, this.stateBridge.getSnapshot().currentIndex, source, playType)
+      await runtime.playQueue(queue, this.stateBridge.getSnapshot().currentIndex, source, playType)
     } catch (error) {
       this.stateBridge.restoreSnapshot(previousSnapshot)
       const err = error as Error
@@ -103,7 +125,7 @@ export class PlaybackCoordinator {
    * @returns 切换动作完成后的 Promise。
    */
   public async playOrPause(): Promise<void> {
-    await this.runtime?.playOrPause()
+    await this.resolveRuntime()?.playOrPause()
   }
 
   /**
@@ -112,7 +134,7 @@ export class PlaybackCoordinator {
    * @returns 切歌动作完成后的 Promise。
    */
   public async playNext(): Promise<void> {
-    await this.runtime?.playNext()
+    await this.resolveRuntime()?.playNext()
   }
 
   /**
@@ -121,7 +143,16 @@ export class PlaybackCoordinator {
    * @returns 切歌动作完成后的 Promise。
    */
   public async playPrevious(): Promise<void> {
-    await this.runtime?.playPrevious()
+    await this.resolveRuntime()?.playPrevious()
+  }
+
+  /**
+   * 切换当前播放模式。
+   *
+   * @returns 模式切换完成后的 Promise。
+   */
+  public async setLoopMode(): Promise<void> {
+    await this.resolveRuntime()?.setLoopMode?.()
   }
 
   /**
@@ -132,6 +163,6 @@ export class PlaybackCoordinator {
    * @returns 跳转动作完成后的 Promise。
    */
   public async seekTo(value: string, source?: string): Promise<void> {
-    await this.runtime?.seekTo(value, source)
+    await this.resolveRuntime()?.seekTo(value, source)
   }
 }

+ 59 - 17
entry/src/main/ets/entryability/EntryAbility.ets

@@ -14,7 +14,7 @@ import window from '@ohos.window';
 import { AbilityConstant, appRecovery, Want } from '@kit.AbilityKit';
 import { BusinessError, emitter } from '@kit.BasicServicesKit';
 import { rpc } from '@kit.IPCKit';
-import { AppUtil, ArrayUtil, GlobalContext, LogUtil, StrUtil } from '@pura/harmony-utils';
+import { AppUtil, ArrayUtil, GlobalContext, LogUtil, PreferencesUtil, StrUtil } from '@pura/harmony-utils';
 import { DemoConstants } from './DemoConstants';
 import { EventConstants } from '../common/constants/EventConstants';
 
@@ -31,9 +31,13 @@ import { display } from '@kit.ArkUI';
 import { ServerLogUtil } from '../common/util/ServerLogUtil';
 import { PlaylistBackupManager } from '../common/util/PlaylistBackupManager';
 import { MusicCardActionConstants } from '../common/player/MusicCardActionConstants';
+import { shouldIgnoreWidgetWantAction } from '../common/player/MusicCardActionRouteHelper';
 import { MusicPlaybackController } from '../controller/MusicPlaybackController';
 import { PlaybackCoordinator } from '../controller/PlaybackCoordinator';
 import { MusicCardFormStore } from '../common/player/MusicCardFormStore';
+import { requestPlaybackPlayerOpen } from '../playback/PlaybackHostState';
+import { PlaybackRestoreCoordinator } from '../playback/PlaybackRestoreCoordinator';
+import { BackgroundAudioPlaybackHost } from '../playback/BackgroundAudioPlaybackHost';
 
 const MUSIC_CARD_ACTION_PATTERN: RegExp = /"ttmusic_music_card_action"\s*:\s*"([^"]*)"/;
 const MUSIC_CARD_FORM_ID_STRING_PATTERN: RegExp = /"ttmusic_music_card_form_id"\s*:\s*"([^"]*)"/;
@@ -66,15 +70,17 @@ export default class EntryAbility extends UIAbility {
             const source: string = this.resolveMusicCardSourceFromText(rawText);
             const formId: string = this.resolveMusicCardFormIdFromText(rawText);
             const seekPositionMs: string = this.resolveMusicCardSeekPositionFromText(rawText);
+            Logger.info('EntryAbility',
+                `[MusicCast] call enter action=${action}, source=${source}, formId=${formId}, seek=${seekPositionMs}, raw=${rawText}`);
             this.registerMusicCardFormIdIfNeeded(formId, source);
             if (StrUtil.isEmpty(action)) {
-                Logger.warn('EntryAbility', `musicCardCallHandler skipped because action empty payload=${rawText}`);
+                Logger.warn('EntryAbility', `[MusicCast] call skipped because action empty payload=${rawText}`);
                 return new EmptyRpcParcelable();
             }
             this.dispatchMusicCardAction(action, 'call', seekPositionMs);
         } catch (error) {
             const err = error as Error;
-            Logger.error('EntryAbility', `musicCardCallHandler failed: ${err.message}`);
+            Logger.error('EntryAbility', `[MusicCast] call failed: ${err.message}`);
         }
         return new EmptyRpcParcelable();
     }
@@ -88,19 +94,19 @@ export default class EntryAbility extends UIAbility {
         }
         Logger.info('EntryAbility', `musicCardActionForward action=${action}`);
         if (action === 'play_pause') {
-            this.context.eventHub?.emit('playOrPause');
+            void MusicPlaybackController.getInstance().playOrPause();
             return;
         }
         if (action === 'prev_song') {
-            this.context.eventHub?.emit('playPrevious');
+            void MusicPlaybackController.getInstance().playPrevious();
             return;
         }
         if (action === 'next_song') {
-            this.context.eventHub?.emit('playNext');
+            void MusicPlaybackController.getInstance().playNext();
             return;
         }
         if (action === 'open_player') {
-            this.context.eventHub?.emit('showPlayerView');
+            void MusicPlaybackController.getInstance().showPlayerView();
             return;
         }
         if (action === MusicCardActionConstants.ACTION_SEEK_TO) {
@@ -169,7 +175,15 @@ export default class EntryAbility extends UIAbility {
     }
 
     async onCreate(want:Want, launchParam:AbilityConstant.LaunchParam) {
+        AppUtil.init(this.context);
         AppStorage.setOrCreate('context', this.context);
+        BackgroundAudioPlaybackHost.getInstance().setContext(this.context);
+        try {
+            PreferencesUtil.init('SP_HARMONY_UTILS_PREFERENCES');
+            Logger.info('EntryAbility', '[MusicCast] PreferencesUtil init success onCreate');
+        } catch (error) {
+            Logger.error('EntryAbility', `[MusicCast] PreferencesUtil init failed onCreate: ${(error as Error).message}`);
+        }
         this.context.eventHub?.on('musicCardActionForward', this.musicCardActionForwardHandler);
         this.registerMusicCardCallHandler();
         this.handleMusicCardActionFromWant(want);
@@ -237,6 +251,14 @@ export default class EntryAbility extends UIAbility {
     async onNewWant(want: Want, launchParam: AbilityConstant.LaunchParam): Promise<void> {
         hilog.info(0x0000, 'testTag', `onNewWant, want=${JSON.stringify(want)}`);
         super.onNewWant(want, launchParam);
+        AppUtil.init(this.context);
+        BackgroundAudioPlaybackHost.getInstance().setContext(this.context);
+        try {
+            PreferencesUtil.init('SP_HARMONY_UTILS_PREFERENCES');
+            Logger.info('EntryAbility', '[MusicCast] PreferencesUtil init success onNewWant');
+        } catch (error) {
+            Logger.error('EntryAbility', `[MusicCast] PreferencesUtil init failed onNewWant: ${(error as Error).message}`);
+        }
         this.handleMusicCardActionFromWant(want);
         this.loadDoWant(want)
         await this.handleParam(want)
@@ -258,54 +280,71 @@ export default class EntryAbility extends UIAbility {
         if (StrUtil.isEmpty(action)) {
             return;
         }
+        if (shouldIgnoreWidgetWantAction(action, source)) {
+            Logger.info('EntryAbility',
+                `[MusicCast] want ignored because call will handle action=${action}, source=${source}, formId=${formId}`);
+            return;
+        }
+        Logger.info('EntryAbility',
+            `[MusicCast] want enter action=${action}, source=${source}, formId=${formId}, seek=${seekPositionMs}`);
         this.dispatchMusicCardAction(action, 'want', seekPositionMs);
     }
 
     private dispatchMusicCardAction(action: string, source: string, seekPositionMs: string = ''): void {
-        Logger.info('EntryAbility', `dispatch music card action=${action}, source=${source}, seek=${seekPositionMs}`);
+        Logger.info('EntryAbility', `[MusicCast] dispatch action=${action}, source=${source}, seek=${seekPositionMs}`);
         const playbackController: MusicPlaybackController = MusicPlaybackController.getInstance();
         const playbackCoordinator: PlaybackCoordinator = PlaybackCoordinator.getInstance();
+        const backgroundHost = BackgroundAudioPlaybackHost.getInstance();
         if (action === MusicCardActionConstants.ACTION_PLAY_PAUSE) {
             if (playbackCoordinator.hasRuntime()) {
+                Logger.info('EntryAbility', '[MusicCast] dispatch -> runtime playOrPause');
                 void playbackController.playOrPause();
             } else {
-                AppStorage.setOrCreate(MusicCardActionConstants.PENDING_ACTION_STORAGE_KEY, action);
+                Logger.info('EntryAbility', '[MusicCast] dispatch -> background host playOrPause');
+                void backgroundHost.handleControlAction(action);
             }
             return;
         }
         if (action === MusicCardActionConstants.ACTION_PREVIOUS) {
             if (playbackCoordinator.hasRuntime()) {
+                Logger.info('EntryAbility', '[MusicCast] dispatch -> runtime playPrevious');
                 void playbackController.playPrevious();
             } else {
-                AppStorage.setOrCreate(MusicCardActionConstants.PENDING_ACTION_STORAGE_KEY, action);
+                Logger.info('EntryAbility', '[MusicCast] dispatch -> background host playPrevious');
+                void backgroundHost.handleControlAction(action);
             }
             return;
         }
         if (action === MusicCardActionConstants.ACTION_NEXT) {
             if (playbackCoordinator.hasRuntime()) {
+                Logger.info('EntryAbility', '[MusicCast] dispatch -> runtime playNext');
                 void playbackController.playNext();
             } else {
-                AppStorage.setOrCreate(MusicCardActionConstants.PENDING_ACTION_STORAGE_KEY, action);
+                Logger.info('EntryAbility', '[MusicCast] dispatch -> background host playNext');
+                void backgroundHost.handleControlAction(action);
             }
             return;
         }
         if (action === MusicCardActionConstants.ACTION_OPEN_PLAYER) {
+            Logger.info('EntryAbility', '[MusicCast] dispatch -> routeToMusicPlayerPage');
             this.routeToMusicPlayerPage();
             return;
         }
         if (action === MusicCardActionConstants.ACTION_SEEK_TO) {
             if (!playbackCoordinator.hasRuntime()) {
-                Logger.warn('EntryAbility', `music card seek ignored because runtime missing, source=${source}`);
+                Logger.info('EntryAbility', `[MusicCast] dispatch -> background host seekTo=${seekPositionMs}`);
+                void backgroundHost.handleControlAction(action, seekPositionMs);
                 return;
             }
             if (StrUtil.isEmpty(seekPositionMs)) {
-                Logger.warn('EntryAbility', `music card seek ignored because seek empty, source=${source}`);
+                Logger.warn('EntryAbility', `[MusicCast] seek ignored because seek empty, source=${source}`);
                 return;
             }
+            Logger.info('EntryAbility', `[MusicCast] dispatch -> runtime seekTo=${seekPositionMs}`);
             void playbackController.seekTo(seekPositionMs, 'music-card-widget');
             return;
         }
-        Logger.warn('EntryAbility', `music card action ignored unsupported action=${action}, source=${source}`);
+        Logger.warn('EntryAbility', `[MusicCast] action ignored unsupported action=${action}, source=${source}`);
     }
 
     private registerMusicCardCallHandler(): void {
@@ -389,11 +428,14 @@ export default class EntryAbility extends UIAbility {
     }
 
     private routeToMusicPlayerPage(): void {
-        if (PlaybackCoordinator.getInstance().hasRuntime()) {
-            this.context.eventHub?.emit('showPlayerView');
+        const openRoute = PlaybackRestoreCoordinator.resolveMusicCardOpenRoute(
+            PlaybackCoordinator.getInstance().hasRuntime()
+        );
+        if (openRoute === 'show_player_now') {
+            void MusicPlaybackController.getInstance().showPlayerView();
             return;
         }
-        AppStorage.setOrCreate(MusicCardActionConstants.PENDING_OPEN_STORAGE_KEY, true);
+        requestPlaybackPlayerOpen();
     }
 
     private handleWeChatCallIfNeed(want: Want) {

+ 0 - 0
lib/src/main/ets/LyricController.ets → entry/src/main/ets/lyric/LyricController.ets


+ 0 - 0
lib/src/main/ets/LyricHelper.ets → entry/src/main/ets/lyric/LyricHelper.ets


+ 0 - 0
lib/src/main/ets/bean/Lyric.ts → entry/src/main/ets/lyric/bean/Lyric.ts


+ 0 - 0
lib/src/main/ets/bean/LyricLine.ts → entry/src/main/ets/lyric/bean/LyricLine.ts


+ 0 - 0
lib/src/main/ets/bean/LyricLineMeasureResult.ets → entry/src/main/ets/lyric/bean/LyricLineMeasureResult.ets


+ 0 - 0
lib/src/main/ets/bean/LyricLineWrapper.ts → entry/src/main/ets/lyric/bean/LyricLineWrapper.ts


+ 0 - 0
lib/src/main/ets/bean/LyricWord.ts → entry/src/main/ets/lyric/bean/LyricWord.ts


+ 0 - 0
lib/src/main/ets/extensions/ListAdapter.ets → entry/src/main/ets/lyric/extensions/ListAdapter.ets


+ 4 - 1
lib/src/main/ets/parse/FileParser.ets → entry/src/main/ets/lyric/parse/FileParser.ets

@@ -1,6 +1,9 @@
-import { IParser, Lyric, LyricParser, LyricLine } from '@seagazer/cclyric';
+
 import fs from '@ohos.file.fs'
+import { Lyric } from '../bean/Lyric';
 import { printD, printW } from '../extensions/Extension';
+import { IParser } from './IParser';
+import { LyricParser } from './LyricParser';
 
 /**
  * Author: seagazer

+ 0 - 0
lib/src/main/ets/parse/LyricParser.ts → entry/src/main/ets/lyric/parse/LyricParser.ts


+ 0 - 0
lib/src/main/ets/view/CcLyricView.ets → entry/src/main/ets/lyric/view/CcLyricView.ets


+ 0 - 29
lib/src/main/ets/view/LyricView2.ets → entry/src/main/ets/lyric/view/LyricView2.ets

@@ -686,35 +686,6 @@ export struct LyricView2 {
     }
 
 
-    @Builder
-    SeekLine() {
-        Row() {
-            Image($r('app.media.cclyric_play'))
-                .width(24)
-                .height(24)
-                .fillColor(this.seekUIColor)
-                .objectFit(ImageFit.Fill)
-                .clickEffect({ level: ClickEffectLevel.MIDDLE })
-                .onClick(() => {
-                    if (this.seekIndex != -1 && this.seekIndex != this.currentIndex) {
-                        this.handleSeekAction()
-                    }
-                })
-            Stack()
-                .height(1)
-                .layoutWeight(1)
-                .backgroundColor(this.seekLineColor)
-                .margin({ left: 8, right: 8 })
-            Text(this.scrollDurationText)
-                .fontSize(this.textSize)
-                .fontColor(this.seekUIColor)
-        }
-        .visibility(this.enableSeek && this.isUserTouching ? Visibility.Visible : Visibility.Hidden)
-        .width('100%')
-        .height('100%')
-        .hitTestBehavior(HitTestMode.Transparent)
-        .transition(TransitionEffect.OPACITY.animation({ duration: this.animDuration }))
-    }
 
     build() {
         Stack() {

+ 137 - 14
entry/src/main/ets/pages/NewIndex.ets

@@ -66,11 +66,25 @@ import { MiniPlayerBar } from '../view/MiniPlayerBar';
 import {
   resolveMiniPlayerMorphTarget,
 } from '../common/util/PlayerDismissHelper';
+import { shouldShowMiniPlayerBar } from '../common/util/MiniPlayerHostViewHelper';
+import { resolvePlayerPageOpenDelayMs } from '../common/util/PlayerPageOpenDispatchHelper';
+import { resolveMiniPlayerMirrorState } from '../common/util/MiniPlayerMirrorStateHelper';
 import {
   MiniPlayerOrbTapAction,
   resolveMiniPlayerOrbTapAction,
   shouldTriggerMiniPlayerOrbSingleTap
 } 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';
 
@@ -93,9 +107,9 @@ struct NewIndex {
     new hdsEffect.ShaderEffectController():undefined;
   @State defalut_home_type:number = 0//首页默认类型 可支持首页 媒体库 歌单 网盘
   @State isDetailView: boolean = false; // 是否在艺术家/专辑详情视图
-  @Provide @Watch('syncMiniPlayerVisibility') isShowPlay: boolean = false;
-  @Provide CONTROL_PlayStatus: number = PlayStatus.INIT;
-  @Provide progressValue: number = 0;
+  @StorageLink('isShowPlay') @Watch('syncMiniPlayerVisibility') isShowPlay: boolean = false;
+  @Provide @Watch('onMiniPlayerControlStatusChanged') CONTROL_PlayStatus: number = PlayStatus.INIT;
+  @Provide @Watch('onMiniPlayerProgressChanged') progressValue: number = 0;
   @State isShowPrecious: boolean = false //播控条显示上一首按钮
   @State bottomBarHeight: number = 64;
   // 迷你播放条的整体形变状态,既用于出现/消失,也用于 full bar <-> orb 切换。
@@ -119,12 +133,22 @@ struct NewIndex {
   @State isMiniPlayerOrbMode: boolean = false;
   @State isMiniPlayerMounted: boolean = true;
   @StorageProp('bottomSafeHeight') bottomSafeHeight: number = 0;
-  @Provide cover: string | undefined = '';
+  @Provide @Watch('onMiniPlayerCoverChanged') cover: string | undefined = '';
   // @Provide currentSong: VideoItem | undefined = undefined;
   @StorageLink('currentSong') currentSong: VideoItem | undefined = undefined;
+  @StorageLink('CONTROL_PlayStatus') @Watch('onHostMiniPlayerControlStatusChanged')
+  hostControlPlayStatus: number = PlayStatus.INIT;
+  @StorageLink('progressValue') @Watch('onHostMiniPlayerProgressChanged')
+  hostProgressValue: number = 0;
+  @StorageLink('cover') @Watch('onHostMiniPlayerCoverChanged')
+  hostCover: string | undefined = '';
+  @StorageLink('playbackRuntimeReady') @Watch('syncPlaybackRuntimeBinding')
+  playbackRuntimeReady: boolean = false;
   @Provide @Watch('syncMiniPlayerVisibility') isMultiSelect: boolean = false;
   /** 页面上下文 */
   context = this.getUIContext().getHostContext() as common.UIAbilityContext
+  private playbackController: MusicPlaybackController = MusicPlaybackController.getInstance()
+  private playbackCoordinator: PlaybackCoordinator = PlaybackCoordinator.getInstance()
   @Provide currentSongListName:string = '' //当前歌单名称
   @Provide  currentSongListID:string='' //当前歌单ID
   @State isDarkMode: boolean = false
@@ -220,6 +244,72 @@ struct NewIndex {
   private readonly miniPlayerModeBounceDuration: number = 220;
   private readonly miniPlayerOrbDoubleTapWindowMs: number = 240;
   private isMiniPlayerModeTransitioning: boolean = false;
+  private pendingPlayerPageOpenTimer: number = -1;
+  private readonly playbackHostActions: MusicPlaybackHostActions = {
+    showPlayerView: (): void => {
+      LogUtil.info(TAG, '[MiniState] hostActions.showPlayerView -> requestPlaybackPlayerOpen')
+      requestPlaybackPlayerOpen()
+    },
+    dismissPlayerView: (): void => {
+      showPlaybackPlayer(false)
+    },
+    openPlayList: (): void => {
+      requestPlaybackPlaylistOpen()
+    }
+  }
+
+  private logMiniPlayerState(stage: string): void {
+    LogUtil.info(TAG,
+      `[MiniState] ${stage} localStatus=${this.CONTROL_PlayStatus}, hostStatus=${this.hostControlPlayStatus}, ` +
+      `localProgress=${this.progressValue}, hostProgress=${this.hostProgressValue}, ` +
+      `localCover=${this.cover ?? ''}, hostCover=${this.hostCover ?? ''}, ` +
+      `song=${this.currentSong?.name ?? ''}`)
+  }
+
+  private syncMiniPlayerStateFromHost(reason: string): void {
+    const mirror = resolveMiniPlayerMirrorState(this.hostControlPlayStatus, this.hostProgressValue, this.hostCover)
+    this.CONTROL_PlayStatus = mirror.controlPlayStatus
+    this.progressValue = mirror.progressValue
+    this.cover = mirror.cover
+    this.logMiniPlayerState(`mirrored from host: ${reason}`)
+  }
+
+  private onMiniPlayerControlStatusChanged(): void {
+    this.logMiniPlayerState('local status changed')
+  }
+
+  private onMiniPlayerProgressChanged(): void {
+    this.logMiniPlayerState('local progress changed')
+  }
+
+  private onMiniPlayerCoverChanged(): void {
+    this.logMiniPlayerState('local cover changed')
+  }
+
+  private onHostMiniPlayerControlStatusChanged(): void {
+    this.logMiniPlayerState('host status changed')
+    this.syncMiniPlayerStateFromHost('status')
+  }
+
+  private onHostMiniPlayerProgressChanged(): void {
+    this.logMiniPlayerState('host progress changed')
+    this.syncMiniPlayerStateFromHost('progress')
+  }
+
+  private onHostMiniPlayerCoverChanged(): void {
+    this.logMiniPlayerState('host cover changed')
+    this.syncMiniPlayerStateFromHost('cover')
+  }
+
+  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() {
     if(this.tabSelectedIndexes[0]==1){
@@ -256,7 +346,7 @@ struct NewIndex {
     console.info('onecold onBackPress isDetailView = '+ this.isDetailView);
     console.info('onecold onBackPress mType = '+  this.mType);
     if (this.isShowPlay) {
-      this.getUIContext().getHostContext()!.eventHub.emit('dismissPlayerView');
+      void this.playbackController.dismissPlayerView();
       return true
     }
     if (this.mType === 4) {
@@ -357,6 +447,11 @@ struct NewIndex {
    */
 
   async aboutToAppear() {
+    ensurePlaybackHostStorageDefaults()
+    this.syncMiniPlayerStateFromHost('aboutToAppear')
+    this.logMiniPlayerState('aboutToAppear after defaults')
+    this.playbackController.setHostActions(this.playbackHostActions)
+    this.syncPlaybackRuntimeBinding()
     this.defalut_home_type = PreferencesUtil.getNumberSync('defalut_home_type', 0)
     this.isShowPrecious = PreferencesUtil.getBooleanSync('isShowPrecious', false)
     ReqPermissionUtil.reqPermissionsFromUser(ReqPermissionUtil.permissions, this.context);
@@ -607,6 +702,10 @@ struct 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.breakpointSystem.unregister();
     emitter.off(EventConstants.EVENT_SWIPE_BACK_UPDATE);
@@ -623,6 +722,10 @@ struct NewIndex {
   }
 
   private clearMiniPlayerAnimationTimer(): void {
+    if (this.pendingPlayerPageOpenTimer >= 0) {
+      clearTimeout(this.pendingPlayerPageOpenTimer)
+      this.pendingPlayerPageOpenTimer = -1
+    }
     if (this.miniPlayerAnimationTimer >= 0) {
       clearTimeout(this.miniPlayerAnimationTimer)
       this.miniPlayerAnimationTimer = -1
@@ -811,7 +914,7 @@ struct NewIndex {
   }
 
   private shouldShowMiniPlayer(): boolean {
-    return !(this.isMultiSelect || (this.mType > 0 && this.mType < 6) || this.isShowPlay)
+    return shouldShowMiniPlayerBar(this.isMultiSelect, this.mType)
   }
 
   // 同步播放条挂载与显示状态。
@@ -1136,16 +1239,16 @@ struct NewIndex {
         this.handleMiniPlayerOrbTap()
       },
       onPlayPrevious: (): void => {
-        this.getUIContext().getHostContext()!.eventHub.emit('playPrevious');
+        void this.playbackController.playPrevious()
       },
       onPlayOrPause: (): void => {
-        this.getUIContext().getHostContext()!.eventHub.emit('playOrPause');
+        void this.playbackController.playOrPause()
       },
       onPlayNext: (): void => {
-        this.getUIContext().getHostContext()!.eventHub.emit('playNext');
+        void this.playbackController.playNext()
       },
       onOpenPlayList: (): void => {
-        this.getUIContext().getHostContext()!.eventHub.emit('openPlayList');
+        void this.playbackController.openPlayList()
       }
     })
   }
@@ -1212,10 +1315,30 @@ struct NewIndex {
   }
 
   setShowPlayTrue(){
-    if (this.isShowPlay || this.isMiniPlayerModeTransitioning) {
+    if (this.isMiniPlayerModeTransitioning) {
+      return
+    }
+    const openDelayMs = resolvePlayerPageOpenDelayMs(this.mType)
+    if (this.mType !== 0) {
+      LogUtil.info(TAG, `[MiniState] setShowPlayTrue switch host page mType=${this.mType} -> 0`)
+      this.mType = 0
+      this.tabSelectedIndexes = [0]
+    }
+    if (this.pendingPlayerPageOpenTimer >= 0) {
+      clearTimeout(this.pendingPlayerPageOpenTimer)
+      this.pendingPlayerPageOpenTimer = -1
+    }
+    if (openDelayMs <= 0) {
+      LogUtil.info(TAG, '[MiniState] setShowPlayTrue dispatch open immediately')
+      void this.playbackController.showPlayerView()
       return
     }
-    this.getUIContext().getHostContext()!.eventHub.emit('openPlayerViewFromMiniBar');
+    LogUtil.info(TAG, `[MiniState] setShowPlayTrue defer open delayMs=${openDelayMs}`)
+    this.pendingPlayerPageOpenTimer = setTimeout(() => {
+      this.pendingPlayerPageOpenTimer = -1
+      LogUtil.info(TAG, '[MiniState] deferred open -> showPlayerView')
+      void this.playbackController.showPlayerView()
+    }, openDelayMs)
   }
 
   // 右侧圆球内部内容:封面、暗罩、白色环形进度和中心播放状态指示。
@@ -1632,7 +1755,7 @@ struct NewIndex {
           if (this.isMiniPlayerModeTransitioning) {
             return
           }
-          this.getUIContext().getHostContext()!.eventHub.emit('openPlayList');
+          void this.playbackController.openPlayList()
         })
     }
     .margin({left:5})
@@ -1716,7 +1839,7 @@ struct NewIndex {
           if (this.isMiniPlayerModeTransitioning) {
             return
           }
-          this.getUIContext().getHostContext()!.eventHub.emit('openPlayList');
+          void this.playbackController.openPlayList()
         })
     }
     .margin({ left: 16 })

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

@@ -0,0 +1,863 @@
+import { common, Context, wantAgent, Want } from '@kit.AbilityKit'
+import { BusinessError } from '@kit.BasicServicesKit'
+import { avSession } from '@kit.AVSessionKit'
+import { image } from '@kit.ImageKit'
+import { FileUtil, ImageUtil, PreferencesUtil, StrUtil } 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 { isPlaybackControlPlaying } from '../common/player/PlaybackControlStateHelper'
+import { MusicCardManager, MusicCardPlaybackStateOptions } from '../common/player/MusicCardManager'
+import { BackgroundTaskManager } from '../common/util/BackgroundTaskManager'
+import Logger from '../common/util/Logger'
+import { imagePathToPixelMap } from '../common/util/CommUtils'
+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,
+  BackgroundAudioPersistedState,
+  BackgroundAudioPlaybackHostHelper,
+  BackgroundAudioRecoveredQueue
+} from './BackgroundAudioPlaybackHostHelper'
+import { PlaybackSnapshot, PlaybackSnapshotSong, resolvePlaybackSnapshotProgressValue } from './model/PlaybackSnapshot'
+
+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
+
+interface PlaybackSnapshotWriter {
+  write(snapshot: PlaybackSnapshot): void
+}
+
+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 playbackSessionCallbacksRegistered: 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)
+    },
+    playOrPause: async (): Promise<void> => {
+      await this.playOrPause()
+    },
+    playNext: async (): Promise<void> => {
+      await this.playNext()
+    },
+    playPrevious: async (): Promise<void> => {
+      await this.playPrevious()
+    },
+    setLoopMode: async (): Promise<void> => {
+      await this.setLoopMode()
+    },
+    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 setLoopMode(): Promise<void> {
+    this.restorePersistedQueueIfNeeded()
+    const nextMode = MusicPlaybackController.resolveNextLoopMode(this.playType)
+    this.playType = nextMode.playType
+    PreferencesUtil.putSync('musicPlayType', this.playType)
+    this.persistCurrentQueue()
+    this.syncHostStorage(this.isPlaying ? PlayStatus.PLAY : PlayStatus.PAUSE)
+    this.syncPlaybackSessionState(this.isPlaying ? PlayStatus.PLAY : PlayStatus.PAUSE)
+    this.persistPlaybackSnapshot(this.isPlaying)
+    Logger.info(TAG, `[MusicCast] setLoopMode playType=${this.playType}, toast=${nextMode.toastText}`)
+  }
+
+  public syncCurrentSongFavoriteState(isFavorite: boolean): void {
+    this.applyCurrentSongFavoriteState(isFavorite)
+  }
+
+  public getSpectrumData(): number[] {
+    if (!this.player) {
+      return []
+    }
+    return this.player.getSpectrumData()
+  }
+
+  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('LastPlayModeType', 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)
+    this.persistPlaybackSnapshot(this.isPlaying)
+  }
+
+  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.PAUSE)
+      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())
+    const progressValue = resolvePlaybackSnapshotProgressValue(positionMs, durationMs, 100)
+    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
+    )
+    this.syncPlaybackDisplayState(progressValue, positionMs, durationMs)
+    this.syncPlaybackSessionState(this.isPlaying ? PlayStatus.PLAY : PlayStatus.PAUSE)
+    this.persistPlaybackSnapshot(this.isPlaying)
+  }
+
+  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.setPlaybackSessionLaunchAbility(abilityContext, session)
+      this.registerPlaybackSessionCallbacks(session)
+      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 setPlaybackSessionLaunchAbility(abilityContext: common.UIAbilityContext,
+    session: avSession.AVSession): Promise<void> {
+    try {
+      const want = new Want()
+      want.bundleName = abilityContext.abilityInfo.bundleName
+      want.abilityName = abilityContext.abilityInfo.name
+      const wantAgentInfo: wantAgent.WantAgentInfo = {
+        wants: [want],
+        operationType: wantAgent.OperationType.START_ABILITIES,
+        requestCode: 0,
+        wantAgentFlags: [wantAgent.WantAgentFlags.UPDATE_PRESENT_FLAG]
+      }
+      const agent = await wantAgent.getWantAgent(wantAgentInfo)
+      await session.setLaunchAbility(agent)
+      Logger.info(TAG, '[MusicCast] playbackSession launchAbility attached')
+    } catch (error) {
+      const err = error as BusinessError
+      Logger.error(TAG, `[MusicCast] playbackSession launchAbility failed code=${err.code}, message=${err.message}`)
+    }
+  }
+
+  private registerPlaybackSessionCallbacks(session: avSession.AVSession): void {
+    if (this.playbackSessionCallbacksRegistered) {
+      return
+    }
+    session.on('play', () => {
+      void this.handlePlaybackSessionPlay()
+    })
+    session.on('pause', () => {
+      this.handlePlaybackSessionPause()
+    })
+    session.on('stop', () => {
+      this.handlePlaybackSessionStop()
+    })
+    session.on('playNext', () => {
+      void this.playNext()
+    })
+    session.on('playPrevious', () => {
+      void this.playPrevious()
+    })
+    session.on('seek', (time: number) => {
+      void this.seekTo(`${time}`)
+    })
+    session.on('setLoopMode', (_mode: number) => {
+      void this.setLoopMode()
+    })
+    session.on('toggleFavorite', (_assetId: string) => {
+      this.toggleCurrentSongFavoriteState()
+    })
+    this.playbackSessionCallbacksRegistered = true
+  }
+
+  private async handlePlaybackSessionPlay(): Promise<void> {
+    this.restorePersistedQueueIfNeeded()
+    if (this.isPrepared && !this.isPlaying) {
+      this.resume()
+      return
+    }
+    if (this.currentIndex >= 0) {
+      await this.playIndex(this.currentIndex)
+      return
+    }
+    if (this.queue.length > 0) {
+      await this.playIndex(0)
+    }
+  }
+
+  private handlePlaybackSessionPause(): void {
+    if (this.isPlaying) {
+      this.pause()
+    }
+  }
+
+  private handlePlaybackSessionStop(): void {
+    this.stop()
+  }
+
+  private async syncPlaybackSessionMetadata(): Promise<void> {
+    if (!this.playbackSession || !this.currentSong) {
+      return
+    }
+    try {
+      const mediaImage = await this.resolvePlaybackSessionMediaImage()
+      let metadata: avSession.AVMetadata = {
+        assetId: this.currentSong.filePath ?? this.currentSong.name ?? '',
+        title: this.currentSong.name,
+        artist: this.currentSong.artist,
+        mediaImage: mediaImage,
+        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 async resolvePlaybackSessionMediaImage(): Promise<image.PixelMap | string> {
+    const coverPath = this.currentSong?.pixelMapPath ?? ''
+    if (StrUtil.isNotEmpty(coverPath)) {
+      if (coverPath.startsWith('http://') || coverPath.startsWith('https://')) {
+        return coverPath
+      }
+      try {
+        const localCoverPath = coverPath.startsWith('file://') ? FileUtil.getFilePath(coverPath) : coverPath
+        return await imagePathToPixelMap(localCoverPath)
+      } catch (error) {
+        Logger.warn(TAG, `[MusicCast] cover pixelMap failed path=${coverPath}, message=${(error as Error).message}`)
+      }
+    }
+    return await ImageUtil.getPixelMapFromMedia($r('app.media.alt'))
+  }
+
+  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,
+      loopMode: MusicPlaybackController.resolveAvSessionLoopMode(this.playType),
+      isFavorite: this.currentSong?.isFav === 1,
+      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 {
+    this.persistPlaybackSnapshot(isPlaying)
+    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 persistPlaybackSnapshot(isPlaying: boolean): void {
+    const snapshot = this.buildPlaybackSnapshot(isPlaying)
+    Logger.info(TAG,
+      `[MiniState] persistPlaybackSnapshot song=${snapshot.currentSongName}, index=${snapshot.currentIndex}, ` +
+      `isPlaying=${snapshot.isPlaying}, shouldResume=${snapshot.shouldResumeWhenActivated}, ` +
+      `position=${snapshot.positionMs}, duration=${snapshot.durationMs}`)
+    this.snapshotStore.write(snapshot)
+  }
+
+  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,
+          isFav: item.isFav,
+          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()
+      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)
+    PreferencesUtil.putSync('musicPlayType', 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 {
+    const isPlaying = isPlaybackControlPlaying(status)
+    const positionMs = this.player ? Math.max(0, this.player.getCurrentPosition()) : 0
+    const durationMs = this.resolveDurationMs()
+    const progressValue = resolvePlaybackSnapshotProgressValue(positionMs, durationMs, 100)
+    AppStorage.setOrCreate('CONTROL_PlayStatus', status)
+    AppStorage.setOrCreate('isPlaying', isPlaying)
+    AppStorage.setOrCreate('animationState', isPlaying ? AnimationStatus.Running : AnimationStatus.Paused)
+    AppStorage.setOrCreate('musicPlayType', this.playType)
+    AppStorage.setOrCreate('songList', this.queue)
+    AppStorage.setOrCreate('currIndex', this.currentIndex)
+    AppStorage.setOrCreate('currentSong', this.currentSong)
+    this.syncPlaybackDisplayState(progressValue, positionMs, durationMs)
+    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 ?? ''}`)
+  }
+
+  private syncPlaybackDisplayState(progressValue: number, positionMs: number, durationMs: number): void {
+    AppStorage.setOrCreate('progressValue', progressValue)
+    AppStorage.setOrCreate('playbackPositionMs', positionMs)
+    AppStorage.setOrCreate('playbackDurationMs', durationMs)
+    AppStorage.setOrCreate('playbackIsFavorite', this.currentSong?.isFav === 1)
+  }
+
+  private toggleCurrentSongFavoriteState(): void {
+    const nextFavorite = this.currentSong?.isFav !== 1
+    this.applyCurrentSongFavoriteState(nextFavorite)
+  }
+
+  private applyCurrentSongFavoriteState(isFavorite: boolean): void {
+    if (!this.currentSong) {
+      return
+    }
+    const nextFavorite = isFavorite ? 1 : 0
+    this.currentSong.isFav = nextFavorite
+    if (this.currentIndex >= 0 && this.currentIndex < this.queue.length) {
+      this.queue[this.currentIndex].isFav = nextFavorite
+    }
+    this.persistCurrentQueue()
+    this.syncHostStorage(this.isPlaying ? PlayStatus.PLAY : PlayStatus.PAUSE)
+    this.syncPlaybackSessionState(this.isPlaying ? PlayStatus.PLAY : PlayStatus.PAUSE)
+    this.persistPlaybackSnapshot(this.isPlaying)
+    Logger.info(TAG, `[MusicCast] favorite updated song=${this.currentSong.name}, isFavorite=${isFavorite}`)
+  }
+}

+ 137 - 0
entry/src/main/ets/playback/BackgroundAudioPlaybackHostHelper.ets

@@ -0,0 +1,137 @@
+import { MusicCardActionConstants } from '../common/player/MusicCardActionConstants'
+import { MusicPlaybackController } from '../controller/MusicPlaybackController'
+import { VideoItem } from '../viewmodel/VideoItem'
+
+export enum BackgroundAudioControlKind {
+  NOOP = 'noop',
+  PAUSE = 'pause',
+  STOP = 'stop',
+  PLAY_INDEX = 'play_index'
+}
+
+export class BackgroundAudioRecoveredQueue {
+  queue: VideoItem[] = []
+  currentIndex: number = -1
+  playType: number = 0
+  currentSong: VideoItem | undefined = undefined
+}
+
+export class BackgroundAudioPersistedState {
+  queue: VideoItem[] = []
+  currentSong: VideoItem | undefined = undefined
+  playType: number = 0
+  errorMessage: string | undefined = undefined
+}
+
+export class BackgroundAudioControlDecision {
+  kind: BackgroundAudioControlKind = BackgroundAudioControlKind.NOOP
+  targetIndex: number = -1
+}
+
+export class BackgroundAudioPlaybackHostHelper {
+  public static readPersistedState(
+    readQueue: () => VideoItem[] | undefined,
+    readCurrentSong: () => VideoItem | undefined,
+    readPlayType: () => number
+  ): BackgroundAudioPersistedState {
+    const state = new BackgroundAudioPersistedState()
+    try {
+      state.queue = readQueue() ?? []
+      state.currentSong = readCurrentSong()
+      state.playType = readPlayType()
+    } catch (error) {
+      state.queue = []
+      state.currentSong = undefined
+      state.playType = 0
+      state.errorMessage = (error as Error).message
+    }
+    return state
+  }
+
+  public static restorePersistedQueue(
+    persistedQueue: VideoItem[] | undefined,
+    persistedCurrentSong: VideoItem | undefined,
+    persistedPlayType: number
+  ): BackgroundAudioRecoveredQueue {
+    const restored = new BackgroundAudioRecoveredQueue()
+    const queue = persistedQueue ? persistedQueue.slice() : []
+    restored.playType = persistedPlayType
+
+    if (persistedCurrentSong) {
+      const existingIndex = queue.findIndex((item: VideoItem): boolean => {
+        return BackgroundAudioPlaybackHostHelper.isSameSong(item, persistedCurrentSong)
+      })
+      if (existingIndex >= 0) {
+        restored.queue = queue
+        restored.currentIndex = existingIndex
+        restored.currentSong = queue[existingIndex]
+        return restored
+      }
+      queue.push(persistedCurrentSong)
+      restored.queue = queue
+      restored.currentIndex = queue.length - 1
+      restored.currentSong = persistedCurrentSong
+      return restored
+    }
+
+    restored.queue = queue
+    if (queue.length > 0) {
+      restored.currentIndex = 0
+      restored.currentSong = queue[0]
+    }
+    return restored
+  }
+
+  public static resolveControlDecision(
+    action: string,
+    currentIndex: number,
+    queueLength: number,
+    isPlaying: boolean,
+    playType: number
+  ): BackgroundAudioControlDecision {
+    const decision = new BackgroundAudioControlDecision()
+    if (queueLength <= 0) {
+      return decision
+    }
+
+    const safeCurrentIndex = currentIndex >= 0 && currentIndex < queueLength ? currentIndex : 0
+    if (action === MusicCardActionConstants.ACTION_PLAY_PAUSE) {
+      if (isPlaying) {
+        decision.kind = BackgroundAudioControlKind.PAUSE
+        return decision
+      }
+      decision.kind = BackgroundAudioControlKind.PLAY_INDEX
+      decision.targetIndex = safeCurrentIndex
+      return decision
+    }
+
+    if (action === MusicCardActionConstants.ACTION_NEXT) {
+      const nextAction = MusicPlaybackController.resolveNextPlaybackAction(playType, safeCurrentIndex, queueLength)
+      if (nextAction === 'stop_at_end') {
+        decision.kind = BackgroundAudioControlKind.STOP
+        return decision
+      }
+      decision.kind = BackgroundAudioControlKind.PLAY_INDEX
+      decision.targetIndex = MusicPlaybackController.resolveNextQueueIndex(safeCurrentIndex, queueLength).nextIndex
+      return decision
+    }
+
+    if (action === MusicCardActionConstants.ACTION_PREVIOUS) {
+      decision.kind = BackgroundAudioControlKind.PLAY_INDEX
+      decision.targetIndex = MusicPlaybackController.resolvePreviousQueueIndex(safeCurrentIndex, queueLength).nextIndex
+      return decision
+    }
+
+    return decision
+  }
+
+  public static isSameSong(left: VideoItem | undefined, right: VideoItem | undefined): boolean {
+    if (!left || !right) {
+      return false
+    }
+    if (left.filePath !== '' && right.filePath !== '' && left.filePath === right.filePath) {
+      return true
+    }
+    return left.id !== '' && right.id !== '' && left.id === right.id
+  }
+}

+ 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, '')
+}

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

@@ -0,0 +1,62 @@
+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('isPlaying') === undefined) {
+    AppStorage.setOrCreate('isPlaying', false)
+  }
+  if (AppStorage.get('animationState') === undefined) {
+    AppStorage.setOrCreate('animationState', AnimationStatus.Paused)
+  }
+  if (AppStorage.get('progressValue') === undefined) {
+    AppStorage.setOrCreate('progressValue', 0)
+  }
+  if (AppStorage.get('playbackPositionMs') === undefined) {
+    AppStorage.setOrCreate('playbackPositionMs', 0)
+  }
+  if (AppStorage.get('playbackDurationMs') === undefined) {
+    AppStorage.setOrCreate('playbackDurationMs', 0)
+  }
+  if (AppStorage.get('playbackIsFavorite') === undefined) {
+    AppStorage.setOrCreate('playbackIsFavorite', false)
+  }
+  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'
+  }
+}

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

@@ -0,0 +1,29 @@
+import { MusicCardActionConstants } from '../common/player/MusicCardActionConstants'
+import { PlaybackActivationActionType } from './model/PlaybackSnapshot'
+
+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'
+  }
+
+  public static resolveMusicCardActivationAction(action: string): PlaybackActivationActionType | undefined {
+    if (action === MusicCardActionConstants.ACTION_PLAY_PAUSE) {
+      return 'resume_play'
+    }
+    if (action === MusicCardActionConstants.ACTION_PREVIOUS) {
+      return 'play_previous'
+    }
+    if (action === MusicCardActionConstants.ACTION_NEXT) {
+      return 'play_next'
+    }
+    if (action === MusicCardActionConstants.ACTION_OPEN_PLAYER) {
+      return 'open_player'
+    }
+    return undefined
+  }
+}

+ 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
+  }
+}

+ 14 - 0
entry/src/main/ets/playback/PlaybackSnapshotHydrationHelper.ets

@@ -0,0 +1,14 @@
+import { PendingPlaybackActivation } from './model/PlaybackSnapshot'
+
+export function shouldHydrateSnapshotAsPlaying(snapshotIsPlaying: boolean,
+  pendingActivation: PendingPlaybackActivation | undefined): boolean {
+  if (!snapshotIsPlaying) {
+    return false
+  }
+  if (!pendingActivation) {
+    return false
+  }
+  return pendingActivation.actionType === 'resume_play'
+    || pendingActivation.actionType === 'play_previous'
+    || pendingActivation.actionType === 'play_next'
+}

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

@@ -0,0 +1,132 @@
+import { PreferencesUtil } from '@pura/harmony-utils'
+import { VideoItem } from '../viewmodel/VideoItem'
+import Logger from '../common/util/Logger'
+import { PlaybackSnapshot, PlaybackSnapshotSong } from './model/PlaybackSnapshot'
+
+export const PLAYBACK_SNAPSHOT_PREFERENCES_KEY = 'playback.host.snapshot'
+const TAG = 'PlaybackSnapshotStore'
+
+export interface RecoveredPlaybackSnapshotState {
+  queue: VideoItem[]
+  currentIndex: number
+  currentSong: VideoItem | undefined
+}
+
+export class PlaybackSnapshotStore {
+  public write(snapshot: PlaybackSnapshot): void {
+    Logger.info(TAG,
+      `[MiniState] snapshot write song=${snapshot.currentSongName}, index=${snapshot.currentIndex}, ` +
+      `isPlaying=${snapshot.isPlaying}, shouldResume=${snapshot.shouldResumeWhenActivated}, ` +
+      `position=${snapshot.positionMs}, duration=${snapshot.durationMs}, updatedAt=${snapshot.updatedAt}`)
+    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 {
+      const parsedSnapshot = JSON.parse(text) as Object | null
+      if (!this.isValidSnapshot(parsedSnapshot)) {
+        this.clear()
+        return undefined
+      }
+      const snapshot = parsedSnapshot as PlaybackSnapshot
+      Logger.info(TAG,
+        `[MiniState] snapshot read song=${snapshot.currentSongName}, index=${snapshot.currentIndex}, ` +
+        `isPlaying=${snapshot.isPlaying}, shouldResume=${snapshot.shouldResumeWhenActivated}, ` +
+        `position=${snapshot.positionMs}, duration=${snapshot.durationMs}, updatedAt=${snapshot.updatedAt}`)
+      return snapshot
+    } catch (_error) {
+      this.clear()
+      return undefined
+    }
+  }
+
+  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 as PlaybackSnapshotSong))
+    }
+    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): boolean {
+    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): boolean {
+    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.isFav = song.isFav ?? 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
+  }
+}

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

@@ -0,0 +1,45 @@
+export interface PlaybackSnapshotSong {
+  filePath: string
+  id?: string
+  type: number
+  name: string
+  isFav?: number
+  artist?: string
+  album?: string
+  duration?: string
+  pixelMapPath?: string
+  lyricContent?: string
+  remote_rel_path?: string
+  webdav_account_id?: string
+}
+
+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
+  shouldResumeWhenActivated: boolean
+}
+
+export type PlaybackActivationActionType = 'resume_play' | 'open_player' | 'play_previous' | 'play_next'
+
+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
+}

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

@@ -1170,9 +1170,13 @@ export struct FindView {
       // 发现页队列统一走内存仓库,避免事件负载里重复塞大数组。
       setFindPlaylist(playlistId, playlistName, songs, safeIndex, meta)
     }
-    void this.playbackController.playQueue(songs.slice(), safeIndex, 'find-view', playType).catch((error: Error) => {
-      Logger.error(TAG, `发现页播放失败: ${error.message}`)
-      ToastUtil.showToast('播放失败')
+    this.playbackController.playInMemoryPlaylist({
+      playlistId,
+      playlistName,
+      songs: songs.slice(),
+      startIndex: safeIndex,
+      playType,
+      findPlaylistMeta: meta
     })
   }
 

A különbségek nem kerülnek megjelenítésre, a fájl túl nagy
+ 438 - 233
entry/src/main/ets/view/LocalMusic.ets


+ 3 - 2
entry/src/main/ets/view/MiniPlayerBar.ets

@@ -5,6 +5,7 @@ import { hdsEffect } from '@kit.UIDesignKit'
 import { VideoItem } from '../viewmodel/VideoItem'
 import { PlayStatus } from '../common/PlayStatus'
 import { CommonConstants } from '../common/constants/CommonConstants'
+import { isPlaybackControlPlaying } from '../common/player/PlaybackControlStateHelper'
 import { PointLightContentButton } from './PointLight/PointLightContentButton'
 import { PlayingIndicator } from './PlayingIndicator'
 
@@ -101,7 +102,7 @@ export struct MiniPlayerBar {
         .height('100%')
         .style({ strokeWidth: 3 })
 
-      if (this.controlPlayStatus === PlayStatus.PLAY) {
+      if (isPlaybackControlPlaying(this.controlPlayStatus)) {
         PlayingIndicator({
           isActive: true,
           indicatorSize: Math.max(16, Math.floor(orbSize * 0.34)),
@@ -204,7 +205,7 @@ export struct MiniPlayerBar {
           .color(this.themeColor)
           .height(38)
           .aspectRatio(CommonConstants.ASPECT_RATIO)
-        Image(this.controlPlayStatus === PlayStatus.PLAY ? $r('app.media.hm_pause') : $r('app.media.hm_play2'))
+        Image(isPlaybackControlPlaying(this.controlPlayStatus) ? $r('app.media.hm_pause') : $r('app.media.hm_play2'))
           .height(36)
           .width(36)
           .fillColor(Color.White)

+ 2 - 1
entry/src/main/ets/view/PipLyricTextBuilder.ets

@@ -1,5 +1,6 @@
 import { NodeController, BuilderNode, FrameNode, UIContext } from "@kit.ArkUI";
-import { LyricController, LyricView2 } from "@seagazer/cclyric";
+import { LyricController } from "../lyric/LyricController";
+import { LyricView2 } from "../lyric/view/LyricView2";
 
 class Params {
   pipBg: string = '';

+ 2 - 1
entry/src/main/ets/view/player/PlayerControls.ets

@@ -1,4 +1,5 @@
 import { PlayStatus } from '../../common/PlayStatus'
+import { isPlaybackControlPlaying } from '../../common/player/PlaybackControlStateHelper'
 import { PointLightButton } from '../PointLight/PointLightButton'
 import { PointLightDefaultButton } from '../PointLight/PointLightDeFaultButton'
 
@@ -93,7 +94,7 @@ export struct PlayerControls {
   private PlayOrPauseButtonContent() {
     Stack() {
       // 播放暂停按钮单独保留加载圈,兼容远程歌曲缓冲场景。
-      Image(this.controlPlayStatus === PlayStatus.PLAY
+      Image(isPlaybackControlPlaying(this.controlPlayStatus)
         ? (this.isCircleBtn ? $r('app.media.hm_pause') : $r('app.media.ic_public_play'))
         : (this.isCircleBtn ? $r('app.media.hm_play2') : $r('app.media.ic_public_pause')))
         .width(this.isLandscape ? 38 : 40)

+ 13 - 1
entry/src/main/ets/view/spectrum/SpectrumView.ets

@@ -1,6 +1,7 @@
 import { IjkMediaPlayer } from '@ohos/ijkplayer'
 import { PreferencesUtil } from '@pura/harmony-utils'
 import { FFTAnalyzer } from '../../common/util/FFTAnalyzer'
+import { BackgroundAudioPlaybackHost } from '../../playback/BackgroundAudioPlaybackHost'
 import { SpectrumRenderer } from './SpectrumRenderer'
 import {
   SpectrumEffectDefinition,
@@ -17,6 +18,15 @@ const TAG: string = 'heanup-SpectrumView'
 const BAND_COUNT: number = 53 // 频段数量
 const PREFERENCE_SELECTED_EFFECT: string = 'selectedSpectrumEffectId'
 
+export function resolvePreferredSpectrumData(hostData?: number[],
+  legacyData?: number[]): number[] {
+  const resolvedHostData = hostData ?? []
+  if (resolvedHostData.length > 0) {
+    return resolvedHostData
+  }
+  return legacyData ?? []
+}
+
 @Component
 export struct SpectrumView {
   @Prop @Watch('onPlayingChange') isPlaying: boolean = false
@@ -164,7 +174,9 @@ export struct SpectrumView {
     }
 
     const player: IjkMediaPlayer = IjkMediaPlayer.getInstance()
-    const pcmData: number[] = player.getSpectrumData()
+    const hostSpectrumData = BackgroundAudioPlaybackHost.getInstance().getSpectrumData()
+    const legacySpectrumData = player.getSpectrumData()
+    const pcmData: number[] = resolvePreferredSpectrumData(hostSpectrumData, legacySpectrumData)
     if (pcmData.length === 0) {
       return
     }

+ 25 - 0
entry/src/main/ets/widget/common/MusicPlayerWidgetHelper.ets

@@ -1,5 +1,13 @@
+import { MusicCardActionConstants } from '../../common/player/MusicCardActionConstants'
 import { resolveMusicCardLyricMetaText } from '../../common/player/MusicCardSnapshot'
 
+export class MusicCardWidgetActionParams {
+  ttmusic_music_card_form_id: string = ''
+  ttmusic_music_card_action: string = ''
+  ttmusic_music_card_source: string = MusicCardActionConstants.ACTION_SOURCE_WIDGET
+  method?: string = undefined
+}
+
 export function resolveMusicCardWidgetCoverSource(
   coverImageName: string,
   coverPath: string,
@@ -60,3 +68,20 @@ export function formatMusicCardWidgetTime(timeMs: number): string {
   }
   return `${pad(minutes)}:${pad(seconds)}`
 }
+
+export function shouldDispatchMusicCardActionByCall(action: string): boolean {
+  return action === MusicCardActionConstants.ACTION_PLAY_PAUSE
+    || action === MusicCardActionConstants.ACTION_PREVIOUS
+    || action === MusicCardActionConstants.ACTION_NEXT
+    || action === MusicCardActionConstants.ACTION_SEEK_TO
+}
+
+export function buildMusicCardWidgetActionParams(formId: string, action: string): MusicCardWidgetActionParams {
+  const params = new MusicCardWidgetActionParams()
+  params.ttmusic_music_card_form_id = formId
+  params.ttmusic_music_card_action = action
+  if (shouldDispatchMusicCardActionByCall(action)) {
+    params.method = MusicCardActionConstants.CALL_METHOD_HANDLE_ACTION
+  }
+  return params
+}

+ 7 - 7
entry/src/main/ets/widget/pages/MusicPlayerWidgetCard.ets

@@ -1,8 +1,10 @@
 import { MusicCardActionConstants } from '../../common/player/MusicCardActionConstants'
 import Logger from '../../common/util/Logger'
 import {
+  buildMusicCardWidgetActionParams,
   clampMusicCardWidgetProgress,
-  resolveMusicCardWidgetCoverSource
+  resolveMusicCardWidgetCoverSource,
+  shouldDispatchMusicCardActionByCall
 } from '../common/MusicPlayerWidgetHelper'
 
 const cardStorage: LocalStorage = new LocalStorage()
@@ -42,14 +44,12 @@ struct MusicPlayerWidgetCard {
 
   private postControlAction(action: string): void {
     Logger.info(TAG, `musicCard small postControlAction formId=${this.formId}, action=${action}`)
+    const useCallDispatch = shouldDispatchMusicCardActionByCall(action)
+    const params = buildMusicCardWidgetActionParams(this.formId, action)
     postCardAction(this, {
-      action: 'router',
+      action: useCallDispatch ? 'call' : 'router',
       abilityName: ENTRY_ABILITY_NAME,
-      params: {
-        ttmusic_music_card_form_id: this.formId,
-        ttmusic_music_card_action: action,
-        ttmusic_music_card_source: MusicCardActionConstants.ACTION_SOURCE_WIDGET
-      }
+      params
     })
   }
 

+ 7 - 7
entry/src/main/ets/widget/pages/MusicPlayerWidgetDynamicLyricCard.ets

@@ -1,9 +1,11 @@
 import { MusicCardActionConstants } from '../../common/player/MusicCardActionConstants'
 import Logger from '../../common/util/Logger'
 import {
+  buildMusicCardWidgetActionParams,
   clampMusicCardWidgetProgress,
   formatMusicCardWidgetTime,
-  resolveMusicCardWidgetCoverSource
+  resolveMusicCardWidgetCoverSource,
+  shouldDispatchMusicCardActionByCall
 } from '../common/MusicPlayerWidgetHelper'
 
 const dynamicLyricCardStorage: LocalStorage = new LocalStorage()
@@ -61,14 +63,12 @@ struct MusicPlayerWidgetDynamicLyricCard {
 
   private postControlAction(action: string): void {
     Logger.info(TAG, `musicCard dynamic lyric postControlAction formId=${this.formId}, action=${action}`)
+    const useCallDispatch = shouldDispatchMusicCardActionByCall(action)
+    const params = buildMusicCardWidgetActionParams(this.formId, action)
     postCardAction(this, {
-      action: 'router',
+      action: useCallDispatch ? 'call' : 'router',
       abilityName: ENTRY_ABILITY_NAME,
-      params: {
-        ttmusic_music_card_form_id: this.formId,
-        ttmusic_music_card_action: action,
-        ttmusic_music_card_source: MusicCardActionConstants.ACTION_SOURCE_WIDGET
-      }
+      params
     })
   }
 

+ 7 - 7
entry/src/main/ets/widget/pages/MusicPlayerWidgetLyricCard.ets

@@ -1,11 +1,13 @@
 import { MusicCardActionConstants } from '../../common/player/MusicCardActionConstants'
 import Logger from '../../common/util/Logger'
 import {
+  buildMusicCardWidgetActionParams,
   clampMusicCardWidgetProgress,
   formatMusicCardWidgetTime,
   resolveMusicCardWidgetCoverSource,
   resolveMusicCardWidgetLyricMeta,
-  resolveMusicCardWidgetNextLyric
+  resolveMusicCardWidgetNextLyric,
+  shouldDispatchMusicCardActionByCall
 } from '../common/MusicPlayerWidgetHelper'
 
 const lyricCardStorage: LocalStorage = new LocalStorage()
@@ -55,14 +57,12 @@ struct MusicPlayerWidgetLyricCard {
 
   private postControlAction(action: string): void {
     Logger.info(TAG, `musicCard lyric postControlAction formId=${this.formId}, action=${action}`)
+    const useCallDispatch = shouldDispatchMusicCardActionByCall(action)
+    const params = buildMusicCardWidgetActionParams(this.formId, action)
     postCardAction(this, {
-      action: 'router',
+      action: useCallDispatch ? 'call' : 'router',
       abilityName: ENTRY_ABILITY_NAME,
-      params: {
-        ttmusic_music_card_form_id: this.formId,
-        ttmusic_music_card_action: action,
-        ttmusic_music_card_source: MusicCardActionConstants.ACTION_SOURCE_WIDGET
-      }
+      params
     })
   }
 

+ 7 - 7
entry/src/main/ets/widget/pages/MusicPlayerWidgetMiniCard.ets

@@ -1,8 +1,10 @@
 import { MusicCardActionConstants } from '../../common/player/MusicCardActionConstants'
 import Logger from '../../common/util/Logger'
 import {
+  buildMusicCardWidgetActionParams,
   clampMusicCardWidgetProgress,
-  resolveMusicCardWidgetCoverSource
+  resolveMusicCardWidgetCoverSource,
+  shouldDispatchMusicCardActionByCall
 } from '../common/MusicPlayerWidgetHelper'
 
 const cardStorage: LocalStorage = new LocalStorage()
@@ -51,14 +53,12 @@ struct u {
 
   private postControlAction(action: string): void {
     Logger.info(TAG, `musicCard small postControlAction formId=${this.formId}, action=${action}`)
+    const useCallDispatch = shouldDispatchMusicCardActionByCall(action)
+    const params = buildMusicCardWidgetActionParams(this.formId, action)
     postCardAction(this, {
-      action: 'router',
+      action: useCallDispatch ? 'call' : 'router',
       abilityName: ENTRY_ABILITY_NAME,
-      params: {
-        ttmusic_music_card_form_id: this.formId,
-        ttmusic_music_card_action: action,
-        ttmusic_music_card_source: MusicCardActionConstants.ACTION_SOURCE_WIDGET
-      }
+      params
     })
   }
 

+ 7 - 7
entry/src/main/ets/widget/pages/MusicPlayerWidgetWideCard.ets

@@ -1,9 +1,11 @@
 import { MusicCardActionConstants } from '../../common/player/MusicCardActionConstants'
 import Logger from '../../common/util/Logger'
 import {
+  buildMusicCardWidgetActionParams,
   clampMusicCardWidgetProgress,
   formatMusicCardWidgetTime,
-  resolveMusicCardWidgetCoverSource
+  resolveMusicCardWidgetCoverSource,
+  shouldDispatchMusicCardActionByCall
 } from '../common/MusicPlayerWidgetHelper'
 
 const wideCardStorage: LocalStorage = new LocalStorage()
@@ -49,14 +51,12 @@ struct MusicPlayerWidgetWideCard {
 
   private postControlAction(action: string): void {
     Logger.info(TAG, `musicCard wide postControlAction formId=${this.formId}, action=${action}`)
+    const useCallDispatch = shouldDispatchMusicCardActionByCall(action)
+    const params = buildMusicCardWidgetActionParams(this.formId, action)
     postCardAction(this, {
-      action: 'router',
+      action: useCallDispatch ? 'call' : 'router',
       abilityName: ENTRY_ABILITY_NAME,
-      params: {
-        ttmusic_music_card_form_id: this.formId,
-        ttmusic_music_card_action: action,
-        ttmusic_music_card_source: MusicCardActionConstants.ACTION_SOURCE_WIDGET
-      }
+      params
     })
   }
 

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

@@ -0,0 +1,255 @@
+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() {
+  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`
+      )
+    })
+
+    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()
+    })
+  })
+}

+ 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 musicCardSnapshotTest from './MusicCardSnapshot.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() {
   abilityTest()
@@ -33,4 +41,12 @@ export default function testsuite() {
   remoteMusicViewModeHelperTest()
   musicCardSnapshotTest()
   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()
+    })
+  })
+}

+ 0 - 17
lib/Index.ets

@@ -1,17 +0,0 @@
-export { IParser } from './src/main/ets/parse/IParser';
-
-export { Lyric } from './src/main/ets/bean/Lyric'
-
-export { LyricLine } from './src/main/ets/bean/LyricLine'
-
-export { LyricParser } from './src/main/ets/parse/LyricParser'
-
-export { FileParser } from './src/main/ets/parse/FileParser'
-
-
-export { LyricView2 } from './src/main/ets/view/LyricView2'
-
-export { LyricController } from './src/main/ets/LyricController'
-
-export { LyricHelper } from './src/main/ets/LyricHelper'
-

+ 0 - 6
lib/hvigorfile.ts

@@ -1,6 +0,0 @@
-import { harTasks } from '@ohos/hvigor-ohos-plugin';
-
-export default {
-    system: harTasks,  /* Built-in plugin of Hvigor. It cannot be modified. */
-    plugins:[]         /* Custom plugin to extend the functionality of Hvigor. */
-}

+ 0 - 30
lib/src/main/ets/extensions/Extension.ts

@@ -1,30 +0,0 @@
-import hilog from '@ohos.hilog';
-
-const TAG = "LyricView"
-
-export function printD(msg: string) {
-    hilog.debug(0, TAG, msg)
-}
-
-export function printW(msg: string) {
-    hilog.warn(0, TAG, msg)
-}
-
-export function duration2text(duration: number): string {
-    let seconds = Math.round(duration / 1000)
-    let minute = Math.floor(seconds / 60)
-    let second = seconds - minute * 60
-    let s1 = minute.toString()
-    let s2 = second >= 10 ? second.toString() : "0" + second
-    return s1 + ":" + s2
-}
-
-export function seconds2text(seconds: number) {
-    let minute = Math.floor(seconds / 60)
-    let second = seconds - minute * 60
-    if (second < 10) {
-        return minute + ":0" + second
-    } else {
-        return minute + ":" + second
-    }
-}

+ 0 - 7
lib/src/main/ets/parse/IParser.ts

@@ -1,7 +0,0 @@
-import { Lyric } from '../bean/Lyric';
-
-export interface IParser {
-
-    parse(source: any): Lyric
-
-}

+ 0 - 13
lib/src/main/resources/base/media/cclyric_play.svg

@@ -1,13 +0,0 @@
-<?xml version="1.0" encoding="UTF-8"?>
-<svg width="24px" height="24px" viewBox="0 0 24 24" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
-    <title>Public/ic_public_play</title>
-    <defs>
-        <path d="M15.9178985,6.64969378 L22.2241113,17.6299232 C22.9117482,18.8272204 22.4985869,20.3552615 21.3012896,21.0428985 C20.9224145,21.2604957 20.4931281,21.375 20.0562128,21.375 L7.44378717,21.375 C6.06307529,21.375 4.94378717,20.2557119 4.94378717,18.875 C4.94378717,18.4380847 5.05829147,18.0087984 5.27588871,17.6299232 L11.5821015,6.64969378 C12.2697385,5.45239653 13.7977796,5.03923521 14.9950768,5.72687214 C15.3789642,5.94734799 15.6974226,6.26580643 15.9178985,6.64969378 Z" id="path-1"></path>
-    </defs>
-    <g id="Public/ic_public_play" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
-        <mask id="mask-2" fill="white">
-            <use xlink:href="#path-1"></use>
-        </mask>
-        <use id="多边形备份-5" fill="#000000" transform="translate(13.750000, 12.125000) rotate(-270.000000) translate(-13.750000, -12.125000) " xlink:href="#path-1"></use>
-    </g>
-</svg>

+ 140 - 105
oh_modules/.ohpm/lock.json5

@@ -15,30 +15,10 @@
           "specifier": "^1.2.4",
           "version": "1.2.4"
         },
-        "@pura/harmony-dialog": {
-          "specifier": "^1.0.6",
-          "version": "1.0.6"
-        },
-        "@ohos/lottie": {
-          "specifier": "^2.0.19",
-          "version": "2.0.19"
-        },
-        "@ohos/pulltorefresh": {
-          "specifier": "^2.1.1",
-          "version": "2.1.1"
-        },
         "@chinalike/popup": {
           "specifier": "^0.0.7",
           "version": "0.0.7"
         },
-        "@seagazer/cclyric": {
-          "specifier": "file:lib",
-          "version": "file:lib"
-        },
-        "@changwei/chardet": {
-          "specifier": "^1.0.0",
-          "version": "1.0.0"
-        },
         "@cashier_alipay/cashiersdk": {
           "specifier": "^15.8.32",
           "version": "15.8.32"
@@ -47,10 +27,6 @@
           "specifier": "^1.0.4",
           "version": "1.0.4"
         },
-        "@sgaolei/lrc_parser": {
-          "specifier": "^1.0.0",
-          "version": "1.0.0"
-        },
         "@ohos/pinyin4js": {
           "specifier": "^2.0.2",
           "version": "2.0.2"
@@ -58,6 +34,42 @@
         "@simplepeng/spider-man": {
           "specifier": "^1.0.1",
           "version": "1.0.1"
+        },
+        "@ohos/juniversalchardet": {
+          "specifier": "^2.0.2",
+          "version": "2.0.2"
+        },
+        "@sj/ffmpeg": {
+          "specifier": "^1.2.5",
+          "version": "1.2.5"
+        },
+        "@ohos/lottie": {
+          "specifier": "^2.0.23",
+          "version": "2.0.23"
+        },
+        "@mcui/mccharts": {
+          "specifier": "^2.8.9",
+          "version": "2.8.9"
+        },
+        "@nutpi/chinese_transverter": {
+          "specifier": "^1.0.4",
+          "version": "1.0.4"
+        },
+        "@abner/track": {
+          "specifier": "^1.0.2",
+          "version": "1.0.2"
+        },
+        "@liuzhosoft/ftp4h": {
+          "specifier": "^2.0.0",
+          "version": "2.0.0"
+        },
+        "@webabcd/harmony-httpserver": {
+          "specifier": "^1.0.0",
+          "version": "1.0.4"
+        },
+        "@pura/harmony-dialog": {
+          "specifier": "^1.1.8",
+          "version": "1.1.8"
         }
       },
       "devDependencies": {},
@@ -71,10 +83,6 @@
           "specifier": "file:ijkplayer",
           "version": "file:ijkplayer"
         },
-        "libentry.so": {
-          "specifier": "file:entry/src/main/cpp/types/libentry",
-          "version": "file:entry/src/main/cpp/types/libentry"
-        },
         "@tencent/wechat_open_sdk": {
           "specifier": "1.0.0",
           "version": "1.0.0"
@@ -95,37 +103,25 @@
       },
       "dynamicDependencies": {},
       "maskedByOverrideDependencyMap": false
-    },
-    "lib": {
-      "name": "lib",
-      "dependencies": {},
-      "devDependencies": {},
-      "dynamicDependencies": {},
-      "maskedByOverrideDependencyMap": false
     }
   },
   "packages": {
-    "@types/libijkplayer_audio_napi.so@file:ijkplayer/src/main/cpp/types/ijkplayer_audio_napi": {
-      "storePath": "ijkplayer/src/main/cpp/types/ijkplayer_audio_napi",
-      "dependencies": {},
-      "dynamicDependencies": {},
-      "dev": true,
-      "dynamic": false,
-      "maskedByOverrideDependencyMap": false
-    },
     "@ohos/ijkplayer@file:ijkplayer": {
       "storePath": "ijkplayer",
       "dependencies": {},
+      "devDependencies": {
+        "@types/libijkplayer_audio_napi.so": "ijkplayer/src/main/cpp/types/ijkplayer_audio_napi"
+      },
       "dynamicDependencies": {},
       "dev": false,
       "dynamic": false,
       "maskedByOverrideDependencyMap": false
     },
-    "libentry.so@file:entry/src/main/cpp/types/libentry": {
-      "storePath": "entry/src/main/cpp/types/libentry",
+    "@types/libijkplayer_audio_napi.so@file:ijkplayer/src/main/cpp/types/ijkplayer_audio_napi": {
+      "storePath": "ijkplayer/src/main/cpp/types/ijkplayer_audio_napi",
       "dependencies": {},
       "dynamicDependencies": {},
-      "dev": false,
+      "dev": true,
       "dynamic": false,
       "maskedByOverrideDependencyMap": false
     },
@@ -149,167 +145,206 @@
       "dynamic": false,
       "maskedByOverrideDependencyMap": false
     },
-    "class-transformer@0.5.1": {
-      "integrity": "sha512-SQa1Ws6hUbfC98vKGxZH3KFY0Y1lm5Zm0SY8XX9zbK7FJCyVEac3ATW0RIpwzW+oOfmHE5PMPufDG9hCfoEOMw==",
-      "storePath": "oh_modules/.ohpm/class-transformer@0.5.1",
+    "@chinalike/popup@0.0.7": {
+      "integrity": "sha512-LgehWpAQJMw02T+tV5RpzOxPbT7Fm5D5ejKVn7tU+tNRYr517kKAspdhtGRVG382C6BSAtcy8p/1eITGH+uzjQ==",
+      "storePath": "oh_modules/.ohpm/@chinalike+popup@0.0.7",
       "dependencies": {},
       "dynamicDependencies": {},
       "dev": false,
       "dynamic": false,
       "maskedByOverrideDependencyMap": false
     },
-    "@pura/harmony-dialog@1.0.6": {
-      "integrity": "sha512-n6TS5LOLc4qaL84XHq711MXULyQhbwnS7fUvjtWYFiSzeobdZFHc0x6X9ZRKPg3+GjQFXe5VIuc2UtQrNWvsUg==",
-      "storePath": "oh_modules/.ohpm/@pura+harmony-dialog@1.0.6",
-      "dependencies": {
-        "@pura/spinkit": "1.0.4"
-      },
+    "@keke/color-picker@1.0.4": {
+      "integrity": "sha512-0cH0Ix3EaoqK2nMxcAZAAyzK2G2oPo6A2KzFpUAgmqI9+Ap04Qy2Z+ALdBgdkmvwhCgJxEKGH8txxudmBfppMw==",
+      "storePath": "oh_modules/.ohpm/@keke+color-picker@1.0.4",
+      "dependencies": {},
       "dynamicDependencies": {},
       "dev": false,
       "dynamic": false,
       "maskedByOverrideDependencyMap": false
     },
-    "@pura/spinkit@1.0.4": {
-      "integrity": "sha512-yI6tlx7s9B9lEsIvdEz7mhW602X/BwdnHWo6TRIHzGfZm0WPdoTpW8FfqNlwNbpDMGnVDKQ2YI10HRYeJDy+Wg==",
-      "storePath": "oh_modules/.ohpm/@pura+spinkit@1.0.4",
+    "@ohos/pinyin4js@2.0.2": {
+      "integrity": "sha512-f4xq1XZ/eF6JLnl9yVgXGQzvkTKsLjI58T+NZbF+uBigSivppkxtrhideVyfmdYna7sTCPQnINOvjww3jWyJww==",
+      "storePath": "oh_modules/.ohpm/@ohos+pinyin4js@2.0.2",
       "dependencies": {},
       "dynamicDependencies": {},
       "dev": false,
       "dynamic": false,
       "maskedByOverrideDependencyMap": false
     },
-    "@ohos/lottie@2.0.19": {
-      "integrity": "sha512-6YGMeuHCdJ4PrJGPj3ycJob61TYbEcgk4GugbcuTGS6eJHJpDUuTja9lPhDmD6RL/etm3i2j4IVd+QCpfI6TWA==",
-      "storePath": "oh_modules/.ohpm/@ohos+lottie@2.0.19",
+    "@simplepeng/spider-man@1.0.1": {
+      "integrity": "sha512-rulclyolBPbYUzEav8crOr5WDuQs+zVWpLtefE2ei0YmBIbVUrpJN8mDRPNI+5kHZdkbYufKWyM/IzbVB/KoKg==",
+      "storePath": "oh_modules/.ohpm/@simplepeng+spider-man@1.0.1",
       "dependencies": {},
       "dynamicDependencies": {},
       "dev": false,
       "dynamic": false,
       "maskedByOverrideDependencyMap": false
     },
-    "@ohos/pulltorefresh@2.1.1": {
-      "integrity": "sha512-YcXPaR29CRHsoaz+nhO190HP7ZXC8tAfanP0yMe/oKsEsZwk2RSIvaRfAHTlMbnQ3QxMlb1QSDougVcIMRrmWQ==",
-      "storePath": "oh_modules/.ohpm/@ohos+pulltorefresh@2.1.1",
+    "@ohos/juniversalchardet@2.0.2": {
+      "integrity": "sha512-EQiclxDnum59j75rI/t5CIOw6nyzbUB0p1hdaRPOJ+FxH7w7nT8tbga08ZwIkuFLzlAO2TtpkgDGAvkAsF3lDw==",
+      "storePath": "oh_modules/.ohpm/@ohos+juniversalchardet@2.0.2",
       "dependencies": {},
       "dynamicDependencies": {},
       "dev": false,
       "dynamic": false,
       "maskedByOverrideDependencyMap": false
     },
-    "@chinalike/popup@0.0.7": {
-      "integrity": "sha512-LgehWpAQJMw02T+tV5RpzOxPbT7Fm5D5ejKVn7tU+tNRYr517kKAspdhtGRVG382C6BSAtcy8p/1eITGH+uzjQ==",
-      "storePath": "oh_modules/.ohpm/@chinalike+popup@0.0.7",
+    "@ohos/lottie@2.0.23": {
+      "integrity": "sha512-SiVmsyllXLcvSrh98qsXtjSoVaxy1bm8sGZzmz94DZOjdQ2zSCnTzY627EevkCCFxAA5HIbqFPUWokhc8bp/3Q==",
+      "storePath": "oh_modules/.ohpm/@ohos+lottie@2.0.23",
       "dependencies": {},
       "dynamicDependencies": {},
       "dev": false,
       "dynamic": false,
       "maskedByOverrideDependencyMap": false
     },
-    "@seagazer/cclyric@file:lib": {
-      "storePath": "lib",
+    "@mcui/mccharts@2.8.9": {
+      "integrity": "sha512-mdpzc6TlYlR/xV+OH3u0Jq5s1UeND8EKQuyXUdtjPMozffrbitPWJcp5N4Fji9v1xs8d/WP62OKnbp79iWMtHA==",
+      "storePath": "oh_modules/.ohpm/@mcui+mccharts@2.8.9",
       "dependencies": {},
       "dynamicDependencies": {},
       "dev": false,
       "dynamic": false,
       "maskedByOverrideDependencyMap": false
     },
-    "@changwei/chardet@1.0.0": {
-      "integrity": "sha512-rLvLtFDQnYlgEaKL7PAKtaaYompnIQ1esK8c3wHpQYKKMdKqCIpwUwn9XmdACMQE7KoehJogpDzG/G4tBPykqA==",
-      "storePath": "oh_modules/.ohpm/@changwei+chardet@1.0.0",
+    "@nutpi/chinese_transverter@1.0.4": {
+      "integrity": "sha512-34IXm4ZPcUfTBQoI1BacdWOTSBw27AX/Ni22LW2ijM7hwBKEES7M+4ohUfkDuDNhZcZqOpwbkbn3OBWHdsZNKQ==",
+      "storePath": "oh_modules/.ohpm/@nutpi+chinese_transverter@1.0.4",
       "dependencies": {},
       "dynamicDependencies": {},
       "dev": false,
       "dynamic": false,
       "maskedByOverrideDependencyMap": false
     },
-    "@cashier_alipay/cashiersdk@15.8.32": {
-      "integrity": "sha512-04tCRKK6XW4pcilD2z+hcHRsBArvY/71SPEnwmxpTcmiHKkW9ggPTbHFLKsKALlWXG8u8tOXJic2D2qzdKm74g==",
-      "storePath": "oh_modules/.ohpm/@cashier_alipay+cashiersdk@15.8.32",
-      "dependencies": {
-        "pako": "2.1.0",
-        "@alipay/blueshieldsdk": "oh_modules/.ohpm/@cashier_alipay+cashiersdk@15.8.32/oh_modules/@cashier_alipay/cashiersdk/lib/blueshieldsdk-1.0.29.har",
-        "@taobao-ohos/utdid_sdk": "oh_modules/.ohpm/@cashier_alipay+cashiersdk@15.8.32/oh_modules/@cashier_alipay/cashiersdk/lib/utdid_sdk-1.0.9.har"
-      },
+    "@abner/track@1.0.2": {
+      "integrity": "sha512-BXZ2rsr2a+TZrychsD0lVw34oPixnSMgy/KnCEFTM5aJJuL/bAlczsG/7AskNRbNOH+ckOL1Z6PIA+w65ji1Iw==",
+      "storePath": "oh_modules/.ohpm/@abner+track@1.0.2",
+      "dependencies": {},
       "dynamicDependencies": {},
       "dev": false,
       "dynamic": false,
       "maskedByOverrideDependencyMap": false
     },
-    "pako@2.1.0": {
-      "integrity": "sha512-w+eufiZ1WuJYgPXbV/PO3NCMEc3xqylkKHzp8bxp1uW4qaSNQUkwmLLEc3kKsfz8lpV1F8Ht3U1Cm+9Srog2ug==",
-      "storePath": "oh_modules/.ohpm/pako@2.1.0",
+    "@liuzhosoft/ftp4h@2.0.0": {
+      "integrity": "sha512-BzXNsSeYZ2WOSxKy6pognc34vLJtXhXMLDxg8IritGZwOX7S6RP1KF2YXJlVaGkumi1+Z1nSWiFjzSjEoD3v1g==",
+      "storePath": "oh_modules/.ohpm/@liuzhosoft+ftp4h@2.0.0",
       "dependencies": {},
       "dynamicDependencies": {},
       "dev": false,
       "dynamic": false,
       "maskedByOverrideDependencyMap": false
     },
-    "@alipay/blueshieldsdk@file:oh_modules/.ohpm/@cashier_alipay+cashiersdk@15.8.32/oh_modules/@cashier_alipay/cashiersdk/lib/blueshieldsdk-1.0.29.har": {
-      "storePath": "oh_modules/.ohpm/@alipay+blueshieldsdk@cow+0gass5tzqhymzjby2f6sd+g2xtoatqsiyvi9qsy=",
+    "@webabcd/harmony-httpserver@1.0.4": {
+      "integrity": "sha512-ZJHe+JAG6eMdQYYomVoYZzuG7HsWA4YxBfpgAQfIkH2Hraj5TsTLOWDfTmPmbjn0jhLgrTk7qYqnDdly0Svgpg==",
+      "storePath": "oh_modules/.ohpm/@webabcd+harmony-httpserver@1.0.4",
       "dependencies": {
-        "libblueshield.so": "oh_modules/.ohpm/@alipay+blueshieldsdk@cow+0gass5tzqhymzjby2f6sd+g2xtoatqsiyvi9qsy=/oh_modules/@alipay/blueshieldsdk/src/main/cpp/types/libblueshield"
+        "@webabcd/harmony-utils": "1.0.1"
       },
       "dynamicDependencies": {},
       "dev": false,
       "dynamic": false,
       "maskedByOverrideDependencyMap": false
     },
-    "libblueshield.so@file:oh_modules/.ohpm/@alipay+blueshieldsdk@cow+0gass5tzqhymzjby2f6sd+g2xtoatqsiyvi9qsy=/oh_modules/@alipay/blueshieldsdk/src/main/cpp/types/libblueshield": {
-      "storePath": "oh_modules/.ohpm/@alipay+blueshieldsdk@cow+0gass5tzqhymzjby2f6sd+g2xtoatqsiyvi9qsy=/oh_modules/@alipay/blueshieldsdk/src/main/cpp/types/libblueshield",
+    "@pura/harmony-dialog@1.1.8": {
+      "integrity": "sha512-IG2BJti8LBvJvkAD7yLwk6F2D66BGYPt6mae2yqAB9hMU5HnOTslWJbrReN9qPQl+ssIjSahMC7aJexEFYm5Dw==",
+      "storePath": "oh_modules/.ohpm/@pura+harmony-dialog@1.1.8",
+      "dependencies": {
+        "@pura/spinkit": "1.0.6"
+      },
+      "dynamicDependencies": {},
+      "dev": false,
+      "dynamic": false,
+      "maskedByOverrideDependencyMap": false
+    },
+    "class-transformer@0.5.1": {
+      "integrity": "sha512-SQa1Ws6hUbfC98vKGxZH3KFY0Y1lm5Zm0SY8XX9zbK7FJCyVEac3ATW0RIpwzW+oOfmHE5PMPufDG9hCfoEOMw==",
+      "storePath": "oh_modules/.ohpm/class-transformer@0.5.1",
       "dependencies": {},
       "dynamicDependencies": {},
       "dev": false,
       "dynamic": false,
       "maskedByOverrideDependencyMap": false
     },
-    "@taobao-ohos/utdid_sdk@file:oh_modules/.ohpm/@cashier_alipay+cashiersdk@15.8.32/oh_modules/@cashier_alipay/cashiersdk/lib/utdid_sdk-1.0.9.har": {
-      "storePath": "oh_modules/.ohpm/@taobao-ohos+utdid_sdk@sxn5blz8wnmyyhhjqdjifot4kh4asf+8aeifzj4aaey=",
+    "@webabcd/harmony-utils@1.0.1": {
+      "integrity": "sha512-q3UlXjtIPkiqG2gMCbF4Pajh4qaTIoT0Tv7x9dO1swz4BoDZj/j0JirNktLuSXxeNOezOtpljodWTfrBc+XeYQ==",
+      "storePath": "oh_modules/.ohpm/@webabcd+harmony-utils@1.0.1",
       "dependencies": {},
       "dynamicDependencies": {},
       "dev": false,
       "dynamic": false,
       "maskedByOverrideDependencyMap": false
     },
-    "@keke/color-picker@1.0.4": {
-      "integrity": "sha512-0cH0Ix3EaoqK2nMxcAZAAyzK2G2oPo6A2KzFpUAgmqI9+Ap04Qy2Z+ALdBgdkmvwhCgJxEKGH8txxudmBfppMw==",
-      "storePath": "oh_modules/.ohpm/@keke+color-picker@1.0.4",
+    "@pura/spinkit@1.0.6": {
+      "integrity": "sha512-CwrAo/YiNAVwIT7q4exsFF8xdoXGTFsj/FfSaFC/m6LbdNbmPrYYYUk9VaZPNxRxjKBHOt6Gf9PkfSzHPdUdow==",
+      "storePath": "oh_modules/.ohpm/@pura+spinkit@1.0.6",
       "dependencies": {},
       "dynamicDependencies": {},
       "dev": false,
       "dynamic": false,
       "maskedByOverrideDependencyMap": false
     },
-    "@sgaolei/lrc_parser@1.0.0": {
-      "integrity": "sha512-JnkArqOidM0qN+Daoxka+PvMq4Eqyv39/oZ/4vHTYlU5ssCTUqJ0GWF7ti9ZQTrDbg89Shx6hjODVXhs3IjgAg==",
-      "storePath": "oh_modules/.ohpm/@sgaolei+lrc_parser@1.0.0",
+    "@sj/ffmpeg@1.2.5": {
+      "integrity": "sha512-IshbyU5FaIMYkEpXiyBtCQfBWbaBUHz1nt04XiVjbxiC+AjGJ1bHUrFMQINe8YIPGRPwcKrafXPxSnkidRXMRQ==",
+      "storePath": "oh_modules/.ohpm/@sj+ffmpeg@1.2.5",
+      "dependencies": {
+        "libffmpeg.so": "oh_modules/.ohpm/@sj+ffmpeg@1.2.5/oh_modules/@sj/ffmpeg/src/main/cpp/types/libffmpeg"
+      },
+      "dynamicDependencies": {},
+      "dev": false,
+      "dynamic": false,
+      "maskedByOverrideDependencyMap": false
+    },
+    "@cashier_alipay/cashiersdk@15.8.32": {
+      "integrity": "sha512-04tCRKK6XW4pcilD2z+hcHRsBArvY/71SPEnwmxpTcmiHKkW9ggPTbHFLKsKALlWXG8u8tOXJic2D2qzdKm74g==",
+      "storePath": "oh_modules/.ohpm/@cashier_alipay+cashiersdk@15.8.32",
       "dependencies": {
-        "liblrcparser.so": "oh_modules/.ohpm/@sgaolei+lrc_parser@1.0.0/oh_modules/@sgaolei/lrc_parser/src/main/cpp/types/liblrcparser"
+        "pako": "2.1.0",
+        "@alipay/blueshieldsdk": "oh_modules/.ohpm/@cashier_alipay+cashiersdk@15.8.32/oh_modules/@cashier_alipay/cashiersdk/lib/blueshieldsdk-1.0.29.har",
+        "@taobao-ohos/utdid_sdk": "oh_modules/.ohpm/@cashier_alipay+cashiersdk@15.8.32/oh_modules/@cashier_alipay/cashiersdk/lib/utdid_sdk-1.0.9.har"
       },
       "dynamicDependencies": {},
       "dev": false,
       "dynamic": false,
       "maskedByOverrideDependencyMap": false
     },
-    "liblrcparser.so@file:oh_modules/.ohpm/@sgaolei+lrc_parser@1.0.0/oh_modules/@sgaolei/lrc_parser/src/main/cpp/types/liblrcparser": {
-      "storePath": "oh_modules/.ohpm/@sgaolei+lrc_parser@1.0.0/oh_modules/@sgaolei/lrc_parser/src/main/cpp/types/liblrcparser",
+    "pako@2.1.0": {
+      "integrity": "sha512-w+eufiZ1WuJYgPXbV/PO3NCMEc3xqylkKHzp8bxp1uW4qaSNQUkwmLLEc3kKsfz8lpV1F8Ht3U1Cm+9Srog2ug==",
+      "storePath": "oh_modules/.ohpm/pako@2.1.0",
       "dependencies": {},
       "dynamicDependencies": {},
       "dev": false,
       "dynamic": false,
       "maskedByOverrideDependencyMap": false
     },
-    "@ohos/pinyin4js@2.0.2": {
-      "integrity": "sha512-f4xq1XZ/eF6JLnl9yVgXGQzvkTKsLjI58T+NZbF+uBigSivppkxtrhideVyfmdYna7sTCPQnINOvjww3jWyJww==",
-      "storePath": "oh_modules/.ohpm/@ohos+pinyin4js@2.0.2",
+    "@taobao-ohos/utdid_sdk@file:oh_modules/.ohpm/@cashier_alipay+cashiersdk@15.8.32/oh_modules/@cashier_alipay/cashiersdk/lib/utdid_sdk-1.0.9.har": {
+      "storePath": "oh_modules/.ohpm/@taobao-ohos+utdid_sdk@sxn5blz8wnmyyhhjqdjifot4kh4asf+8aeifzj4aaey=",
       "dependencies": {},
       "dynamicDependencies": {},
       "dev": false,
       "dynamic": false,
       "maskedByOverrideDependencyMap": false
     },
-    "@simplepeng/spider-man@1.0.1": {
-      "integrity": "sha512-rulclyolBPbYUzEav8crOr5WDuQs+zVWpLtefE2ei0YmBIbVUrpJN8mDRPNI+5kHZdkbYufKWyM/IzbVB/KoKg==",
-      "storePath": "oh_modules/.ohpm/@simplepeng+spider-man@1.0.1",
+    "@alipay/blueshieldsdk@file:oh_modules/.ohpm/@cashier_alipay+cashiersdk@15.8.32/oh_modules/@cashier_alipay/cashiersdk/lib/blueshieldsdk-1.0.29.har": {
+      "storePath": "oh_modules/.ohpm/@alipay+blueshieldsdk@cow+0gass5tzqhymzjby2f6sd+g2xtoatqsiyvi9qsy=",
+      "dependencies": {
+        "libblueshield.so": "oh_modules/.ohpm/@alipay+blueshieldsdk@cow+0gass5tzqhymzjby2f6sd+g2xtoatqsiyvi9qsy=/oh_modules/@alipay/blueshieldsdk/src/main/cpp/types/libblueshield"
+      },
+      "dynamicDependencies": {},
+      "dev": false,
+      "dynamic": false,
+      "maskedByOverrideDependencyMap": false
+    },
+    "libffmpeg.so@file:oh_modules/.ohpm/@sj+ffmpeg@1.2.5/oh_modules/@sj/ffmpeg/src/main/cpp/types/libffmpeg": {
+      "storePath": "oh_modules/.ohpm/@sj+ffmpeg@1.2.5/oh_modules/@sj/ffmpeg/src/main/cpp/types/libffmpeg",
+      "dependencies": {},
+      "dynamicDependencies": {},
+      "dev": false,
+      "dynamic": false,
+      "maskedByOverrideDependencyMap": false
+    },
+    "libblueshield.so@file:oh_modules/.ohpm/@alipay+blueshieldsdk@cow+0gass5tzqhymzjby2f6sd+g2xtoatqsiyvi9qsy=/oh_modules/@alipay/blueshieldsdk/src/main/cpp/types/libblueshield": {
+      "storePath": "oh_modules/.ohpm/@alipay+blueshieldsdk@cow+0gass5tzqhymzjby2f6sd+g2xtoatqsiyvi9qsy=/oh_modules/@alipay/blueshieldsdk/src/main/cpp/types/libblueshield",
       "dependencies": {},
       "dynamicDependencies": {},
       "dev": false,

Nem az összes módosított fájl került megjelenítésre, mert túl sok fájl változott