14 Commits 5f5dd92d80 ... 91a4707649

Autor SHA1 Mensagem Data
  onecold 91a4707649 抽离播放控制 4 meses atrás
  onecold 2595c56a66 refactor(player): make local music a playback runtime 4 meses atrás
  onecold 29e96d7653 fix(player): ensure controller fallback hits coordinator 4 meses atrás
  onecold 40963bb9ff refactor(player): route controller through coordinator 4 meses atrás
  onecold 4c0e5dbfc7 fix(player): guard playback coordinator state 4 meses atrás
  onecold 051fd10b8f fix(player): enforce playback coordinator singleton 4 meses atrás
  onecold f94a637c2e feat(player): add playback coordinator 4 meses atrás
  onecold 88a8f17821 fix(player): reset playback status when replacing queue 4 meses atrás
  onecold 0a585cf656 fix(player): enforce playback state bridge singleton 4 meses atrás
  onecold 7a564c87d0 feat(player): add playback state bridge 4 meses atrás
  Codex f295f66858 docs(player): 补充播放控制脱离 LocalMusic 设计 4 meses atrás
  onecold 55aacac195 fix(player): restore unified playback controller 4 meses atrás
  onecold 1d7027cef8 fix(localmusic): add missing menu scroll helper 4 meses atrás
  onecold 9527e8c000 merge(master): sync latest master changes 4 meses atrás
26 arquivos alterados com 1908 adições e 572 exclusões
  1. 0 1
      .gitignore
  2. 2 2
      AppScope/app.json5
  3. 281 0
      docs/superpowers/specs/2026-03-29-localmusic2-player-refactor-design.md
  4. 0 294
      docs/superpowers/specs/2026-03-31-localmusic-player-refactor-design.md
  5. 281 0
      docs/superpowers/specs/2026-04-02-playback-controller-decouple-localmusic-design.md
  6. 80 0
      entry/src/main/ets/common/network/EmbyApi.ets
  7. 59 0
      entry/src/main/ets/common/network/JellyfinApi.ets
  8. 54 0
      entry/src/main/ets/common/util/EmbyPlaylistHelper.ets
  9. 25 0
      entry/src/main/ets/common/util/FindPlaylistStore.ets
  10. 3 0
      entry/src/main/ets/common/util/LocalMusicMenuScrollHelper.ets
  11. 36 0
      entry/src/main/ets/common/util/RemoteMusicSearchHelper.ets
  12. 3 0
      entry/src/main/ets/common/util/RemoteMusicViewModeHelper.ets
  13. 88 5
      entry/src/main/ets/controller/MusicPlaybackController.ets
  14. 69 0
      entry/src/main/ets/controller/PlaybackCoordinator.ets
  15. 104 0
      entry/src/main/ets/controller/PlaybackStateBridge.ets
  16. 21 3
      entry/src/main/ets/pages/ChartsCount.ets
  17. 19 63
      entry/src/main/ets/pages/PlaylistDetailPage.ets
  18. 18 43
      entry/src/main/ets/pages/WebDavMainPage.ets
  19. 0 1
      entry/src/main/ets/view/FindAlbumDetail.ets
  20. 6 14
      entry/src/main/ets/view/FindView.ets
  21. 145 86
      entry/src/main/ets/view/LocalMusic.ets
  22. 210 56
      entry/src/main/ets/view/RemoteMusicPage.ets
  23. 7 1
      entry/src/ohosTest/ets/test/List.test.ets
  24. 90 3
      entry/src/ohosTest/ets/test/MusicPlaybackController.test.ets
  25. 247 0
      entry/src/ohosTest/ets/test/PlaybackCoordinator.test.ets
  26. 60 0
      entry/src/ohosTest/ets/test/PlaybackStateBridge.test.ets

+ 0 - 1
.gitignore

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

+ 2 - 2
AppScope/app.json5

@@ -2,8 +2,8 @@
   "app": {
     "bundleName": "com.xgplayer.ttmusic.hm",
     "vendor": "example",
-    "versionCode": 20260328,
-    "versionName": "2.0.6",
+    "versionCode": 20260402,
+    "versionName": "2.0.7",
     "icon": "$media:app_icon",
     "label": "$string:app_name",
     "multiAppMode": {

+ 281 - 0
docs/superpowers/specs/2026-03-29-localmusic2-player-refactor-design.md

@@ -0,0 +1,281 @@
+# LocalMusic2 播放器拆分设计
+
+## 背景
+
+当前 [LocalMusic.ets](/mnt/d/harmony/2025/qimeng/TTMusic/entry/src/main/ets/view/LocalMusic.ets) 已演变为内容展示、播放控制、底部播放条、全屏播放页、卡片联动混杂在一起的大文件,单文件规模超过两万行,继续在原文件上实现音乐卡片和播放器能力会持续放大耦合风险。
+
+仓库内虽然已有 [LocalMusic2.ets](/mnt/d/harmony/2025/qimeng/TTMusic/entry/src/main/ets/musicCard/LocalMusic2.ets),但它目前本质上仍是 `LocalMusic` 的复制体,尚未形成真正可维护的新架构。
+
+本次设计目标是在 **不修改 `LocalMusic`** 的前提下,以 `LocalMusic2` 为新入口,完成播放器职责拆分,为音乐卡片、底部播放条和独立播放页提供清晰边界。
+
+## 目标
+
+- `NewIndex` 挂载底部播放条和全屏播放浮层,成为播放器宿主。
+- `LocalMusic2` 只负责音乐分类与列表展示,不再承担播放器 UI 宿主职责。
+- 全屏播放页抽离为独立的 [MusicPlayerBuilder.ets](/mnt/d/harmony/2025/qimeng/TTMusic/entry/src/main/ets/player/MusicPlayerBuilder.ets)。
+- 播放控制逻辑抽离为独立控制类,统一管理队列、播放状态、进度、歌词、卡片同步。
+- 首版必须兼容本地、歌单、WebDAV、Navidrome、发现页等现有来源。
+- 播放页交互优先复用当前 `LocalMusic` / `LocalMusic2` 已验证过的交互能力。
+
+## 非目标
+
+- 不在本轮重写底层 Ijk 播放内核。
+- 不在本轮重做现有播放页视觉设计。
+- 不在本轮修改 [LocalMusic.ets](/mnt/d/harmony/2025/qimeng/TTMusic/entry/src/main/ets/view/LocalMusic.ets) 的现有行为。
+- 不追求一次性移除所有 `AppStorage` 依赖,允许首版保留必要兼容镜像。
+
+## 核心架构
+
+### 1. NewIndex 作为播放器宿主
+
+[NewIndex.ets](/mnt/d/harmony/2025/qimeng/TTMusic/entry/src/main/ets/pages/NewIndex.ets) 负责:
+
+- 挂载底部播放条组件。
+- 挂载全屏播放浮层。
+- 管理 `isShowPlay` 这类纯 UI 显示状态。
+- 处理返回键优先关闭播放浮层。
+- 响应音乐卡片的 `OPEN_PLAYER` 动作。
+
+`NewIndex` 不再承担具体播放逻辑,也不直接维护播放队列。
+
+### 2. LocalMusic2 作为内容页
+
+[LocalMusic2.ets](/mnt/d/harmony/2025/qimeng/TTMusic/entry/src/main/ets/musicCard/LocalMusic2.ets) 负责:
+
+- 本地音乐分类展示。
+- 音乐列表、歌单、搜索、定位当前歌曲等内容能力。
+- 用户点击歌曲或歌单后,组装统一的播放请求并发给播放控制器。
+
+`LocalMusic2` 不再负责:
+
+- 底部播放条 UI。
+- 全屏播放页 UI。
+- `isShowPlay` 的宿主级管理。
+- 音乐卡片命令消费。
+
+### 3. MusicPlayerBuilder 作为独立播放页 UI
+
+新增 [MusicPlayerBuilder.ets](/mnt/d/harmony/2025/qimeng/TTMusic/entry/src/main/ets/player/MusicPlayerBuilder.ets),负责:
+
+- 复用当前播放页交互与视觉。
+- 读取播放控制器提供的状态进行渲染。
+- 将播放/暂停、切歌、拖动进度、上滑切歌、下滑关闭等操作转发给播放控制器或宿主。
+
+该文件不作为状态源头,只做播放器 UI 呈现层。
+
+### 4. MusicPlaybackController 作为唯一播放控制中台
+
+新增 [MusicPlaybackController.ets](/mnt/d/harmony/2025/qimeng/TTMusic/entry/src/main/ets/player/MusicPlaybackController.ets),负责:
+
+- 当前歌曲、当前队列、当前索引。
+- 播放、暂停、上一首、下一首、seek。
+- 播放模式、进度、时长、歌词、封面。
+- 多数据源队列切换。
+- 音乐卡片状态同步。
+- 播放状态对底部播放条与播放页的统一输出。
+
+控制器是唯一播放状态源,避免多个页面各自维护一份播放器状态。
+
+## 文件拆分方案
+
+### 新增文件
+
+- [MusicPlaybackController.ets](/mnt/d/harmony/2025/qimeng/TTMusic/entry/src/main/ets/player/MusicPlaybackController.ets)
+  用于集中承接现有播放器主逻辑。
+- [MusicPlayerBuilder.ets](/mnt/d/harmony/2025/qimeng/TTMusic/entry/src/main/ets/player/MusicPlayerBuilder.ets)
+  用于承接全屏播放页 Builder。
+- [MiniPlayerBar.ets](/mnt/d/harmony/2025/qimeng/TTMusic/entry/src/main/ets/player/MiniPlayerBar.ets)
+  用于承接底部播放条。
+- [PlaybackRequest.ets](/mnt/d/harmony/2025/qimeng/TTMusic/entry/src/main/ets/player/model/PlaybackRequest.ets)
+  定义统一播放请求。
+- [PlaybackState.ets](/mnt/d/harmony/2025/qimeng/TTMusic/entry/src/main/ets/player/model/PlaybackState.ets)
+  定义统一播放状态快照。
+
+### 修改文件
+
+- [NewIndex.ets](/mnt/d/harmony/2025/qimeng/TTMusic/entry/src/main/ets/pages/NewIndex.ets)
+  接入播放器宿主、底部播放条、全屏浮层。
+- [LocalMusic2.ets](/mnt/d/harmony/2025/qimeng/TTMusic/entry/src/main/ets/musicCard/LocalMusic2.ets)
+  收缩为内容页,并把播放入口改为发请求。
+- [EntryAbility.ets](/mnt/d/harmony/2025/qimeng/TTMusic/entry/src/main/ets/entryability/EntryAbility.ets)
+  保持音乐卡片消息分发入口不变。
+- [MusicCardPlaybackStore.ets](/mnt/d/harmony/2025/qimeng/TTMusic/entry/src/main/ets/musicCard/MusicCardPlaybackStore.ets)
+  继续作为卡片快照存储。
+- [MusicCardFormManager.ets](/mnt/d/harmony/2025/qimeng/TTMusic/entry/src/main/ets/musicCard/MusicCardFormManager.ets)
+  继续负责卡片刷新。
+
+## 数据模型
+
+### PlaybackRequest
+
+统一播放入口模型至少包含:
+
+- `sourceType`:来源类型,例如本地、歌单、WebDAV、Navidrome、发现页。
+- `playlistId`:来源队列标识。
+- `playlistName`:当前队列展示名称。
+- `songs`:已拿到的歌曲对象列表。
+- `songFilePaths`:只拿到路径时的回填列表。
+- `startIndex`:起播索引。
+- `playType`:请求时希望应用的播放模式。
+- `openPlayer`:是否自动展开全屏播放页。
+
+### PlaybackState
+
+统一状态快照至少包含:
+
+- 当前歌曲。
+- 当前队列。
+- 当前索引。
+- 播放状态。
+- 当前进度与总时长。
+- 当前封面。
+- 当前歌词。
+- 是否可切上一首/下一首。
+- 当前来源上下文。
+
+## 数据流设计
+
+### 页面发起播放
+
+所有页面统一走以下链路:
+
+1. `LocalMusic2`、歌单页、WebDAV 页面、发现页等组装 `PlaybackRequest`。
+2. 调用 `MusicPlaybackController.play(request)`。
+3. 控制器解析来源、刷新队列、设置索引、启动播放。
+4. 如 `request.openPlayer === true`,控制器通知宿主展开全屏播放页。
+
+这样页面层不再直接调用 `setShowPlayTrue()`、`doPlay()`、`startPlayOrResumePlay()` 等底层方法组合。
+
+### 宿主层显示控制
+
+`NewIndex` 负责以下 UI 宿主逻辑:
+
+- 收到控制器的展开请求后设置 `isShowPlay = true`。
+- 收到关闭请求或返回键事件时设置 `isShowPlay = false`。
+- 将底部播放条和全屏播放浮层统一挂载在宿主层,而不是内容页层。
+
+现有 `dismissPlayerView` 事件仍可保留作为过渡期兼容,但最终应由宿主层统一管理,不再让内容页充当播放器宿主。
+
+### 状态同步策略
+
+首版采用“双轨同步”:
+
+- `MusicPlaybackController` 内部状态是主状态。
+- 关键状态镜像到 `AppStorage`,保持现有组件兼容。
+
+首版保留的兼容字段包括:
+
+- `currentSong`
+- `progressValue`
+- `CONTROL_PlayStatus`
+- `cover`
+
+后续稳定后再逐步收紧 `AppStorage` 依赖。
+
+## 音乐卡片联动
+
+### 保留现有入口
+
+[EntryAbility.ets](/mnt/d/harmony/2025/qimeng/TTMusic/entry/src/main/ets/entryability/EntryAbility.ets) 中的卡片消息入口继续保留:
+
+- `PLAY_OR_PAUSE`
+- `PLAY_PREVIOUS`
+- `PLAY_NEXT`
+- `OPEN_PLAYER`
+
+### 新职责分配
+
+- `PLAY_OR_PAUSE`、`PLAY_PREVIOUS`、`PLAY_NEXT` 交给 `MusicPlaybackController` 执行。
+- `OPEN_PLAYER` 交给 `NewIndex` 宿主处理,直接展开全屏播放浮层。
+
+### 卡片状态更新
+
+每次以下状态变化后,由 `MusicPlaybackController` 负责更新:
+
+- 当前歌曲变化。
+- 播放状态变化。
+- 封面变化。
+
+更新流程为:
+
+1. 写入 [MusicCardPlaybackStore.ets](/mnt/d/harmony/2025/qimeng/TTMusic/entry/src/main/ets/musicCard/MusicCardPlaybackStore.ets)。
+2. 调用 [MusicCardFormManager.ets](/mnt/d/harmony/2025/qimeng/TTMusic/entry/src/main/ets/musicCard/MusicCardFormManager.ets) 刷新所有卡片。
+
+## 迁移顺序
+
+### 阶段 1:抽控制器与模型
+
+- 新建 `PlaybackRequest`、`PlaybackState`、`MusicPlaybackController`。
+- 优先迁移现有核心方法,例如播放、暂停、切歌、队列装载、歌单/网盘请求处理。
+- 暂时保留原有 UI Builder,不立刻大规模移动展示层。
+
+### 阶段 2:抽播放页
+
+- 将 `LocalMusic2` 中的大播放页 Builder 迁移到 `MusicPlayerBuilder.ets`。
+- 由 `NewIndex` 挂载全屏浮层。
+- 复用当前动画、上滑切歌、下滑关闭、播放列表 Sheet 等交互。
+
+### 阶段 3:抽底部播放条
+
+- 将底部播放条迁移到 `MiniPlayerBar.ets`。
+- `NewIndex` 统一挂载普通模式和 HiCar 模式播放条。
+- 播放条动作全部走控制器。
+
+### 阶段 4:收缩 LocalMusic2
+
+- 删除 `LocalMusic2` 中播放器宿主职责。
+- 删除对 `isShowPlay` 的直接控制。
+- 删除卡片命令监听与播放页关闭事件监听。
+- 只保留内容展示与播放请求发起能力。
+
+### 阶段 5:多来源回归
+
+- 统一验证本地、歌单、WebDAV、Navidrome、发现页播放流程。
+- 补齐来源上下文切换时的队列同步与状态恢复。
+
+## 错误处理
+
+- 当 `PlaybackRequest` 中只有路径列表时,控制器负责回填缺失歌曲对象;回填失败的项目跳过并记录日志。
+- 当来源队列为空时,不展开播放页,直接给出 Toast 提示。
+- 当卡片刷新失败时,沿用现有 `MusicCardFormManager` 行为,记录日志并移除失效 formId。
+- 当宿主层未挂载完成时,卡片命令可先写入挂起动作队列,待宿主或控制器注册后消费。
+
+## 测试与验收
+
+首版以手工回归为主,必须覆盖:
+
+- 本地音乐列表点击播放。
+- 歌单点击播放。
+- WebDAV 点击播放。
+- Navidrome 点击播放。
+- 发现页点击播放。
+- 底部播放条播放、暂停、上一首、下一首。
+- 全屏播放页展开、关闭、拖动进度、上下滑动交互。
+- 音乐卡片播放、暂停、切歌、打开播放页。
+- 返回键在播放页打开时优先关闭浮层。
+
+如果本轮新增可稳定编写的 Hypium 用例,可优先覆盖控制器层的纯逻辑部分,例如:
+
+- `PlaybackRequest` 队列构建。
+- 来源切换后的索引与状态恢复。
+- 卡片状态快照更新逻辑。
+
+## 风险与取舍
+
+### 风险
+
+- 现有播放器逻辑大量依赖页面成员变量,首轮迁移时容易出现漏迁。
+- `AppStorage`、`@Consume`、`eventHub`、控制器新状态源并存的过渡期会增加短期复杂度。
+- 多来源播放链路分散,回归验证量较大。
+
+### 取舍
+
+- 首版优先保证职责分离和行为兼容,不追求一次性清理所有历史状态通道。
+- 先把“谁负责什么”理顺,再逐步减少页面直接持有的播放器细节。
+- 先复用现有交互和动画,避免在架构迁移阶段同时引入新的 UI 回归风险。
+
+## 结论
+
+本次重构采用“`NewIndex` 做宿主、`LocalMusic2` 做内容页、`MusicPlayerBuilder` 做播放页、`MusicPlaybackController` 做唯一控制中台”的拆法。
+
+这是在不修改 `LocalMusic` 的约束下,兼顾现有交互复用、多来源兼容、音乐卡片接入和后续可维护性的最稳方案。后续实施时应严格按照迁移顺序推进,避免再次把播放器能力回流到内容页中。

+ 0 - 294
docs/superpowers/specs/2026-03-31-localmusic-player-refactor-design.md

@@ -1,294 +0,0 @@
-# LocalMusic 播放控制与播放页拆分设计
-
-## 背景
-
-当前播放器相关能力主要堆叠在 `entry/src/main/ets/view/LocalMusic.ets` 中,形成了几个明显问题:
-
-1. `LocalMusic.ets` 同时承担媒体库列表、分页加载、播放控制、播放页 UI、歌词、播放列表弹层、详情、编辑标签、均衡器、更多菜单等职责,文件过大,修改风险高。
-2. 迷你播放条的显示状态已经在 `entry/src/main/ets/pages/NewIndex.ets` 上层维护,但播放页实际仍由 `LocalMusic.ets` 内部 `bindContentCover` 托管,导致播放页生命周期和媒体库页面深度耦合。
-3. 歌曲详情、编辑标签这类能力既被媒体库列表使用,也被播放页使用,但当前实现散落在 `LocalMusic.ets` 私有状态中,复用边界不清晰。
-4. 重型播放状态与超大列表页面常驻耦合,容易造成状态膨胀、页面驻留成本过高,不利于后续排查内存占用和发热问题。
-
-用户希望本轮先完成播放器重构,再在第二阶段实现桌面音乐卡片;并明确要求:
-
-- 播放页不要再走 `router`,改用 `bindContentCover`。
-- 点击迷你播放条后弹出独立播放页。
-- 现有播放页能力尽量整体搬迁,不做缩水版。
-- 歌曲详情、编辑标签等需要复用的能力要抽成组件,供 `LocalMusic` 和播放页共用。
-- 本轮尽量不改现有交互和视觉效果,做无感拆分。
-
-## 目标
-
-- 将播放页容器从 `LocalMusic.ets` 迁移到 `NewIndex.ets` 上层,通过 `bindContentCover` 统一托管。
-- 将播放控制逻辑从 `LocalMusic.ets` 抽离为独立播放控制层,后续可被迷你播放条、播放页、桌面卡片复用。
-- 将歌曲详情、编辑标签等复用能力抽成独立组件,不再依赖 `LocalMusic.ets` 私有页面状态。
-- `LocalMusic.ets` 只保留媒体库、分页、列表、详情导航、列表入口等媒体库域职责。
-- 在不改变主要交互与视觉效果的前提下,降低 `LocalMusic.ets` 常驻状态复杂度,为内存占用和发热优化打基础。
-
-## 非目标
-
-- 本轮不实现桌面音乐卡片。
-- 本轮不把播放页改造成独立 Navigation 路由页面。
-- 本轮不重做播放器视觉设计,也不主动改变现有手势和动画语义。
-- 本轮不顺带大规模重构媒体库分页、远程歌曲、发现页播放逻辑,除非为接入新播放控制层所必需。
-
-## 现状分析
-
-### 1. 播放页宿主位置不对
-
-- `NewIndex.ets` 已通过 `@Provide isShowPlay` 持有播放页显示状态,并负责迷你播放条交互。
-- 但 `LocalMusic.ets` 内部仍使用 `bindContentCover($$this.isShowPlay, this.MusicPlayBuilder(), ...)` 托管播放页。
-- 结果是“全局播放页状态”由上层拥有,“播放页实例和生命周期”却绑在子页面里,边界分裂。
-
-### 2. 播放控制与媒体库页面耦合过深
-
-- `LocalMusic.ets` 内同时持有 `IjkMediaPlayer`、进度更新、歌词控制器、AVSession、播放页动画状态、播放列表弹层状态、编辑标签状态等重型对象和状态。
-- 播放相关事件目前主要通过 `eventHub` 与 `LocalMusic.ets` 通信,说明其他页面把它当作事实上的播放控制中心。
-- 这会导致后续任何播放能力扩展都必须回到 `LocalMusic.ets` 修改。
-
-### 3. 复用能力没有明确边界
-
-- 歌曲详情 `detailSheet(item)`、编辑标签 `editSheet(item)` 既服务于列表项长按,也服务于播放页更多功能。
-- 但它们依赖 `LocalMusic.ets` 的编辑字段、歌词临时状态、封面状态、保存回写流程。
-- 结果是播放页即使拆出单独组件,也无法真正摆脱 `LocalMusic.ets`。
-
-## 方案对比
-
-### 方案 A:只抽播放页 UI 文件
-
-做法:
-
-- 将 `MusicPlayBuilder()` 的 UI 拆到独立组件文件。
-- `IjkMediaPlayer`、歌词、播放控制、弹层状态继续保留在 `LocalMusic.ets`。
-
-优点:
-
-- 改动最小,上手最快。
-
-缺点:
-
-- 本质仍是“一个大页面 + 一个外置 UI 文件”,没有解决播放控制归属问题。
-- `LocalMusic.ets` 体量和状态复杂度下降有限。
-- 对内存、发热、后续音乐卡片复用帮助很小。
-
-### 方案 B:拆分“播放控制层 + 独立播放页 + 复用弹层组件”
-
-做法:
-
-- `NewIndex.ets` 成为播放页唯一宿主,通过 `bindContentCover` 托管独立播放页组件。
-- 抽出独立播放控制层,统一管理播放器实例、进度、播放行为、播放事件、歌词同步等播放域能力。
-- 将歌曲详情、编辑标签等复用能力抽成组件,列表页和播放页共享。
-
-优点:
-
-- 边界清晰,能真正降低 `LocalMusic.ets` 复杂度。
-- 能保持当前交互外观基本不变,同时为音乐卡片和其他播放入口留出复用接口。
-- 能把常驻重型播放状态从媒体库大页面中拆出,优化方向正确。
-
-缺点:
-
-- 改动面较大,需要梳理当前播放页依赖。
-- 需要在短期内同时处理状态迁移和组件复用。
-
-### 方案 C:直接改成独立 Navigation 页面
-
-做法:
-
-- 新建独立播放页路由或 Navigation 目的页。
-- 所有播放页打开动作统一改为页面跳转。
-
-优点:
-
-- 从页面结构上最彻底。
-
-缺点:
-
-- 与当前用户要求不符。
-- 会改变返回链路和页面层级,回归风险高。
-- 不适合这次“无感拆分”的目标。
-
-## 推荐方案
-
-本轮采用方案 B。
-
-原因:
-
-1. 它是本轮唯一同时满足“无感拆分”“不走 router”“整体搬迁现有播放页能力”“后续支持音乐卡片”的方案。
-2. 播放页 UI 是否拆文件不是核心,核心是播放域状态从 `LocalMusic.ets` 脱钩;方案 B 能真正做到这一点。
-3. 通过先抽公共组件,再迁播放页容器,可以在尽量保持现有交互的前提下逐步落地,风险可控。
-
-## 设计
-
-### 1. 顶层承载结构
-
-- `NewIndex.ets` 持续作为 `isShowPlay` 的顶层拥有者。
-- 将播放页的 `bindContentCover` 从 `LocalMusic.ets` 移除,迁移到 `NewIndex.ets`。
-- `NewIndex.ets` 负责:
-  - 迷你播放条点击后打开播放页。
-  - 返回键优先关闭播放页。
-  - 承载独立播放页组件。
-
-这样可以保证播放页是全局层能力,而不是媒体库子页面内部能力。
-
-### 2. 播放控制层边界
-
-新增独立播放控制层,统一承接以下职责:
-
-- 持有或封装 `IjkMediaPlayer` 实例。
-- 处理播放、暂停、上一首、下一首、切歌、seek、倍速、循环模式、随机模式。
-- 维护播放进度、总时长、当前时间、缓冲态、播放状态。
-- 管理歌词控制器、歌词同步、单行歌词与播放页歌词展示所需状态。
-- 处理播放相关 `eventHub` 事件注册与派发。
-- 同步 `AppStorage` 中仍需对外共享的关键状态,如 `currentSong`、`songList`、`currIndex`、`isPlaying`。
-
-该层不负责媒体库列表分页,不直接承担大段页面 UI 构建。
-
-### 3. 播放页组件边界
-
-新增独立播放页组件 `PlayerPage.ets`。
-
-该组件负责:
-
-- 承载现有播放页完整视觉结构与交互。
-- 处理下拉关闭、上滑切歌、封面/歌词切换、底部控制区、更多菜单、播放列表入口等 UI 行为。
-- 通过播放控制层读取和修改播放状态。
-
-该组件不再自己初始化播放器核心能力,也不直接承担媒体库页面职责。
-
-### 4. 公共复用组件边界
-
-本轮至少抽出以下复用组件:
-
-- 歌曲详情组件:供列表页与播放页共用。
-- 编辑标签组件:供列表页与播放页共用。
-
-必要时可继续抽出:
-
-- 当前播放列表组件。
-- 播放页更多菜单中的独立弹层项。
-
-这些组件的原则是:
-
-- 组件只负责 UI 与交互组织。
-- 具体保存、刷新、同步逻辑通过参数和回调传入。
-- 不再直接耦合 `LocalMusic.ets` 私有字段命名和私有状态流。
-
-### 5. LocalMusic 保留职责
-
-重构后 `LocalMusic.ets` 只保留媒体库域能力:
-
-- 本地歌曲、艺术家、专辑、文件夹等列表加载与分页。
-- 列表页排序、筛选、详情页切换。
-- 列表项点击后发起播放请求。
-- 列表项长按时调起公共详情组件、公共编辑标签组件。
-- 媒体库页本身的显示与交互。
-
-`LocalMusic.ets` 不再负责:
-
-- 托管播放页 `bindContentCover`。
-- 独占播放控制中心角色。
-- 长期持有只为播放页服务的大量临时 UI 状态。
-
-## 数据与状态流
-
-### 1. 顶层状态
-
-- `isShowPlay` 继续由 `NewIndex.ets` 维护。
-- `NewIndex.ets` 通过 `bindContentCover` 决定独立播放页显示与关闭。
-
-### 2. 共享播放状态
-
-为降低一次性改动风险,本轮继续复用已有 `AppStorage` 关键键值:
-
-- `currentSong`
-- `songList`
-- `currIndex`
-- `isPlaying`
-
-这样可以先稳定迁移播放能力,不强行在本轮引入新的全局状态协议。
-
-### 3. 事件流
-
-现有迷你播放条、发现页等外部入口仍可暂时复用 `eventHub`,但事件的接收与处理从 `LocalMusic.ets` 转移到新的播放控制宿主。
-
-核心事件包括:
-
-- 打开播放页
-- 关闭播放页
-- 播放 / 暂停
-- 上一首 / 下一首
-- 打开当前播放列表
-
-后续如果需要,再逐步减少对 `eventHub` 的依赖。
-
-### 4. 编辑与详情回写
-
-歌曲详情、编辑标签组件关闭后,需要通过回调完成:
-
-- 当前歌曲信息同步。
-- 媒体库列表项刷新。
-- 播放页展示信息刷新。
-- 必要的歌词、封面、标签文本同步。
-
-回写逻辑放在控制层或调用方,不写死在公共组件内部。
-
-## 性能与内存优化方向
-
-本轮不承诺一次性彻底解决所有发热问题,但要明确消减以下高风险点:
-
-1. 将播放页容器从 `LocalMusic.ets` 移出,减少媒体库超大页面与完整播放页 UI 的常驻绑定。
-2. 将播放控制、歌词控制、播放页临时动画状态从媒体库列表域剥离,降低页面级状态数量。
-3. 公共弹层组件按需创建,避免详情、编辑标签逻辑继续深埋在超大页面内部。
-4. 为第二阶段音乐卡片复用播放器控制层,避免未来再次从 `LocalMusic.ets` 复制逻辑。
-
-## 风险与处理
-
-### 风险 1:播放页拆出后交互回归
-
-处理:
-
-- 播放页 UI 以“整体搬迁”为原则,优先保持现有结构与行为。
-- 顶层只改变承载位置,不主动改视觉和交互语义。
-
-### 风险 2:播放控制迁移时共享状态丢失
-
-处理:
-
-- 第一版继续复用现有 `AppStorage` 键,降低联动改造范围。
-- 事件入口保持兼容,优先确保迷你播放条、列表页、发现页不被破坏。
-
-### 风险 3:详情/编辑标签拆分后刷新链路断裂
-
-处理:
-
-- 在组件接口中显式定义保存成功回调、关闭回调、当前歌曲回写回调。
-- 先围绕当前使用点完成最小闭环,不做过度抽象。
-
-### 风险 4:发热问题不能立刻完全改善
-
-处理:
-
-- 本轮先消除最明显的结构性耦合点。
-- 实现后重点回归连续播放、多次打开关闭播放页、媒体库长驻场景,观察驻留与交互流畅度。
-
-## 验证
-
-### 手工验证
-
-1. 从迷你播放条点击进入播放页,确认通过 `bindContentCover` 打开独立播放页。
-2. 播放页内播放、暂停、上一首、下一首、拖动进度、歌词切换、播放列表打开行为与重构前保持一致。
-3. 从播放页打开“详情”“编辑标签”,确认组件正常展示,并能正确保存和回写当前歌曲信息。
-4. 从媒体库列表长按歌曲打开“详情”“编辑标签”,确认与播放页复用同一套组件,行为正常。
-5. 播放页关闭后,迷你播放条状态保持正常,返回键可优先关闭播放页。
-6. 连续播放多首歌曲,反复打开关闭播放页,确认没有明显卡顿、异常状态残留或播放器失联。
-
-### 回归验证
-
-- 发现页、远程页、迷你播放条仍可正常控制播放。
-- `LocalMusic.ets` 分页列表、排序、详情页切换行为不受影响。
-- 当前播放列表总数、歌曲切换、封面和歌词展示保持正确。
-
-### 自动化验证
-
-- 如本轮新增了可纯逻辑测试的播放控制辅助类,为其补充对应单测。
-- 当前仓库若受本地 DevEco / SDK 环境限制无法完成可靠编译,需要在实现阶段明确记录未完成项。

+ 281 - 0
docs/superpowers/specs/2026-04-02-playback-controller-decouple-localmusic-design.md

@@ -0,0 +1,281 @@
+# 播放控制脱离 LocalMusic 宿主设计
+
+## 背景
+
+当前项目里的统一播放控制入口是 `MusicPlaybackController`,但它本质上仍然只是一个动作分发器或事件桥接层:
+
+- `playOrPause`、`playNext`、`playPrevious`、`seekTo` 等动作依赖 `LocalMusic` 在页面生命周期里注册 actions。
+- 起播一首歌或一组歌时,页面侧通常还是通过 `EVENT_PLAYLIST_PLAY` 发送请求,再由 `LocalMusic` 监听并执行真实播放。
+- 播放器实例、播放队列、当前歌曲、当前索引、AVSession、投播联动等核心逻辑都沉在 `LocalMusic` 内部。
+
+这导致一个结构性问题:任何页面想直接调用 controller 播放,实际上仍然依赖 `LocalMusic` 充当“播放器宿主页面”。
+
+## 本阶段目标
+
+第一阶段只解决“播放核心依赖 LocalMusic 宿主”的问题,不做整站 UI 重构。
+
+完成标准:
+
+- 任意页面可以直接通过 `MusicPlaybackController` 起播单曲或队列。
+- 任意页面可以直接通过 `MusicPlaybackController` 调用 `play/pause/next/previous/seek`。
+- `LocalMusic` 不再负责接收 `EVENT_PLAYLIST_PLAY` 作为主播放入口。
+- 迷你播放条、全屏播放器、播放页显隐、列表高亮等 UI 显示状态,暂时仍保留在 `NewIndex/LocalMusic` 层。
+- 允许 `LocalMusic` 继续常驻隐藏挂载,但它不再是播放指令的宿主。
+
+非目标:
+
+- 本阶段不把迷你播放条状态完全抽成独立 store。
+- 本阶段不强行拆完 `LocalMusic` 的所有播放相关字段。
+- 本阶段不重写投播、歌词、AVSession 的全部实现方式,只调整宿主归属和调用链。
+
+## 推荐方案
+
+采用“抽离 `PlaybackCoordinator`,保留现有 UI 宿主”的方案。
+
+核心思想:
+
+- `MusicPlaybackController` 升级成真正的统一控制入口。
+- 新增 `PlaybackCoordinator` 承接真实播放核心逻辑。
+- `LocalMusic` 从“播放逻辑宿主”降级为“播放 UI 容器”。
+- 页面层不再依赖 `EVENT_PLAYLIST_PLAY -> LocalMusic` 这条链路起播。
+
+不采用以下方案:
+
+- 仅继续做事件桥接。这样 controller 仍然不是实际播放入口,后续还要返工。
+- 一次性把 UI 状态和核心状态全部独立。首轮改动面过大,容易同时打坏迷你播放条、投播、AVSession 与歌词联动。
+
+## 目标架构
+
+### 1. MusicPlaybackController
+
+职责:
+
+- 提供全局统一 API。
+- 对外暴露直接控制接口,而不是依赖页面注册 actions。
+- 将页面请求转发给 `PlaybackCoordinator`。
+
+第一阶段期望接口:
+
+- `playSong(song: VideoItem, options?)`
+- `playQueue(songs: VideoItem[], startIndex: number, options?)`
+- `playOrPause()`
+- `playNext()`
+- `playPrevious()`
+- `seekTo(value: string, source?: string)`
+
+兼容策略:
+
+- 旧的 `setActions/clearActions` 暂时保留,但标记为过渡能力。
+- controller 内部优先走 `PlaybackCoordinator`;仅对尚未迁出的能力允许短期回退。
+
+### 2. PlaybackCoordinator
+
+新增协调层,负责播放核心链路。
+
+职责:
+
+- 持有当前播放队列、当前索引、当前歌曲、基础播放状态。
+- 承接起播单曲、起播队列、切歌、暂停/恢复、seek。
+- 协调底层 player、AVSession、投播模块与播放元数据同步。
+- 向 UI 层暴露可订阅的最小播放状态。
+
+第一阶段必须迁出的逻辑:
+
+- `doPlay` 主链路及其前后置准备逻辑。
+- 当前歌曲切换。
+- 播放队列切换。
+- `play/pause/next/previous/seek` 的控制主路径。
+
+第一阶段允许仍留在 UI 层的逻辑:
+
+- 播放页打开关闭。
+- 迷你播放条显示/隐藏状态。
+- 纯视觉动画与页面交互状态。
+
+### 3. LocalMusic
+
+迁移后职责:
+
+- 读取 `PlaybackCoordinator` 暴露的播放状态并渲染 UI。
+- 负责全屏播放器、播放列表界面、迷你播放条相关视觉层逻辑。
+- 保留页面内交互,但交互结果改为调用 controller,而不是直接执行播放核心流程。
+
+迁移后不再承担的职责:
+
+- 监听 `EVENT_PLAYLIST_PLAY` 作为主播放入口。
+- 持有独占的起播核心流程。
+- 作为 controller actions 的唯一宿主。
+
+### 4. NewIndex
+
+保持当前职责:
+
+- 承载页面级 UI 容器。
+- 继续持有迷你播放条显示层状态。
+- 通过共享播放状态决定何时展示播放 UI。
+
+## 数据流
+
+迁移前:
+
+1. 页面点击歌曲。
+2. 页面发事件或调用桥接逻辑。
+3. `LocalMusic` 监听事件。
+4. `LocalMusic` 执行 `doPlay` 与后续播放流程。
+
+迁移后:
+
+1. 页面点击歌曲。
+2. 页面直接调用 `MusicPlaybackController.playSong(...)` 或 `playQueue(...)`。
+3. controller 直接调用 `PlaybackCoordinator`。
+4. `PlaybackCoordinator` 更新播放队列、当前歌曲、底层播放器与共享播放状态。
+5. `LocalMusic/NewIndex` 作为 UI 消费者响应状态变化。
+
+关键变化:
+
+- 页面到播放核心的入口从“事件 + LocalMusic”改为“controller + coordinator”。
+- `LocalMusic` 从命令执行者变成状态消费者。
+
+## 状态拆分原则
+
+为降低风险,第一阶段只抽“核心播放状态”,不抽“全部 UI 状态”。
+
+核心播放状态建议包含:
+
+- 当前歌曲 `currentSong`
+- 播放队列 `songList`
+- 当前索引 `curIndex`
+- 是否正在播放 `isPlaying`
+- 当前进度/时长
+- 当前播放模式
+
+继续留在 UI 层的状态:
+
+- 播放页是否展开
+- 迷你播放条显隐
+- 页面动画状态
+- 手势、滚动、弹窗、浮层相关状态
+
+判断标准:
+
+- 影响实际播放行为的状态,属于 coordinator。
+- 只影响视觉表现的状态,留在 UI。
+
+## 分阶段迁移计划
+
+### 阶段 1:抽出起播入口
+
+目标:
+
+- 从 `LocalMusic` 中抽离 `doPlay` 主链路到 `PlaybackCoordinator`。
+- 支持 `playSong(song)` 和 `playQueue(songs, startIndex)`。
+- 首批页面切到新入口:`ChartsCount`、歌单页、发现页等最直接的列表页。
+
+验收:
+
+- 不经 `EVENT_PLAYLIST_PLAY` 也能直接起播。
+- 首次起播后迷你播放条和播放器界面仍能正常响应。
+
+### 阶段 2:抽出基础控制动作
+
+目标:
+
+- 将 `playOrPause`、`playNext`、`playPrevious`、`seekTo` 从 `LocalMusic` 挪到 `PlaybackCoordinator`。
+- `MusicPlaybackController.setActions/clearActions` 进入兼容态,不再作为主路径。
+
+验收:
+
+- 页面级 controller 调用不再依赖 `LocalMusic` 注册 actions。
+
+### 阶段 3:UI 状态改为消费共享状态
+
+目标:
+
+- `LocalMusic` 改为从共享状态读取当前歌曲、索引、播放状态、进度等。
+- 清理 UI 层对核心播放状态的直接写入。
+
+验收:
+
+- `LocalMusic` 只做 UI 响应,不再承担核心状态源头角色。
+
+### 阶段 4:清理旧事件桥接
+
+目标:
+
+- 清理 `EVENT_PLAYLIST_PLAY` 作为本地主播放主链的职责。
+- 清理为桥接页面播放而存在的过渡数据仓库和 pending request 逻辑。
+
+验收:
+
+- 本地播放主链全部走 controller -> coordinator。
+
+## 兼容与回退策略
+
+为了避免一次改坏所有场景,第一阶段允许短期双轨:
+
+- 新页面入口优先调用 `controller -> coordinator`。
+- 未迁移完的少数路径可保留旧逻辑,直到对应页面切换完成。
+
+但有两条硬约束:
+
+- 不允许新增新的 `EVENT_PLAYLIST_PLAY -> LocalMusic` 依赖。
+- controller 对外文档和新代码一律以 direct API 为准。
+
+## 风险与约束
+
+### 1. 强耦合字段风险
+
+`LocalMusic` 当前很可能把播放器状态、UI 状态、动画状态、投播状态混在同一个大组件中。
+
+应对:
+
+- 第一阶段先只迁移真正决定播放行为的字段。
+- 对一时拆不开的字段,先通过只读映射或桥接读取,避免一轮就全部改写。
+
+### 2. AVSession / 投播联动风险
+
+如果 AVSession 与投播流程深度耦合 `LocalMusic` 内部字段,直接搬迁会有回归风险。
+
+应对:
+
+- 优先保证 coordinator 成为“命令主入口”。
+- AVSession 与投播在第一阶段可通过适配层接入 coordinator,不要求同一轮完全重构。
+
+### 3. UI 不同步风险
+
+若 coordinator 成为状态源,但 `LocalMusic` 仍在局部直接写旧字段,容易出现 UI 与实际播放状态分裂。
+
+应对:
+
+- 每迁一类动作,就同步把对应 UI 读取切到共享状态。
+- 禁止同一字段同时存在“coordinator 写”和“UI 自己写”两套主路径。
+
+## 测试与验证
+
+第一阶段验证重点不是全量 UI,而是播放主链切换是否成功。
+
+必须验证:
+
+- controller 直接调用可起播单曲。
+- controller 直接调用可起播队列。
+- `play/pause/next/previous/seek` 不依赖 `LocalMusic` actions。
+- `LocalMusic` 不再作为 `EVENT_PLAYLIST_PLAY` 的主执行入口。
+- 迷你播放条和全屏播放器仍能跟随共享状态更新。
+
+建议测试层次:
+
+- 控制器与 coordinator 的纯逻辑测试。
+- 队列切换与索引切换的回归测试。
+- AVSession / 投播的冒烟验证。
+- 真机手工验证:统计页、歌单页、发现页直接起播。
+
+## 决策结论
+
+本阶段采用以下明确决策:
+
+- 可以接受 `LocalMusic` 继续常驻隐藏挂载。
+- 不再接受 `LocalMusic` 作为播放指令宿主。
+- 先保留 `NewIndex/LocalMusic` 的 UI 显示状态。
+- 先抽播放核心,再逐步抽状态与显示层。
+
+这保证第一阶段的目标聚焦为:让 controller 真正变成任何页面都能直接调用的播放入口,同时把风险控制在可验证范围内。

+ 80 - 0
entry/src/main/ets/common/network/EmbyApi.ets

@@ -2,6 +2,7 @@ import { http } from '@kit.NetworkKit';
 import { PreferencesUtil } from '@pura/harmony-utils';
 import { WebDavAccount } from '../../viewmodel/WebDavAccount';
 import { ServerLogUtil } from '../util/ServerLogUtil';
+import { convertEmbyPlaylistEntries, EmbyPlaylistSummary, findEmbyMusicLibraryViewId } from '../util/EmbyPlaylistHelper';
 
 const TAG = 'heanup EmbyApi';
 const CLIENT_NAME = 'TTMusic';
@@ -34,6 +35,7 @@ interface EmbyItem {
   Id?: string;
   Name?: string;
   Type?: string;
+  CollectionType?: string;
   Album?: string;
   AlbumId?: string;
   Artists?: string[];
@@ -42,6 +44,7 @@ interface EmbyItem {
   RunTimeTicks?: number;
   ProductionYear?: number;
   IndexNumber?: number;
+  ChildCount?: number;
   ImageTags?: EmbyItemImageTags;
   MediaSources?: Array<EmbyMediaSource>;
 }
@@ -146,8 +149,12 @@ export interface EmbySong {
   lyricIndex?: number; // 歌词流索引
 }
 
+export interface EmbyPlaylist extends EmbyPlaylistSummary {
+}
+
 export class EmbyApi {
   private authCache: Map<string, EmbyAuthContext> = new Map();
+  private musicViewCache: Map<string, string> = new Map();
 
   async getArtists(account: WebDavAccount): Promise<EmbyArtist[]> {
     const auth = await this.ensureAuth(account);
@@ -372,6 +379,63 @@ export class EmbyApi {
     return result;
   }
 
+  async getPlaylistsPage(account: WebDavAccount, startIndex: number = 0, limit: number = 200): Promise<EmbyPagedResponse<EmbyPlaylist>> {
+    const auth = await this.ensureAuth(account);
+    const musicViewId = await this.getMusicLibraryViewId(account, auth.userId);
+    if (!musicViewId) {
+      throw new Error('Emby 未找到音乐媒体库视图');
+    }
+    const params: Array<QueryParam> = [
+      new QueryParam('SortBy', 'SortName'),
+      new QueryParam('SortOrder', 'Ascending'),
+      new QueryParam('ParentId', musicViewId),
+      new QueryParam('Recursive', 'true'),
+      new QueryParam('IncludeItemTypes', 'Playlist'),
+      new QueryParam('Fields', 'SortName,CanDelete,PrimaryImageAspectRatio,BasicSyncInfo,Container,ProductionYear,Status,EndDate,Prefix'),
+      new QueryParam('EnableImageTypes', 'Primary'),
+      new QueryParam('StartIndex', startIndex.toString()),
+      new QueryParam('Limit', limit.toString())
+    ];
+    const response = await this.get<EmbyItemsResponse>(account, `/Users/${auth.userId}/Items`, params);
+    const items = response.Items ?? [];
+    const playlists = convertEmbyPlaylistEntries(items);
+    const total = response.TotalRecordCount;
+    const nextStart = total !== undefined
+      ? (startIndex + items.length < total ? startIndex + items.length : null)
+      : (items.length < limit ? null : startIndex + items.length);
+    const result: EmbyPagedResponse<EmbyPlaylist> = { items: playlists, nextStart: nextStart, total: total };
+    return result;
+  }
+
+  async getPlaylistSongsPage(account: WebDavAccount, playlistId: string, startIndex: number = 0, limit: number = 200): Promise<EmbyPagedResponse<EmbySong>> {
+    const auth = await this.ensureAuth(account);
+    const params: Array<QueryParam> = [
+      new QueryParam('SortBy', 'ListItemOrder,SortName,Album,ParentIndexNumber,IndexNumber'),
+      new QueryParam('SortOrder', 'Ascending'),
+      new QueryParam('Fields', 'PrimaryImageAspectRatio,MediaSources,AudioInfo,DateCreated,ProductionYear'),
+      new QueryParam('ImageTypeLimit', '1'),
+      new QueryParam('IncludeItemTypes', 'Audio'),
+      new QueryParam('ParentId', playlistId),
+      new QueryParam('StartIndex', startIndex.toString()),
+      new QueryParam('Limit', limit.toString())
+    ];
+    const response = await this.get<EmbyItemsResponse>(account, `/Users/${auth.userId}/Items`, params);
+    const items = response.Items ?? [];
+    const songs: EmbySong[] = [];
+    for (let i = 0; i < items.length; i++) {
+      const song = this.toSong(items[i]);
+      if (song) {
+        songs.push(song);
+      }
+    }
+    const total = response.TotalRecordCount;
+    const nextStart = total !== undefined
+      ? (startIndex + items.length < total ? startIndex + items.length : null)
+      : (items.length < limit ? null : startIndex + items.length);
+    const result: EmbyPagedResponse<EmbySong> = { items: songs, nextStart: nextStart, total: total };
+    return result;
+  }
+
   async searchSongs(account: WebDavAccount, keyword: string, startIndex: number = 0, limit: number = 200): Promise<EmbyPagedResponse<EmbySong>> {
     const auth = await this.ensureAuth(account);
     const params: Array<QueryParam> = [
@@ -537,6 +601,22 @@ export class EmbyApi {
   private invalidateAuth(account: WebDavAccount): void {
     const key = this.getAccountKey(account);
     this.authCache.delete(key);
+    this.musicViewCache.delete(key);
+  }
+
+  private async getMusicLibraryViewId(account: WebDavAccount, userId: string): Promise<string | undefined> {
+    const key = this.getAccountKey(account);
+    const cached = this.musicViewCache.get(key);
+    if (cached && cached.length > 0) {
+      return cached;
+    }
+    const response = await this.get<EmbyItemsResponse>(account, `/Users/${userId}/Views`);
+    const items = response.Items ?? [];
+    const viewId = findEmbyMusicLibraryViewId(items);
+    if (viewId && viewId.length > 0) {
+      this.musicViewCache.set(key, viewId);
+    }
+    return viewId;
   }
 
   private async login(account: WebDavAccount): Promise<EmbyAuthContext> {

+ 59 - 0
entry/src/main/ets/common/network/JellyfinApi.ets

@@ -42,6 +42,7 @@ interface JellyfinItem {
   RunTimeTicks?: number;
   ProductionYear?: number;
   IndexNumber?: number;
+  ChildCount?: number;
   ImageTags?: JellyfinItemImageTags;
   MediaSources?: Array<JellyfinMediaSource>;
 }
@@ -142,6 +143,13 @@ export interface JellyfinSong {
   year?: number;
 }
 
+export interface JellyfinPlaylist {
+  id: string;
+  name: string;
+  durationSeconds?: number;
+  songCount?: number;
+}
+
 export class JellyfinApi {
   private authCache: Map<string, JellyfinAuthContext> = new Map();
 
@@ -349,6 +357,57 @@ export class JellyfinApi {
     return result;
   }
 
+  async getPlaylistsPage(account: WebDavAccount, startIndex: number = 0, limit: number = 200): Promise<JellyfinPagedResponse<JellyfinPlaylist>> {
+    const auth = await this.ensureAuth(account);
+    const params: Array<QueryParam> = [
+      new QueryParam('SortBy', 'SortName'),
+      new QueryParam('SortOrder', 'Ascending'),
+      new QueryParam('Recursive', 'true'),
+      new QueryParam('IncludeItemTypes', 'Playlist'),
+      new QueryParam('MediaTypes', 'Audio'),
+      new QueryParam('Fields', 'SortName,CanDelete,PrimaryImageAspectRatio'),
+      new QueryParam('StartIndex', startIndex.toString()),
+      new QueryParam('Limit', limit.toString())
+    ];
+    const response = await this.get<JellyfinItemsResponse>(account, `/Users/${auth.userId}/Items`, params);
+    const items = response.Items ?? [];
+    const playlists: JellyfinPlaylist[] = items
+      .filter(item => item.Id && item.Name)
+      .map(item => {
+        const playlist: JellyfinPlaylist = {
+          id: item.Id as string,
+          name: item.Name as string,
+          durationSeconds: item.RunTimeTicks ? item.RunTimeTicks / 10000000 : undefined,
+          songCount: item.ChildCount
+        };
+        return playlist;
+      });
+    const total = response.TotalRecordCount;
+    const nextStart = total !== undefined
+      ? (startIndex + items.length < total ? startIndex + items.length : null)
+      : (items.length < limit ? null : startIndex + items.length);
+    const result: JellyfinPagedResponse<JellyfinPlaylist> = { items: playlists, nextStart: nextStart, total: total };
+    return result;
+  }
+
+  async getPlaylistSongs(account: WebDavAccount, playlistId: string): Promise<JellyfinSong[]> {
+    const auth = await this.ensureAuth(account);
+    const params: Array<QueryParam> = [
+      new QueryParam('Fields', 'SortName,CanDelete,MediaSources,DateCreated,ProductionYear'),
+      new QueryParam('UserId', auth.userId)
+    ];
+    const response = await this.get<JellyfinItemsResponse>(account, `/Playlists/${playlistId}/Items`, params);
+    const items = response.Items ?? [];
+    const songs: JellyfinSong[] = [];
+    for (let i = 0; i < items.length; i++) {
+      const song = this.toSong(items[i]);
+      if (song) {
+        songs.push(song);
+      }
+    }
+    return songs;
+  }
+
   async searchSongs(account: WebDavAccount, keyword: string, startIndex: number = 0, limit: number = 200): Promise<JellyfinPagedResponse<JellyfinSong>> {
     const auth = await this.ensureAuth(account);
     const params: Array<QueryParam> = [

+ 54 - 0
entry/src/main/ets/common/util/EmbyPlaylistHelper.ets

@@ -0,0 +1,54 @@
+export interface EmbyViewEntry {
+  Id?: string;
+  Name?: string;
+  CollectionType?: string;
+}
+
+export interface EmbyPlaylistEntry {
+  Id?: string;
+  Name?: string;
+  RunTimeTicks?: number;
+  ChildCount?: number;
+}
+
+export interface EmbyPlaylistSummary {
+  id: string;
+  name: string;
+  durationSeconds?: number;
+  songCount?: number;
+}
+
+export function findEmbyMusicLibraryViewId(items: EmbyViewEntry[]): string | undefined {
+  for (let i = 0; i < items.length; i++) {
+    const item = items[i];
+    if (!item.Id) {
+      continue;
+    }
+    if ((item.CollectionType ?? '').trim().toLowerCase() === 'music') {
+      return item.Id;
+    }
+  }
+  return undefined;
+}
+
+export function convertEmbyPlaylistEntries(items: EmbyPlaylistEntry[]): EmbyPlaylistSummary[] {
+  const results: EmbyPlaylistSummary[] = [];
+  for (let i = 0; i < items.length; i++) {
+    const item = items[i];
+    if (!item.Id || !item.Name) {
+      continue;
+    }
+    const summary: EmbyPlaylistSummary = {
+      id: item.Id,
+      name: item.Name
+    };
+    if (item.RunTimeTicks !== undefined && item.RunTimeTicks !== null && item.RunTimeTicks > 0) {
+      summary.durationSeconds = item.RunTimeTicks / 10000000;
+    }
+    if (item.ChildCount !== undefined && item.ChildCount !== null && item.ChildCount >= 0) {
+      summary.songCount = item.ChildCount;
+    }
+    results.push(summary);
+  }
+  return results;
+}

+ 25 - 0
entry/src/main/ets/common/util/FindPlaylistStore.ets

@@ -58,6 +58,31 @@ export function getFindPlaylistMeta(): FindPlaylistMeta {
   return cloneFindPlaylistMeta(findPlaylistMeta);
 }
 
+function isSameFindQueue(left: VideoItem[], right: VideoItem[]): boolean {
+  if (left.length !== right.length) {
+    return false;
+  }
+  for (let index = 0; index < left.length; index++) {
+    if ((left[index]?.filePath ?? '') !== (right[index]?.filePath ?? '')) {
+      return false;
+    }
+  }
+  return true;
+}
+
+export function resolveFindPagedLocalQueueMetaForPlayback(queue: VideoItem[], startIndex: number): FindPlaylistMeta | undefined {
+  if (!findPlaylistMeta.isPagedLocalQueue) {
+    return undefined;
+  }
+  if (findCurrentPlayIndex !== startIndex) {
+    return undefined;
+  }
+  if (!isSameFindQueue(findVideoItems, queue)) {
+    return undefined;
+  }
+  return cloneFindPlaylistMeta(findPlaylistMeta);
+}
+
 export function clearFindPlaylist(): void {
   findPlaylistId = '';
   findPlaylistName = '';

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

@@ -0,0 +1,3 @@
+export function shouldWrapLocalMusicMenuInScroll(_isContextMenu: boolean): boolean {
+  return true
+}

+ 36 - 0
entry/src/main/ets/common/util/RemoteMusicSearchHelper.ets

@@ -0,0 +1,36 @@
+import { NavidromeRestAlbum, NavidromeRestArtist, NavidromeRestPlaylist } from '../network/NavidromeRestApi'
+
+function normalizeKeyword(keyword: string): string {
+  return keyword.trim().toLowerCase()
+}
+
+function includesKeyword(value: string | undefined, keyword: string): boolean {
+  if (keyword.length === 0) {
+    return true
+  }
+  return (value ?? '').trim().toLowerCase().includes(keyword)
+}
+
+export function filterArtistsByKeyword(items: NavidromeRestArtist[], keyword: string): NavidromeRestArtist[] {
+  const normalized = normalizeKeyword(keyword)
+  if (normalized.length === 0) {
+    return [...items]
+  }
+  return items.filter((item: NavidromeRestArtist) => includesKeyword(item.name, normalized))
+}
+
+export function filterAlbumsByKeyword(items: NavidromeRestAlbum[], keyword: string): NavidromeRestAlbum[] {
+  const normalized = normalizeKeyword(keyword)
+  if (normalized.length === 0) {
+    return [...items]
+  }
+  return items.filter((item: NavidromeRestAlbum) => includesKeyword(item.name, normalized))
+}
+
+export function filterPlaylistsByKeyword(items: NavidromeRestPlaylist[], keyword: string): NavidromeRestPlaylist[] {
+  const normalized = normalizeKeyword(keyword)
+  if (normalized.length === 0) {
+    return [...items]
+  }
+  return items.filter((item: NavidromeRestPlaylist) => includesKeyword(item.name, normalized))
+}

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

@@ -0,0 +1,3 @@
+export function shouldRenderRemoteMusicSongView(selectedTab: number, isDetailView: boolean): boolean {
+  return selectedTab === 0 || isDetailView
+}

+ 88 - 5
entry/src/main/ets/controller/MusicPlaybackController.ets

@@ -1,3 +1,11 @@
+import { emitter } from '@kit.BasicServicesKit'
+import { EventConstants } from '../common/constants/EventConstants'
+import { buildPlaylistDispatchFilePaths } from '../common/util/PlaylistPlayDispatchHelper'
+import { PlaylistPlayRequest, savePendingPlaylistPlay } from '../common/util/PlaylistPlayRequestStore'
+import { setFindPlaylist } from '../common/util/FindPlaylistStore'
+import { PlaybackCoordinator } from './PlaybackCoordinator'
+import { VideoItem } from '../viewmodel/VideoItem'
+
 export interface MusicPlaybackControllerActions {
   playOrPause: () => void | Promise<void>
   playNext: () => void | Promise<void>
@@ -6,6 +14,15 @@ export interface MusicPlaybackControllerActions {
   seekTo: (value: string, source?: string) => void | Promise<void>
 }
 
+export interface InMemoryPlaylistPlaybackOptions {
+  playlistId: string
+  playlistName: string
+  songs: VideoItem[]
+  startIndex: number
+  isJump?: boolean
+  playType?: number
+}
+
 export interface LoopModeResolution {
   playType: number
   toastText: string
@@ -53,6 +70,7 @@ export type ItemPlaybackToggleAction = 'toggle_current' | 'play_target'
 export class MusicPlaybackController {
   private static instance?: MusicPlaybackController
   private actions?: MusicPlaybackControllerActions
+  private playbackCoordinator: PlaybackCoordinator = PlaybackCoordinator.getInstance()
 
   public static getInstance(): MusicPlaybackController {
     if (!MusicPlaybackController.instance) {
@@ -241,6 +259,23 @@ export class MusicPlaybackController {
     return 'play_target'
   }
 
+  public static buildInMemoryPlaylistPlayRequest(options: InMemoryPlaylistPlaybackOptions): PlaylistPlayRequest {
+    const songs = options.songs ?? []
+    const playlistId = options.playlistId.indexOf('find-') === 0 ? options.playlistId : `find-${options.playlistId}`
+    const maxIndex = Math.max(songs.length - 1, 0)
+    const safeStartIndex = songs.length <= 0 ? 0 : Math.max(0, Math.min(options.startIndex, maxIndex))
+
+    return new PlaylistPlayRequest(
+      playlistId,
+      options.playlistName,
+      songs.length,
+      safeStartIndex,
+      buildPlaylistDispatchFilePaths(playlistId, songs),
+      options.isJump,
+      options.playType
+    )
+  }
+
   public setActions(actions: MusicPlaybackControllerActions): void {
     this.actions = actions
   }
@@ -251,16 +286,49 @@ export class MusicPlaybackController {
     }
   }
 
-  public playOrPause(): void {
-    this.actions?.playOrPause()
+  public async playSong(song: VideoItem, source: string = 'controller', playType?: number): Promise<void> {
+    await this.playbackCoordinator.playSong(song, source, playType)
+  }
+
+  public async playQueue(queue: VideoItem[], startIndex: number, source: string = 'controller',
+    playType?: number): Promise<void> {
+    await this.playbackCoordinator.playQueue(queue, startIndex, source, playType)
+  }
+
+  public async playOrPauseDirect(): Promise<void> {
+    await this.playbackCoordinator.playOrPause()
+  }
+
+  public async playNextDirect(): Promise<void> {
+    await this.playbackCoordinator.playNext()
+  }
+
+  public async playPreviousDirect(): Promise<void> {
+    await this.playbackCoordinator.playPrevious()
+  }
+
+  public async playOrPause(): Promise<void> {
+    if (this.actions) {
+      await this.actions.playOrPause()
+      return
+    }
+    await this.playbackCoordinator.playOrPause()
   }
 
   public async playNext(): Promise<void> {
-    await this.actions?.playNext?.()
+    if (this.actions) {
+      await this.actions.playNext?.()
+      return
+    }
+    await this.playNextDirect()
   }
 
   public async playPrevious(): Promise<void> {
-    await this.actions?.playPrevious?.()
+    if (this.actions) {
+      await this.actions.playPrevious?.()
+      return
+    }
+    await this.playPreviousDirect()
   }
 
   public async setLoopMode(): Promise<void> {
@@ -268,6 +336,21 @@ export class MusicPlaybackController {
   }
 
   public async seekTo(value: string, source?: string): Promise<void> {
-    await this.actions?.seekTo?.(value, source)
+    if (this.actions) {
+      await this.actions.seekTo?.(value, source)
+      return
+    }
+    await this.playbackCoordinator.seekTo(value, source)
+  }
+
+  public playInMemoryPlaylist(options: InMemoryPlaylistPlaybackOptions): void {
+    if (!options.songs || options.songs.length <= 0) {
+      return
+    }
+
+    const request = MusicPlaybackController.buildInMemoryPlaylistPlayRequest(options)
+    setFindPlaylist(request.playlistId, request.playlistName, options.songs, request.startIndex)
+    savePendingPlaylistPlay(request)
+    emitter.emit({ eventId: EventConstants.EVENT_PLAYLIST_PLAY }, { data: request })
   }
 }

+ 69 - 0
entry/src/main/ets/controller/PlaybackCoordinator.ets

@@ -0,0 +1,69 @@
+import { PlaybackStateBridge } from './PlaybackStateBridge'
+import { VideoItem } from '../viewmodel/VideoItem'
+
+export interface PlaybackRuntime {
+  playQueue: (queue: VideoItem[], startIndex: number, source: string, playType?: number) => Promise<void>
+  playOrPause: () => Promise<void>
+  playNext: () => Promise<void>
+  playPrevious: () => Promise<void>
+  seekTo: (value: string, source?: string) => Promise<void>
+}
+
+export class PlaybackCoordinator {
+  private static instance?: PlaybackCoordinator
+  private runtime?: PlaybackRuntime
+  private stateBridge: PlaybackStateBridge = PlaybackStateBridge.getInstance()
+  private constructor() {}
+
+  public static getInstance(): PlaybackCoordinator {
+    if (!PlaybackCoordinator.instance) {
+      PlaybackCoordinator.instance = new PlaybackCoordinator()
+    }
+    return PlaybackCoordinator.instance
+  }
+
+  public setRuntime(runtime: PlaybackRuntime): void {
+    this.runtime = runtime
+  }
+
+  public clearRuntime(runtime?: PlaybackRuntime): void {
+    if (!runtime || this.runtime === runtime) {
+      this.runtime = undefined
+    }
+  }
+
+  public async playQueue(queue: VideoItem[], startIndex: number, source: string, playType?: number): Promise<void> {
+    if (!this.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)
+    } catch (error) {
+      this.stateBridge.restoreSnapshot(previousSnapshot)
+      const err = error as Error
+      throw new Error(err.message)
+    }
+  }
+
+  public async playSong(song: VideoItem, source: string, playType?: number): Promise<void> {
+    await this.playQueue([song], 0, source, playType)
+  }
+
+  public async playOrPause(): Promise<void> {
+    await this.runtime?.playOrPause()
+  }
+
+  public async playNext(): Promise<void> {
+    await this.runtime?.playNext()
+  }
+
+  public async playPrevious(): Promise<void> {
+    await this.runtime?.playPrevious()
+  }
+
+  public async seekTo(value: string, source?: string): Promise<void> {
+    await this.runtime?.seekTo(value, source)
+  }
+}

+ 104 - 0
entry/src/main/ets/controller/PlaybackStateBridge.ets

@@ -0,0 +1,104 @@
+import { VideoItem } from '../viewmodel/VideoItem'
+
+export interface PlaybackSnapshot {
+  queue: VideoItem[]
+  currentIndex: number
+  currentSong?: VideoItem
+  isPlaying: boolean
+  positionMs: number
+  durationMs: number
+}
+
+export class PlaybackStateBridge {
+  private static instance?: PlaybackStateBridge
+  private snapshot: PlaybackSnapshot = {
+    queue: [],
+    currentIndex: -1,
+    currentSong: undefined,
+    isPlaying: false,
+    positionMs: 0,
+    durationMs: 0
+  }
+
+  private constructor() {
+  }
+
+  public static getInstance(): PlaybackStateBridge {
+    if (!PlaybackStateBridge.instance) {
+      PlaybackStateBridge.instance = new PlaybackStateBridge()
+    }
+    return PlaybackStateBridge.instance
+  }
+
+  public reset(): void {
+    this.snapshot = this.buildSnapshot([], -1, undefined, false, 0, 0)
+    AppStorage.setOrCreate('currentSong', undefined)
+    AppStorage.setOrCreate('currentQueue', [])
+    AppStorage.setOrCreate('currentQueueIndex', -1)
+    AppStorage.setOrCreate('isPlaying', false)
+  }
+
+  public replaceQueue(queue: VideoItem[], startIndex: number): void {
+    const safeQueue = queue.slice()
+    const maxIndex = Math.max(safeQueue.length - 1, 0)
+    const safeIndex = safeQueue.length <= 0 ? -1 : Math.max(0, Math.min(startIndex, maxIndex))
+    const currentSong = safeIndex >= 0 ? safeQueue[safeIndex] : undefined
+
+    this.snapshot = this.buildSnapshot(safeQueue, safeIndex, currentSong, false, 0, 0)
+    AppStorage.setOrCreate('currentSong', currentSong)
+    AppStorage.setOrCreate('currentQueue', safeQueue)
+    AppStorage.setOrCreate('currentQueueIndex', safeIndex)
+    AppStorage.setOrCreate('isPlaying', false)
+  }
+
+  public updatePlaybackState(isPlaying: boolean, positionMs: number, durationMs: number): void {
+    this.snapshot = this.buildSnapshot(
+      this.snapshot.queue,
+      this.snapshot.currentIndex,
+      this.snapshot.currentSong,
+      isPlaying,
+      positionMs,
+      durationMs
+    )
+    AppStorage.setOrCreate('isPlaying', isPlaying)
+  }
+
+  public restoreSnapshot(snapshot: PlaybackSnapshot): void {
+    const safeQueue = snapshot.queue.slice()
+    this.snapshot = this.buildSnapshot(
+      safeQueue,
+      snapshot.currentIndex,
+      snapshot.currentSong,
+      snapshot.isPlaying,
+      snapshot.positionMs,
+      snapshot.durationMs
+    )
+    AppStorage.setOrCreate('currentSong', snapshot.currentSong)
+    AppStorage.setOrCreate('currentQueue', safeQueue)
+    AppStorage.setOrCreate('currentQueueIndex', snapshot.currentIndex)
+    AppStorage.setOrCreate('isPlaying', snapshot.isPlaying)
+  }
+
+  public getSnapshot(): PlaybackSnapshot {
+    return this.buildSnapshot(
+      this.snapshot.queue,
+      this.snapshot.currentIndex,
+      this.snapshot.currentSong,
+      this.snapshot.isPlaying,
+      this.snapshot.positionMs,
+      this.snapshot.durationMs
+    )
+  }
+
+  private buildSnapshot(queue: VideoItem[], currentIndex: number, currentSong: VideoItem | undefined,
+    isPlaying: boolean, positionMs: number, durationMs: number): PlaybackSnapshot {
+    return {
+      queue: queue.slice(),
+      currentIndex,
+      currentSong,
+      isPlaying,
+      positionMs,
+      durationMs
+    }
+  }
+}

+ 21 - 3
entry/src/main/ets/pages/ChartsCount.ets

@@ -13,8 +13,9 @@ import MediaTable from '../common/util/MediaTable';
 import { McPieChart, Options } from '@mcui/mccharts'
 import { ComponentContent } from '@kit.ArkUI';
 import { TitleBarPointLightButton } from '../view/PointLight/TitleBarPointLightButton';
+import { MusicPlaybackController } from '../controller/MusicPlaybackController';
+
 
-// 批量编辑标签
 @Component
 export struct ChartsCount {
   @StorageProp('topSafeHeight') topSafeHeight: number = 0;
@@ -47,6 +48,7 @@ export struct ChartsCount {
   @State maxRefreshingHeight: number = 100.0;
 
   private table: MediaTable = new MediaTable(this.context)
+  private playbackController: MusicPlaybackController = MusicPlaybackController.getInstance()
   @State defOption: Options = new Options({
     title: {
       show: true,
@@ -377,6 +379,24 @@ export struct ChartsCount {
     .backgroundColor(Color.Transparent)
     .width('100%')
     .height(89)
+    .onClick(() => {
+      this.playChartSong(item, index)
+    })
+  }
+
+  private playChartSong(item: VideoItem, index: number): void {
+    if (this.selectedFiles.length <= 0) {
+      return
+    }
+    const matchedIndex = this.selectedFiles.findIndex((song: VideoItem): boolean => song.filePath === item.filePath)
+    void this.playbackController.playQueue(
+      [...this.selectedFiles],
+      matchedIndex >= 0 ? matchedIndex : index,
+      'controller-open'
+    ).catch((error: Error) => {
+      LogUtil.error(`ChartsCount 播放排行榜歌曲失败: ${error}`)
+      ToastUtil.showToast('播放失败')
+    })
   }
 }
 
@@ -394,5 +414,3 @@ function customRefreshingContent() {
   .constraintSize({ minHeight: 32 })
   .width("100%")
 }
-
-

+ 19 - 63
entry/src/main/ets/pages/PlaylistDetailPage.ets

@@ -14,7 +14,7 @@ import { LazyDataSource } from '../common/util/LazyDataSource';
 import { ConfigurationConstant } from '@kit.AbilityKit';
 import { ButtonFancyModifier, ShadowModifier, SymbolGlyphFancyModifier } from '../common/util/AttributeModifierUtil';
 import { PlayingIndicator } from '../view/PlayingIndicator';
-import { PlaylistPlayRequest, savePendingPlaylistPlay } from '../common/util/PlaylistPlayRequestStore';
+import { MusicPlaybackController } from '../controller/MusicPlaybackController';
 
 // 工具函数:将 #RRGGBB 字符串和透明度转为 'rgba(r,g,b,a)' 字符串,深色模式下可返回深色变体
 function themeColorWithAlpha(themeColor: string, alpha: number, isDarkMode: boolean = false): string {
@@ -52,6 +52,7 @@ export struct PlaylistDetailPage {
 
   private playlistTable: PlaylistTable = new PlaylistTable(getContext(this))
   private mediaTable: MediaTable = new MediaTable(getContext(this))
+  private playbackController: MusicPlaybackController = MusicPlaybackController.getInstance()
   private playlistId: string = ''
   @StorageProp('themeColor') themeColor: string = CommonConstants.DEFAULT_THEME_COLOR;
   @State isDarkMode: boolean = false
@@ -406,31 +407,13 @@ export struct PlaylistDetailPage {
       ToastUtil.showToast('歌单为空')
       return
     }
-
-    // 发送播放歌单事件
-    const eventPlaylistPlay: emitter.InnerEvent = { eventId: EventConstants.EVENT_PLAYLIST_PLAY }
-
-    LogUtil.info(`heanup 准备发送播放事件,playlist: ${this.playlist ? '存在' : '不存在'}, songs: ${this.songList.length}首`)
-
-    // 构建歌单播放事件数据
-    const playlistData = new PlaylistPlayRequest(
-      this.playlist?.id || '',
-      this.playlist?.name || '',
-      this.songList.length,
-      0,
-      this.songList.map(song => song.filePath)
-    );
-
-    LogUtil.info(`heanup 发送歌单播放事件数据: ${JSON.stringify(playlistData)}`)
-
-    const eventData: emitter.EventData = {
-      data: playlistData
-    };
-
-    savePendingPlaylistPlay(playlistData)
-    emitter.emit(eventPlaylistPlay, eventData)
-
-    ToastUtil.showToast('开始播放歌单')
+    LogUtil.info(`heanup 直连播放歌单,playlist: ${this.playlist ? '存在' : '不存在'}, songs: ${this.songList.length}首`)
+    void this.playbackController.playQueue([...this.songList], 0, 'controller-open').then(() => {
+      ToastUtil.showToast('开始播放歌单')
+    }).catch((error: Error) => {
+      LogUtil.error(`heanup 播放歌单失败: ${error}`)
+      ToastUtil.showToast('播放失败')
+    })
   }
 
   /**
@@ -441,30 +424,11 @@ export struct PlaylistDetailPage {
     LogUtil.info(`heanup 播放指定歌曲: ${song.name}, 索引: ${index}`)
     LogUtil.info(`heanup 歌曲列表长度: ${this.songList.length}`)
     LogUtil.info(`heanup 歌单ID: ${this.playlist?.id}, 歌单名称: ${this.playlist?.name}`)
-
-    // 检查歌曲文件路径
     LogUtil.info(`heanup 所有歌曲文件路径: ${JSON.stringify(this.songList.map(s => s.filePath))}`)
-
-    // 发送播放歌单事件
-    const eventPlaylistPlay: emitter.InnerEvent = { eventId: EventConstants.EVENT_PLAYLIST_PLAY }
-    const playlistData = new PlaylistPlayRequest(
-      this.playlist?.id || '',
-      this.playlist?.name || '',
-      this.songList.length,
-      index,
-      this.songList.map(s => s.filePath)
-    );
-
-    LogUtil.info(`heanup 准备发送事件,eventId: ${eventPlaylistPlay.eventId}`)
-    LogUtil.info(`heanup 发送歌单播放事件数据: ${JSON.stringify(playlistData)}`)
-
-    const eventData: emitter.EventData = {
-      data: playlistData
-    };
-
-    LogUtil.info(`heanup eventData对象: ${JSON.stringify(eventData)}`)
-    savePendingPlaylistPlay(playlistData)
-    emitter.emit(eventPlaylistPlay, eventData)
+    void this.playbackController.playQueue([...this.songList], index, 'controller-open').catch((error: Error) => {
+      LogUtil.error(`heanup 播放指定歌曲失败: ${error}`)
+      ToastUtil.showToast('播放失败')
+    })
   }
 
   /**
@@ -562,19 +526,12 @@ export struct PlaylistDetailPage {
           }
 
           // 更新播放列表(重新发送歌单播放事件,保持当前播放位置)
-          const playlistData = new PlaylistPlayRequest(
-            this.playlist.id,
-            this.playlist.name,
-            this.songList.length,
-            this.curIndex >= this.songList.length ? Math.max(0, this.songList.length - 1) : this.curIndex,
-            this.songList.map(s => s.filePath)
-          )
-
-          const eventPlaylistPlay: emitter.InnerEvent = { eventId: EventConstants.EVENT_PLAYLIST_PLAY }
-          const eventData: emitter.EventData = { data: playlistData }
-          savePendingPlaylistPlay(playlistData)
-          emitter.emit(eventPlaylistPlay, eventData)
-          LogUtil.info('heanup 已更新播放列表,移除了被删除的歌曲')
+          const nextIndex = this.curIndex >= this.songList.length ? Math.max(0, this.songList.length - 1) : this.curIndex
+          void this.playbackController.playQueue([...this.songList], nextIndex, 'playlist-detail-sync').then(() => {
+            LogUtil.info('heanup 已通过 controller 更新播放列表,移除了被删除的歌曲')
+          }).catch((error: Error) => {
+            LogUtil.error(`heanup 更新播放列表失败: ${error}`)
+          })
         }
 
         ToastUtil.showToast('已从歌单移除')
@@ -1754,4 +1711,3 @@ export  async function  updateAllSongsSortOrder(playlistTable:PlaylistTable,song
 
 
 
-

+ 18 - 43
entry/src/main/ets/pages/WebDavMainPage.ets

@@ -36,9 +36,9 @@ import { DownloadCenter } from '../view/DownloadCenter';
 import { WebDavUrlUtil } from '../common/util/WebDavUrlUtil';
 import { SearchHistoryUtil } from '../common/util/SearchHistoryUtil';
 import { PlayingIndicator } from '../view/PlayingIndicator';
-import { PlaylistPlayRequest, savePendingPlaylistPlay } from '../common/util/PlaylistPlayRequestStore';
 import { buildWebDavVisibleThumbPrefetchWindow, WebDavThumbPrefetchRange } from '../common/util/WebDavThumbPrefetchHelper';
 import { buildWebDavSongItemRenderKey } from '../common/util/WebDavListRenderHelper';
+import { MusicPlaybackController } from '../controller/MusicPlaybackController';
 
 interface WebDavMetadataUpdatePayload {
   filePath: string;
@@ -175,6 +175,7 @@ export struct WebDavMainPage {
   private downloadCenterRefreshTimer: number = -1;
   private displayReloadToken: number = 0;
   private pendingDirectoryRefreshToken: number = 0;
+  private playbackController: MusicPlaybackController = MusicPlaybackController.getInstance()
 
   private async runSerializedThumbnailFfmpeg(task: () => Promise<void>): Promise<void> {
     const nextTask: Promise<void> = globalRemoteThumbFfmpegQueue.then(async (): Promise<void> => {
@@ -1935,20 +1936,11 @@ export struct WebDavMainPage {
       return;
     }
 
-    globalWebdavVideoItems = playlist;
-    globalWebdavCurrentPlayIndex = startIndex;
-
-    const eventPlaylistPlay: emitter.InnerEvent = { eventId: EventConstants.EVENT_PLAYLIST_PLAY };
-    const playlistData = new PlaylistPlayRequest(
-      'webdav-playlist',
-      '下载中心',
-      playlist.length,
-      startIndex,
-      playlist.map((item: VideoItem): string => item.filePath),
-      true
-    );
-    savePendingPlaylistPlay(playlistData);
-    emitter.emit(eventPlaylistPlay, { data: playlistData });
+    setWebdavPlaylist(playlist, startIndex);
+    void this.playbackController.playQueue(playlist.slice(), startIndex, 'controller-open').catch((error: Error) => {
+      Logger.error(TAG, `下载中心播放失败: ${error.message}`);
+      this.getUIContext().getPromptAction().showToast({ message: '播放失败' });
+    });
 
 
   }
@@ -2505,38 +2497,21 @@ export struct WebDavMainPage {
         });
       }
 
-      // 直接使用当前的VideoItem数组
       const videoItems: VideoItem[] = queue.slice();
-      const songFilePaths: string[] = [];
-
-      for (let i = 0; i < videoItems.length; i++) {
-        const item = videoItems[i];
-        songFilePaths.push(item.filePath); // 使用filePath作为文件路径
-      }
-
-      // 直接通过事件传递videoItems数据,不使用GlobalContext
-      const eventPlaylistPlay: emitter.InnerEvent = { eventId: EventConstants.EVENT_PLAYLIST_PLAY };
-
-      const playlistData = new PlaylistPlayRequest(
-        'webdav-playlist',
-        `${getRemoteDriveDisplayLabel(this.selectedAccount?.webType)} - ${this.selectedAccount?.name || '未知账户'}`,
-        videoItems.length,
-        playIndex,
-        songFilePaths,
-        isJump
-      );
-
-      // 保存videoItems到全局内存
-      globalWebdavVideoItems = videoItems;
-      globalWebdavCurrentPlayIndex = playIndex;
+      setWebdavPlaylist(videoItems, playIndex);
 
       Logger.info(TAG, `heanup 保存WebDAV歌曲到全局内存,长度: ${videoItems.length}, 索引: ${playIndex}`);
 
-      // 发送播放请求事件,只传递索引信息
-      savePendingPlaylistPlay(playlistData);
-      emitter.emit(eventPlaylistPlay, { data: playlistData });
-
-      Logger.info(TAG, `heanup 发送WebDAV播放事件,只传递索引: ${playIndex}`);
+      void this.playbackController.playQueue(
+        videoItems,
+        playIndex,
+        isJump ? 'controller-open' : 'webdav-main'
+      ).then(() => {
+        Logger.info(TAG, `heanup WebDAV 直连播放成功,索引: ${playIndex}`);
+      }).catch((error: Error) => {
+        Logger.error(TAG, `播放歌曲失败: ${error.message}`);
+        this.getUIContext().getPromptAction().showToast({ message: '播放失败' });
+      });
 
 
     } catch (error) {

+ 0 - 1
entry/src/main/ets/view/FindAlbumDetail.ets

@@ -671,6 +671,5 @@ export struct FindAlbumDetail {
     .scrollBar(BarState.Off)
     .edgeEffect(EdgeEffect.Spring)
     .backgroundColor(this.useTransparentBackground ? Color.Transparent : $r('app.color.start_window_background'))
-    .padding({ bottom: this.bottomSafeHeight + 75 })
   }
 }

+ 6 - 14
entry/src/main/ets/view/FindView.ets

@@ -29,7 +29,6 @@ import {
   getFindVideoItems,
   setFindPlaylist
 } from '../common/util/FindPlaylistStore'
-import { PlaylistPlayRequest, savePendingPlaylistPlay } from '../common/util/PlaylistPlayRequestStore'
 import PlaylistTable from '../common/util/PlaylistTable'
 import { convertPlaylistSongsToVideoItems } from '../pages/PlaylistDetailPage'
 import { getWebdavVideoItems, setWebdavPlaylist } from '../pages/WebDavMainPage'
@@ -42,13 +41,13 @@ import {
   resolveQueueStartIndex,
   shouldWaitForIndexedRemotePlayback
 } from '../common/util/FindDiscoveryHelper'
-import { buildPlaylistDispatchFilePaths } from '../common/util/PlaylistPlayDispatchHelper'
 import { insertSongToNextPlayQueue, QueueInsertStatus } from '../common/util/PlayQueueHelper'
 import { resolveRemoteCoverForSong } from '../common/util/RemoteCoverResolver'
 import {
   buildLocalRandomPagedQueueSeed,
   FIND_LOCAL_RANDOM_PAGED_PLAYLIST_ID
 } from '../common/util/LocalRandomPagedQueueHelper'
+import { MusicPlaybackController } from '../controller/MusicPlaybackController'
 
 @Builder
 export function FindViewBuilder() {
@@ -228,6 +227,7 @@ export struct FindView {
   private remoteAccountMap: Map<string, WebDavAccount> = new Map<string, WebDavAccount>()
   private remoteCoverRepairTicket: number = 0
   private remoteCoverRepairTimer: number = -1
+  private playbackController: MusicPlaybackController = MusicPlaybackController.getInstance()
 
   aboutToAppear(): void {
     this.initSetting()
@@ -1170,18 +1170,10 @@ export struct FindView {
       // 发现页队列统一走内存仓库,避免事件负载里重复塞大数组。
       setFindPlaylist(playlistId, playlistName, songs, safeIndex, meta)
     }
-    const eventPlaylistPlay: emitter.InnerEvent = { eventId: EventConstants.EVENT_PLAYLIST_PLAY }
-    const playlistData = new PlaylistPlayRequest(
-      playlistId,
-      playlistName,
-      dispatchCount,
-      safeIndex,
-      buildPlaylistDispatchFilePaths(playlistId, songs),
-      false,
-      playType
-    )
-    savePendingPlaylistPlay(playlistData)
-    emitter.emit(eventPlaylistPlay, { data: playlistData })
+    void this.playbackController.playQueue(songs.slice(), safeIndex, 'find-view', playType).catch((error: Error) => {
+      Logger.error(TAG, `发现页播放失败: ${error.message}`)
+      ToastUtil.showToast('播放失败')
+    })
   }
 
   private getCurrentPlaybackQueue(): VideoItem[] {

+ 145 - 86
entry/src/main/ets/view/LocalMusic.ets

@@ -14,7 +14,13 @@ import {
   getNavidromeTotalCount,
   setNavidromeTotalCount
 } from '../common/util/NavidromePlaylistStore';
-import { FindPlaylistMeta, getFindVideoItems, getFindCurrentPlayIndex, getFindPlaylistMeta } from '../common/util/FindPlaylistStore';
+import {
+  FindPlaylistMeta,
+  getFindVideoItems,
+  getFindCurrentPlayIndex,
+  getFindPlaylistMeta,
+  resolveFindPagedLocalQueueMetaForPlayback
+} from '../common/util/FindPlaylistStore';
 import { triggerLoadMoreSongs, hasMoreData } from '../common/util/NavidromeRandomLoader';
 import {
   DEFAULT_LOCAL_RANDOM_QUEUE_PAGE_SIZE,
@@ -117,8 +123,11 @@ import { BreakpointType, BreakpointTypeEnum } from '../common/util/BreakpointSys
 import { resourceManager } from '@kit.LocalizationKit';
 import { deviceInfo } from '@kit.BasicServicesKit';
 import '../common/network/RemoteCacheRegistry';
+import { shouldWrapLocalMusicMenuInScroll } from '../common/util/LocalMusicMenuScrollHelper';
 import { ServerLogUtil } from '../common/util/ServerLogUtil';
-import { MusicPlaybackController, MusicPlaybackControllerActions } from '../controller/MusicPlaybackController';
+import { MusicPlaybackController } from '../controller/MusicPlaybackController';
+import { PlaybackCoordinator, PlaybackRuntime } from '../controller/PlaybackCoordinator'
+import { PlaybackStateBridge } from '../controller/PlaybackStateBridge'
 import { lyricService, SongData } from '../common/service/LyricService';
 import { shouldResolveRemoteLyricOnPlay, tryResolveRemotePlaybackLyric } from '../common/util/RemotePlaybackLyricUtil';
 import { resolvePlayerDismissMorphTarget } from '../common/util/PlayerDismissHelper';
@@ -145,7 +154,6 @@ import {
 } from '../common/util/RemotePlayerUtil';
 import {
   PlaylistPlayRequest,
-  consumePendingPlaylistPlay,
   clearPendingPlaylistPlay
 } from '../common/util/PlaylistPlayRequestStore';
 import { resolvePlaylistDisplayCount } from '../common/util/PlaylistPlayDispatchHelper'
@@ -484,21 +492,53 @@ export struct LocalMusic {
   private contentNode?: ComponentContent<Object> = undefined;
   private skipNextPlaylistPersist: boolean = false
   private playbackController: MusicPlaybackController = MusicPlaybackController.getInstance()
-  private playbackControllerActions: MusicPlaybackControllerActions = {
-    playOrPause: (): void => {
-      this.playOrPause()
+  private playbackCoordinator: PlaybackCoordinator = PlaybackCoordinator.getInstance()
+  private playbackStateBridge: PlaybackStateBridge = PlaybackStateBridge.getInstance()
+  private playbackRuntime: PlaybackRuntime = {
+    playQueue: async (queue: VideoItem[], startIndex: number, source: string, playType?: number) => {
+      const safeIndex = queue.length <= 0 ? -1 : Math.max(0, Math.min(startIndex, queue.length - 1))
+      if (safeIndex < 0) {
+        return
+      }
+      const findPagedLocalMeta = resolveFindPagedLocalQueueMetaForPlayback(queue, safeIndex)
+      if (playType !== undefined) {
+        this.applyRequestedPlayType(playType)
+      }
+      if (findPagedLocalMeta?.isPagedLocalQueue) {
+        this.playQueueScope = 'find_paged_local'
+        this.applyFindPagedLocalQueueState(findPagedLocalMeta, findPagedLocalMeta.totalCount)
+        this.currentPlaylistSongFilePaths = []
+      } else {
+        this.resetFindPagedLocalQueueState()
+      }
+      this.songList = [...queue]
+      this.currentSongList = [...queue]
+      this.sonDataSource.pushArrayData(this.songList)
+      this.curIndex = safeIndex
+      this.currentSong = queue[safeIndex]
+      AppStorage.setOrCreate('currentSong', this.currentSong)
+      if (source === 'controller-open') {
+        this.setShowPlayTrue()
+      }
+      await this.doPlay(queue[safeIndex], safeIndex, true)
+      this.playbackStateBridge.replaceQueue(this.songList, this.curIndex)
+      this.playbackStateBridge.updatePlaybackState(
+        this.CONTROL_PlayStatus === PlayStatus.PLAY,
+        this.mIjkMediaPlayer.getCurrentPosition(),
+        this.getActiveDuration()
+      )
     },
-    playNext: (): Promise<void> => {
-      return this.playNext()
+    playOrPause: async () => {
+      await this.playOrPause()
     },
-    playPrevious: (): Promise<void> => {
-      return this.playPrevious()
+    playNext: async () => {
+      await this.playNext()
     },
-    setLoopMode: (): void => {
-      this.setLoopMode()
+    playPrevious: async () => {
+      await this.playPrevious()
     },
-    seekTo: (value: string, source?: string): Promise<void> => {
-      return this.seekTo(value, source)
+    seekTo: async (value: string, source?: string) => {
+      await this.seekTo(value, source ?? 'coordinator')
     }
   }
 
@@ -1045,7 +1085,7 @@ export struct LocalMusic {
   }
   // 组件生命周期
   aboutToAppear() {
-    this.playbackController.setActions(this.playbackControllerActions)
+    this.playbackCoordinator.setRuntime(this.playbackRuntime)
 
     console.info('onecold aboutToAppear sdkApiVersion = '+deviceInfo.sdkApiVersion)
     if(ArrayUtil.isNotEmpty(this.videoLocalList)&&this.modeType==0){
@@ -1109,44 +1149,6 @@ export struct LocalMusic {
       }
     });
 
-    // 监听歌单播放请求事件
-    let eventPlaylistPlay: emitter.InnerEvent = { eventId: EventConstants.EVENT_PLAYLIST_PLAY }
-    emitter.on(eventPlaylistPlay, async (eventData: emitter.EventData) => {
-      const data = eventData.data as Record<string, Object>
-
-      if (!data) {
-        Logger.error('heanup eventPlaylistPlay: eventData.data is undefined or null')
-        return
-      }
-
-      // 检查歌单播放数据结构
-      if (data.playlistId && data.songFilePaths && data.startIndex !== undefined) {
-        const playlistData = new PlaylistPlayRequest(
-          data.playlistId as string,
-          data.playlistName as string,
-          data.songCount as number,
-          data.startIndex as number,
-          data.songFilePaths as string[],
-          data.isJump as boolean,
-          data.playType as number
-        )
-
-        await this.dispatchPlaylistPlayRequest(playlistData)
-        return
-      }
-
-      Logger.error('heanup eventPlaylistPlay: 接收到无效的数据结构')
-    });
-    Promise.resolve().then(async (): Promise<void> => {
-      const pendingPlaylistRequest = consumePendingPlaylistPlay();
-      if (!pendingPlaylistRequest) {
-        return;
-      }
-      Logger.info(TAG,
-        `检测到待处理播放请求,准备补偿执行: ${pendingPlaylistRequest.playlistName}, startIndex=${pendingPlaylistRequest.startIndex}`);
-      await this.dispatchPlaylistPlayRequest(pendingPlaylistRequest);
-    });
-
     let eventRefreshSort: emitter.InnerEvent = { eventId: EventConstants.EVENT_PLAYLIST_REFRESH_SORT }
     // 监听广播事件(通用设置配置更新)
     emitter.on(eventRefreshSort, (eventData: emitter.EventData) => {
@@ -2292,7 +2294,7 @@ export struct LocalMusic {
   // 组件消失生命周期
   aboutToDisappear() {
     console.info('LifeCycleComponent aboutToDisappear');
-    this.playbackController.clearActions(this.playbackControllerActions)
+    this.playbackCoordinator.clearRuntime(this.playbackRuntime)
     if (this.alphaBetHideTimer) {
       clearTimeout(this.alphaBetHideTimer);
       this.alphaBetHideTimer = 0;
@@ -2333,7 +2335,6 @@ export struct LocalMusic {
     emitter.off(EventConstants.EVENT_SETTING_UPDATE);
     emitter.off(EventConstants.EVENT_WEBDAV_METADATA_UPDATED);
     emitter.off(EventConstants.EVENT_EQUALIZER_CHANGED);
-    emitter.off(EventConstants.EVENT_PLAYLIST_PLAY);
     emitter.off(EventConstants.EVENT_PLAYLIST_REFRESH_SORT);
     emitter.off(EventConstants.EVENT_PLAY_QUEUE_REFRESH);
     emitter.off(EventConstants.EVENT_OPEN_LOCAL_SPECIAL_LIST);
@@ -5903,11 +5904,28 @@ export struct LocalMusic {
   private DirItem(item: VideoItem, index?: number) {
     Button({ type: ButtonType.Capsule, stateEffect: true }) {
       Row() {
-        SymbolGlyph($r('sys.symbol.folder'))
-          .fontSize(this.twoFingerType==3?55:this.twoFingerType==2?50:45)
-          .fontColor([this.themeColor])
-          .alignSelf(ItemAlign.Center)
-          .margin({ left: 20 ,top:8,bottom:8})
+        if(this.modeType !== 0 && StrUtil.isNotEmpty(item.pixelMapPath)){
+          Image(this.getListImageSource(item, index ?? -1, this.coverThumbVersion))
+            .height(this.twoFingerType == 3 ? 55 : this.twoFingerType == 2 ? 50 : 40)
+            .width(this.twoFingerType == 3 ? 55 : this.twoFingerType == 2 ? 50 : 40)
+            .fillColor(this.themeColor)// .fontSize(33)
+            .alt($r('app.media.music_group'))
+            .borderRadius(this.isCoverRectangle?9:'100%')
+            .clip(true)
+            .shadow({
+              radius: 2,
+              type: ShadowType.BLUR,
+              color: 'on_primary'
+            })
+            .sourceSize({width:38, height:38})
+            .margin({ left: 20, top: 8, bottom: 8 })
+        }else{
+          SymbolGlyph($r('sys.symbol.folder'))
+            .fontSize(this.twoFingerType==3?55:this.twoFingerType==2?50:45)
+            .fontColor([this.themeColor])
+            .alignSelf(ItemAlign.Center)
+            .margin({ left: 20 ,top:8,bottom:8})
+        }
         Column() {
           Text(item.name.startsWith('.') ? item.name.replace(/\./g, '') : item.name)
             .fontSize(this.twoFingerType == 1 ? 12 : this.twoFingerType == 2 ? 15 : 18)
@@ -6456,11 +6474,11 @@ export struct LocalMusic {
             .width('100%')
             .clickEffect({ level: ClickEffectLevel.LIGHT, scale: 0.9 })
 
-            .bindContextMenu(this.MenuBuilder(item, index, item.filePath), ResponseType.LongPress,
+            .bindContextMenu(this.ContextMenuBuilder(item, index, item.filePath), ResponseType.LongPress,
               {
                 preview: MenuPreviewMode.IMAGE
               })
-            .bindContextMenu(this.MenuBuilder(item, index, item.filePath), ResponseType.RightClick,
+            .bindContextMenu(this.ContextMenuBuilder(item, index, item.filePath), ResponseType.RightClick,
               {
                 preview: MenuPreviewMode.IMAGE
               })
@@ -6472,7 +6490,7 @@ export struct LocalMusic {
         .columnsGap(12) // 列间距
         .rowsGap(this.columns >= 4 ? 8 : (this.columns == 3 ? 10 : 14)) // 行间距
         .cachedCount(this.columns >= 4 ? 2 : (this.columns == 3 ? 3 : 4))
-        .padding({bottom:this.bottomBarHeight+10})
+        // .padding({bottom:this.bottomBarHeight+10})
         .scrollBar(BarState.Off)
         // .fadingEdge(true, { fadingEdgeLength: LengthMetrics.vp(38) })
         // 效果:只有向上或者向下滑动到顶部时才会有弹簧回弹效果
@@ -6866,11 +6884,11 @@ export struct LocalMusic {
             this.MusicItemGrid(item, index)
           }
         }
-        .bindContextMenu(this.MenuBuilder(item, index, item.filePath), ResponseType.LongPress,
+        .bindContextMenu(this.ContextMenuBuilder(item, index, item.filePath), ResponseType.LongPress,
           {
             preview: MenuPreviewMode.IMAGE
           })
-        .bindContextMenu(this.MenuBuilder(item, index, item.filePath), ResponseType.RightClick,
+        .bindContextMenu(this.ContextMenuBuilder(item, index, item.filePath), ResponseType.RightClick,
           {
             preview: MenuPreviewMode.IMAGE
           })
@@ -6887,7 +6905,7 @@ export struct LocalMusic {
     .width('94%')
     .height('100%')
     .editMode(true)
-    .padding({bottom:this.bottomBarHeight+this.bottomSafeHeight+10})
+    .padding({bottom:this.bottomSafeHeight+10})
     .reuseId('grid_item')
     .layoutWeight(1)
     .scrollBar(BarState.Off)
@@ -7201,20 +7219,16 @@ export struct LocalMusic {
   }
   @State isShowDetail:boolean = false
   @State longItemFilePathDetail:string =''
-  @Builder
-  MenuBuilder(item: VideoItem, index: number, filePath: string) {
-    if(
-      ((this.modeType===2||this.modeType==3)&&!this.isCanBack)
-        ||(item.name === LocalMusic.STR_LOCK_VIDEO || item.name === LocalMusic.STR_FAC_VIDEO ||
-        item.name === LocalMusic.STR_HISTORY_MUSIC)
-    ){
-      //如果是专辑和艺术家的首页,不能长按
-      //如果是我的收藏 最近播放等也不能长按
-    }else{
+  private canShowItemContextMenu(item: VideoItem): boolean {
+    return !(((this.modeType===2||this.modeType==3)&&!this.isCanBack)
+      ||(item.name === LocalMusic.STR_LOCK_VIDEO || item.name === LocalMusic.STR_FAC_VIDEO ||
+      item.name === LocalMusic.STR_HISTORY_MUSIC))
+  }
 
-      Scroll() {
-        Flex({ direction: FlexDirection.Column, justifyContent: FlexAlign.Center, alignItems: ItemAlign.Center }) {
-          Menu() {
+  @Builder
+  private LocalMusicMenuContent(item: VideoItem, index: number, filePath: string) {
+    Flex({ direction: FlexDirection.Column, justifyContent: FlexAlign.Center, alignItems: ItemAlign.Center }) {
+      Menu() {
             MenuItem({
               symbolStartIcon: new SymbolGlyphModifier(Utility.getIsFav(this.favList, item) ?
                 $r('sys.symbol.heart_fill') : $r('sys.symbol.heart')),
@@ -7444,16 +7458,42 @@ export struct LocalMusic {
                 this.longItemFilePath = ''
               })
 
-          }
-          .font({ size: 14, weight: FontWeight.Normal })
-        }
-        .width(180)
       }
-      .height('auto')
-      .enableScrollInteraction(true)
-      .scrollBar(this.isGridMusic?BarState.On:BarState.Off)
+      .font({ size: 14, weight: FontWeight.Normal })
     }
+    .width(180)
+  }
 
+  @Builder
+  MenuBuilder(item: VideoItem, index: number, filePath: string) {
+    if (this.canShowItemContextMenu(item)) {
+      if (shouldWrapLocalMusicMenuInScroll(false)) {
+        Scroll() {
+          this.LocalMusicMenuContent(item, index, filePath)
+        }
+        .height('auto')
+        .enableScrollInteraction(true)
+        .scrollBar(this.isGridMusic?BarState.On:BarState.Off)
+      } else {
+        this.LocalMusicMenuContent(item, index, filePath)
+      }
+    }
+  }
+
+  @Builder
+  ContextMenuBuilder(item: VideoItem, index: number, filePath: string) {
+    if (this.canShowItemContextMenu(item)) {
+      if (shouldWrapLocalMusicMenuInScroll(true)) {
+        Scroll() {
+          this.LocalMusicMenuContent(item, index, filePath)
+        }
+        .height('auto')
+        .enableScrollInteraction(true)
+        .scrollBar(this.isGridMusic?BarState.On:BarState.Off)
+      } else {
+        this.LocalMusicMenuContent(item, index, filePath)
+      }
+    }
   }
   private listMaxScrollOffsetY: number = 0
   private selectedIndex: number = -1
@@ -17645,6 +17685,15 @@ export struct LocalMusic {
 
     }
 
+    try {
+      this.playbackStateBridge.updatePlaybackState(
+        this.CONTROL_PlayStatus === PlayStatus.PLAY,
+        position,
+        duration
+      )
+    } catch (error) {
+      // setProgress 频率较高,这里仅做兜底避免影响主流程
+    }
   }
 
   private startProgressTask() {
@@ -17739,6 +17788,7 @@ export struct LocalMusic {
     this.curIndex = index
     this.currentSong = this.songList[this.curIndex]
     this.applyCurrentSongMetadata(this.currentSong)
+    this.playbackStateBridge.replaceQueue(this.songList, this.curIndex)
   }
 
   private ensureSongQueued(song: VideoItem): number {
@@ -18915,6 +18965,15 @@ export struct LocalMusic {
     } catch (error) {
       Logger.error('heanup 发送播放状态变化事件失败: ' + error)
     }
+    try {
+      this.playbackStateBridge.updatePlaybackState(
+        this.CONTROL_PlayStatus === PlayStatus.PLAY,
+        this.getActivePlaybackPositionMs(),
+        this.getActiveDuration()
+      )
+    } catch (error) {
+      Logger.warn(TAG, `回写 PlaybackStateBridge 失败: ${error}`)
+    }
   }
 
   private setPlaybackStateChangeListener(): void {

+ 210 - 56
entry/src/main/ets/view/RemoteMusicPage.ets

@@ -24,21 +24,21 @@ import { ServerLogUtil } from '../common/util/ServerLogUtil';
 import { RemoteDriveManager } from '../common/util/RemoteDriveManager';
 import { SettingPage } from '../pages/SettingPage';
 import { TitleBarPointLightButton } from './PointLight/TitleBarPointLightButton';
-import { jellyfinApi, JellyfinAlbum, JellyfinArtist, JellyfinSong } from '../common/network/JellyfinApi';
-import { embyApi, EmbyAlbum, EmbyArtist, EmbySong } from '../common/network/EmbyApi';
+import { jellyfinApi, JellyfinAlbum, JellyfinArtist, JellyfinPlaylist, JellyfinSong } from '../common/network/JellyfinApi';
+import { embyApi, EmbyAlbum, EmbyArtist, EmbyPlaylist, EmbySong } from '../common/network/EmbyApi';
 import { audioStationApi, AudioStationAlbum, AudioStationArtist, AudioStationPlaylist, AudioStationSong } from '../common/network/AudioStationApi';
 import { plexApi, PlexAlbum, PlexArtist, PlexPlaylist, PlexSong } from '../common/network/PlexApi';
 import { daoLiYuApi, DaoLiYuAlbum, DaoLiYuArtist, DaoLiYuTrack, DaoLiYuPlaylist } from '../common/network/DaoLiYuApi';
-import { getRemoteDriveDisplayLabel } from '../common/util/RemoteDriveLabel';
 import NavidromeListCache, { AllCacheData } from '../common/util/NavidromeListCache';
 import { LazyDataSource } from '../common/util/LazyDataSource';
 import { taskpool } from '@kit.ArkTS';
 import { SearchHistoryUtil } from '../common/util/SearchHistoryUtil';
 import { PlayingIndicator } from './PlayingIndicator';
 import { hdsEffect } from '@kit.UIDesignKit';
-import { PlaylistPlayRequest, savePendingPlaylistPlay } from '../common/util/PlaylistPlayRequestStore';
+import { filterAlbumsByKeyword, filterArtistsByKeyword, filterPlaylistsByKeyword } from '../common/util/RemoteMusicSearchHelper';
+import { MusicPlaybackController } from '../controller/MusicPlaybackController';
+import { shouldRenderRemoteMusicSongView } from '../common/util/RemoteMusicViewModeHelper';
 
-const NAVIDROME_PLAYLIST_ID = 'navidrome-playlist';
 const NAVIDROME_SEARCH_LIMIT = 500;
 
 const ITEM_HEIGHT_SMALL: number = 48; // 列表项小高度
@@ -341,6 +341,7 @@ export struct RemoteMusicPage {
   @State sortType: number = 0; // 排序类型 0:名称升序 1:名称降序 2:时间升序 3:时间降序 4:大小升序 5:大小降序
   @State isSearchLoading: boolean = false;
   private readonly REMOTE_SEARCH_LIMIT: number = 500;
+  private playbackController: MusicPlaybackController = MusicPlaybackController.getInstance()
 
   // SegmentButton选项
   @State tabOptions: SegmentButtonOptions = this.createTabOptions();
@@ -356,17 +357,17 @@ export struct RemoteMusicPage {
       this.clearFilter();
     }
 
-    // 从艺术家或专辑切换回全部时,清除筛选状态
-    if (this.selectedTab === 0 && this.filterType !== NavFilterType.None) {
-      // 不清除筛选,保持筛选状态
-    } else if (this.selectedTab !== 0) {
-      // 切换到艺术家或专辑标签页时,清除筛选和搜索状态
+    if (this.selectedTab !== 0) {
       this.clearFilter();
-      this.isSearchMode = false;
-      this.searchText = '';
-      this.filteredList = [];
-      this.isSearchLoading = false;
-      this.searchTicket++;
+    }
+
+    if (this.isSearchMode && this.searchText.length > 0) {
+      if (this.selectedTab === 0) {
+        void this.onSearchInput(this.searchText);
+      } else {
+        this.isSearchLoading = false;
+        this.refreshVisibleDataSources();
+      }
     }
   }
 
@@ -591,10 +592,14 @@ export struct RemoteMusicPage {
 
   // 更新所有 LazyForEach 数据源
   private updateAllDataSources(): void {
+    this.refreshVisibleDataSources();
+  }
+
+  private refreshVisibleDataSources(): void {
     this.songDataSource.pushArrayData(this.getVisibleSongs());
-    this.artistDataSource.pushArrayData(this.artists);
-    this.albumDataSource.pushArrayData(this.albums);
-    this.playlistDataSource.pushArrayData(this.playlists);
+    this.artistDataSource.pushArrayData(this.getVisibleArtists());
+    this.albumDataSource.pushArrayData(this.getVisibleAlbums());
+    this.playlistDataSource.pushArrayData(this.getVisiblePlaylists());
   }
 
   private async loadMediaLibrary(account: WebDavAccount): Promise<void> {
@@ -771,14 +776,21 @@ export struct RemoteMusicPage {
       this.songNextStart = 0;
       this.artistNextStart = 0;
       this.albumNextStart = 0;
-      this.playlistNextStart = null;
+      this.playlistNextStart = 0;
+      const playlistLoad = this.loadNextJellyfinPlaylistPage(account, ticket).catch((error: Error) => {
+        if (ticket === this.loadTicket) {
+          this.playlistNextStart = null;
+          void ServerLogUtil.warn('NavidromeLoad', `Jellyfin 歌单首屏加载失败,已跳过: ${error.message}`);
+        }
+      });
       await Promise.all([
         this.loadNextJellyfinSongPage(account, ticket),
         this.loadNextJellyfinArtistPage(account, ticket),
-        this.loadNextJellyfinAlbumPage(account, ticket)
+        this.loadNextJellyfinAlbumPage(account, ticket),
+        playlistLoad
       ]);
       if (ticket === this.loadTicket) {
-        void ServerLogUtil.info('NavidromeLoad', `Jellyfin 首屏完成: 歌曲 ${this.allVideos.length} / 艺术家 ${this.artists.length} / 专辑 ${this.albums.length}`);
+        void ServerLogUtil.info('NavidromeLoad', `Jellyfin 首屏完成: 歌曲 ${this.allVideos.length} / 艺术家 ${this.artists.length} / 专辑 ${this.albums.length} / 歌单 ${this.playlists.length}`);
       }
     } catch (error) {
       if (ticket === this.loadTicket) {
@@ -802,14 +814,21 @@ export struct RemoteMusicPage {
       this.songNextStart = 0;
       this.artistNextStart = 0;
       this.albumNextStart = 0;
-      this.playlistNextStart = null;
+      this.playlistNextStart = 0;
+      const playlistLoad = this.loadNextEmbyPlaylistPage(account, ticket).catch((error: Error) => {
+        if (ticket === this.loadTicket) {
+          this.playlistNextStart = null;
+          void ServerLogUtil.warn('NavidromeLoad', `Emby 歌单首屏加载失败,已跳过: ${error.message}`);
+        }
+      });
       await Promise.all([
         this.loadNextEmbySongPage(account, ticket),
         this.loadNextEmbyArtistPage(account, ticket),
-        this.loadNextEmbyAlbumPage(account, ticket)
+        this.loadNextEmbyAlbumPage(account, ticket),
+        playlistLoad
       ]);
       if (ticket === this.loadTicket) {
-        void ServerLogUtil.info('NavidromeLoad', `Emby 首屏完成: 歌曲 ${this.allVideos.length} / 艺术家 ${this.artists.length} / 专辑 ${this.albums.length}`);
+        void ServerLogUtil.info('NavidromeLoad', `Emby 首屏完成: 歌曲 ${this.allVideos.length} / 艺术家 ${this.artists.length} / 专辑 ${this.albums.length} / 歌单 ${this.playlists.length}`);
       }
     } catch (error) {
       if (ticket === this.loadTicket) {
@@ -1136,6 +1155,30 @@ export struct RemoteMusicPage {
     }
   }
 
+  private async loadNextJellyfinPlaylistPage(account: WebDavAccount, ticket?: number): Promise<void> {
+    if (this.playlistNextStart === null || this.isPlaylistPageLoading) {
+      return;
+    }
+    const currentTicket = ticket ?? this.loadTicket;
+    this.isPlaylistPageLoading = true;
+    try {
+      const response = await jellyfinApi.getPlaylistsPage(account, this.playlistNextStart, this.REMOTE_PAGE_SIZE);
+      if (currentTicket !== this.loadTicket) {
+        return;
+      }
+      const processed = await this.convertJellyfinPlaylistsToRest(response.items, account);
+      if (currentTicket !== this.loadTicket) {
+        return;
+      }
+      this.playlists = [...this.playlists, ...processed];
+      this.updateAllDataSources();
+      this.playlistNextStart = response.nextStart;
+      void ServerLogUtil.info('NavidromeLoad', `Jellyfin 歌单追加: 本次 ${processed.length} 个, 总数 ${this.playlists.length}`);
+    } finally {
+      this.isPlaylistPageLoading = false;
+    }
+  }
+
   private async loadNextEmbySongPage(account: WebDavAccount, ticket?: number): Promise<void> {
     if (this.songNextStart === null || this.isSongPageLoading) {
       return;
@@ -1229,6 +1272,30 @@ export struct RemoteMusicPage {
     }
   }
 
+  private async loadNextEmbyPlaylistPage(account: WebDavAccount, ticket?: number): Promise<void> {
+    if (this.playlistNextStart === null || this.isPlaylistPageLoading) {
+      return;
+    }
+    const currentTicket = ticket ?? this.loadTicket;
+    this.isPlaylistPageLoading = true;
+    try {
+      const response = await embyApi.getPlaylistsPage(account, this.playlistNextStart, this.REMOTE_PAGE_SIZE);
+      if (currentTicket !== this.loadTicket) {
+        return;
+      }
+      const processed = await this.convertEmbyPlaylistsToRest(response.items, account);
+      if (currentTicket !== this.loadTicket) {
+        return;
+      }
+      this.playlists = [...this.playlists, ...processed];
+      this.updateAllDataSources();
+      this.playlistNextStart = response.nextStart;
+      void ServerLogUtil.info('NavidromeLoad', `Emby 歌单追加: 本次 ${processed.length} 个, 总数 ${this.playlists.length}`);
+    } finally {
+      this.isPlaylistPageLoading = false;
+    }
+  }
+
   private async loadNextAudioStationSongPage(account: WebDavAccount, ticket?: number): Promise<void> {
     if (this.songNextStart === null || this.isSongPageLoading) {
       return;
@@ -1608,6 +1675,9 @@ export struct RemoteMusicPage {
         case 2:
           await this.loadNextJellyfinAlbumPage(account);
           break;
+        case 3:
+          await this.loadNextJellyfinPlaylistPage(account);
+          break;
         default:
           break;
       }
@@ -1624,6 +1694,9 @@ export struct RemoteMusicPage {
         case 2:
           await this.loadNextEmbyAlbumPage(account);
           break;
+        case 3:
+          await this.loadNextEmbyPlaylistPage(account);
+          break;
         default:
           break;
       }
@@ -1892,6 +1965,40 @@ export struct RemoteMusicPage {
     return results;
   }
 
+  private async convertJellyfinPlaylistsToRest(playlists: JellyfinPlaylist[], account: WebDavAccount): Promise<NavidromeRestPlaylist[]> {
+    const results: NavidromeRestPlaylist[] = [];
+    for (let i = 0; i < playlists.length; i++) {
+      const playlist = playlists[i];
+      const coverUrl = await this.buildCoverUrl(account, playlist.id, 300);
+      const restPlaylist: NavidromeRestPlaylist = {
+        id: playlist.id,
+        name: playlist.name,
+        duration: playlist.durationSeconds,
+        songCount: playlist.songCount,
+        coverUrl: coverUrl
+      };
+      results.push(restPlaylist);
+    }
+    return results;
+  }
+
+  private async convertEmbyPlaylistsToRest(playlists: EmbyPlaylist[], account: WebDavAccount): Promise<NavidromeRestPlaylist[]> {
+    const results: NavidromeRestPlaylist[] = [];
+    for (let i = 0; i < playlists.length; i++) {
+      const playlist = playlists[i];
+      const coverUrl = await this.buildCoverUrl(account, playlist.id, 300);
+      const restPlaylist: NavidromeRestPlaylist = {
+        id: playlist.id,
+        name: playlist.name,
+        duration: playlist.durationSeconds,
+        songCount: playlist.songCount,
+        coverUrl: coverUrl
+      };
+      results.push(restPlaylist);
+    }
+    return results;
+  }
+
   private convertAudioStationArtistsToRest(artists: AudioStationArtist[]): NavidromeRestArtist[] {
     return artists.map(artist => {
       const name = artist.name ?? '';
@@ -2187,6 +2294,20 @@ export struct RemoteMusicPage {
     return results;
   }
 
+  private async fetchAllEmbyPlaylistSongs(account: WebDavAccount, playlistId: string): Promise<EmbySong[]> {
+    const results: EmbySong[] = [];
+    let startIndex = 0;
+    while (true) {
+      const response = await embyApi.getPlaylistSongsPage(account, playlistId, startIndex, this.REMOTE_PAGE_SIZE);
+      results.push(...response.items);
+      if (response.nextStart === null) {
+        break;
+      }
+      startIndex = response.nextStart;
+    }
+    return results;
+  }
+
   private async fetchAllAudioStationArtistSongs(account: WebDavAccount, artistName: string): Promise<AudioStationSong[]> {
     if (!artistName || artistName.trim().length === 0) {
       return [];
@@ -2199,6 +2320,10 @@ export struct RemoteMusicPage {
     });
   }
 
+  private async fetchAllJellyfinPlaylistSongs(account: WebDavAccount, playlistId: string): Promise<JellyfinSong[]> {
+    return jellyfinApi.getPlaylistSongs(account, playlistId);
+  }
+
   private async fetchAllAudioStationPlaylistSongs(account: WebDavAccount, playlistId: string): Promise<AudioStationSong[]> {
     const results: AudioStationSong[] = [];
     let startIndex = 0;
@@ -2722,7 +2847,7 @@ export struct RemoteMusicPage {
               }
             }
           })
-          .visibility(this.selectedTab==0?Visibility.Visible:Visibility.None)
+          .visibility(this.isDetailView ? Visibility.None : Visibility.Visible)
 
           // 排序按钮
           TitleBarPointLightButton({
@@ -2923,7 +3048,7 @@ export struct RemoteMusicPage {
       this.loadSearchHistory();
       this.filteredList = [];
       this.isSearchLoading = false;
-      this.songDataSource.pushArrayData(this.getVisibleSongs());
+      this.refreshVisibleDataSources();
       void ServerLogUtil.info('NavidromeSearch', '搜索已清空,显示所有歌曲');
       return;
     }
@@ -2931,6 +3056,14 @@ export struct RemoteMusicPage {
     if (!this.isSearchMode) {
       this.isSearchMode = true;
     }
+
+    if (this.selectedTab !== 0) {
+      this.isSearchLoading = false;
+      this.refreshVisibleDataSources();
+      void ServerLogUtil.info('NavidromeSearch', `本地名称过滤完成: "${keyword}"`);
+      return;
+    }
+
     const account = this.resolveActiveAccount();
     if (!account) {
       this.filteredList = [];
@@ -2971,7 +3104,7 @@ export struct RemoteMusicPage {
       const searchTime = Date.now() - startTime;
       if (!restSongs || restSongs.length === 0) {
         this.filteredList = [];
-        this.songDataSource.pushArrayData(this.getVisibleSongs());
+        this.refreshVisibleDataSources();
         void ServerLogUtil.warn('NavidromeSearch', `搜索无结果: "${keyword}"`);
         void ServerLogUtil.info('NavidromeSearch', `- 搜索耗时: ${searchTime}ms`);
         return;
@@ -2991,7 +3124,7 @@ export struct RemoteMusicPage {
         return;
       }
       this.filteredList = [];
-      this.songDataSource.pushArrayData(this.getVisibleSongs());
+      this.refreshVisibleDataSources();
       const message = (error as Error).message ?? 'Navidrome 搜索失败';
       ToastUtil.showToast(message);
       void ServerLogUtil.error('NavidromeSearch', `搜索失败: ${message}`);
@@ -3006,11 +3139,11 @@ export struct RemoteMusicPage {
   private getCurrentCount(): number {
     switch (this.selectedTab) {
       case 1:
-        return this.artists.length;
+        return this.getVisibleArtists().length;
       case 2:
-        return this.albums.length;
+        return this.getVisibleAlbums().length;
       case 3:
-        return this.playlists.length;
+        return this.getVisiblePlaylists().length;
       default:
         return this.getVisibleSongs().length;
     }
@@ -3019,11 +3152,11 @@ export struct RemoteMusicPage {
   private getEmptyTitle(): string {
     switch (this.selectedTab) {
       case 1:
-        return '暂无艺术家';
+        return this.isSearchMode && this.searchText.length > 0 ? '没有匹配的艺术家' : '暂无艺术家';
       case 2:
-        return '暂无专辑';
+        return this.isSearchMode && this.searchText.length > 0 ? '没有匹配的专辑' : '暂无专辑';
       case 3:
-        return '暂无歌单';
+        return this.isSearchMode && this.searchText.length > 0 ? '没有匹配的歌单' : '暂无歌单';
       default:
         if (this.isSearchMode && this.searchText.length > 0) {
           return this.isSearchLoading ? '正在搜索远程歌曲' : '没有匹配的远程歌曲';
@@ -3038,11 +3171,11 @@ export struct RemoteMusicPage {
   private getEmptySubtitle(): string {
     switch (this.selectedTab) {
       case 1:
-        return '当前筛选没有找到艺术家';
+        return this.isSearchMode && this.searchText.length > 0 ? '换个艺术家关键词再试试吧' : '当前筛选没有找到艺术家';
       case 2:
-        return '当前筛选没有找到专辑';
+        return this.isSearchMode && this.searchText.length > 0 ? '换个专辑关键词再试试吧' : '当前筛选没有找到专辑';
       case 3:
-        return '当前筛选没有找到歌单';
+        return this.isSearchMode && this.searchText.length > 0 ? '换个歌单关键词再试试吧' : '当前筛选没有找到歌单';
       default:
         if (this.isSearchMode && this.searchText.length > 0) {
           return this.isSearchLoading ? '请稍候,正在通过 API 搜索' : '换个关键词再试试吧';
@@ -3466,6 +3599,27 @@ export struct RemoteMusicPage {
     return this.allVideos;
   }
 
+  private getVisibleArtists(): NavidromeRestArtist[] {
+    if (this.isSearchMode && this.searchText.length > 0 && !this.isDetailView) {
+      return filterArtistsByKeyword(this.artists, this.searchText);
+    }
+    return this.artists;
+  }
+
+  private getVisibleAlbums(): NavidromeRestAlbum[] {
+    if (this.isSearchMode && this.searchText.length > 0 && !this.isDetailView) {
+      return filterAlbumsByKeyword(this.albums, this.searchText);
+    }
+    return this.albums;
+  }
+
+  private getVisiblePlaylists(): NavidromeRestPlaylist[] {
+    if (this.isSearchMode && this.searchText.length > 0 && !this.isDetailView) {
+      return filterPlaylistsByKeyword(this.playlists, this.searchText);
+    }
+    return this.playlists;
+  }
+
   private onArtistSelected(artist: NavidromeRestArtist): void {
     if (!artist || !artist.id) {
       return;
@@ -3587,7 +3741,13 @@ export struct RemoteMusicPage {
           restSongs = this.convertDaoLiYuSongsToRestSongs(songs);
         }
       } else if (this.filterType === NavFilterType.Playlist) {
-        if (this.isAudioStationAccount(account)) {
+        if (this.isJellyfinAccount(account)) {
+          const songs = await this.fetchAllJellyfinPlaylistSongs(account, this.filterId);
+          restSongs = this.convertJellyfinSongsToRestSongs(songs);
+        } else if (this.isEmbyAccount(account)) {
+          const songs = await this.fetchAllEmbyPlaylistSongs(account, this.filterId);
+          restSongs = this.convertEmbySongsToRestSongs(songs);
+        } else if (this.isAudioStationAccount(account)) {
           const songs = await this.fetchAllAudioStationPlaylistSongs(account, this.filterId);
           restSongs = this.convertAudioStationSongsToRestSongs(songs);
         } else if (this.isPlexAccount(account)) {
@@ -3682,22 +3842,16 @@ export struct RemoteMusicPage {
       if (this.serverTotalSongCount > 0) {
         setNavidromeTotalCount(this.serverTotalSongCount);
       }
-
-      const playlistData = new PlaylistPlayRequest(
-        NAVIDROME_PLAYLIST_ID,
-        `${getRemoteDriveDisplayLabel(account.webType)} - ${account.name ?? '未知账户'}`,
-        this.serverTotalSongCount > 0 ? this.serverTotalSongCount : playlistSource.length,
+      void this.playbackController.playQueue(
+        playlistSource.slice(),
         startIndex,
-        playlistSource.map(item => item.filePath),
-        isJump
-      );
-
-      const eventPlaylistPlay: emitter.InnerEvent = { eventId: EventConstants.EVENT_PLAYLIST_PLAY };
-      savePendingPlaylistPlay(playlistData);
-      emitter.emit(eventPlaylistPlay, { data: playlistData });
-
-      void ServerLogUtil.info('StreamingPlay', `播放事件已发送`);
-      void ServerLogUtil.debug('StreamingPlay', `播放事件详情: 列表ID=${playlistData.playlistId}, 列表名称=${playlistData.playlistName}, 歌曲数=${playlistData.songCount}, 起始索引=${playlistData.startIndex}, 是否跳转=${playlistData.isJump}`);
+        isJump ? 'controller-open' : 'remote-music'
+      ).then(() => {
+        void ServerLogUtil.info('StreamingPlay', '播放请求已通过 controller 发送');
+      }).catch((error: Error) => {
+        void ServerLogUtil.error('StreamingPlay', `播放失败: ${error.message}`);
+        ToastUtil.showToast('播放失败');
+      });
 
 
     } catch (error) {
@@ -3846,7 +4000,7 @@ export struct RemoteMusicPage {
       }
 
 
-      if (this.selectedTab === 0||this.isDetailView) {
+      if (shouldRenderRemoteMusicSongView(this.selectedTab, this.isDetailView)) {
         LazyForEach(this.songDataSource, (item: VideoItem, index: number) => {
           GridItem() {
             this.buildSongItemGrid(item, index)
@@ -4419,7 +4573,7 @@ export struct RemoteMusicPage {
   getListView() {
     List({scroller:this.scroller, space: 8 }) {
       // 详情视图模式下显示筛选后的歌曲,否则根据标签页显示对应内容
-      if (this.selectedTab === 0||this.isDetailView) {
+      if (shouldRenderRemoteMusicSongView(this.selectedTab, this.isDetailView)) {
         LazyForEach(this.songDataSource, (item: VideoItem, index: number) => {
           ListItem() {
             this.buildSongItem(item, index)
@@ -4582,7 +4736,7 @@ export struct RemoteMusicPage {
           layoutMode: WaterFlowLayoutMode.SLIDING_WINDOW
         }) {
 
-          if (this.selectedTab === 0) {
+          if (shouldRenderRemoteMusicSongView(this.selectedTab, this.isDetailView)) {
             // 歌曲瀑布流
             LazyForEach(this.songDataSource, (item: VideoItem, index: number) => {
               FlowItem() {

+ 7 - 1
entry/src/ohosTest/ets/test/List.test.ets

@@ -14,7 +14,13 @@
  */
 
 import abilityTest from './Ability.test'
+import musicPlaybackControllerTest from './MusicPlaybackController.test'
+import playbackCoordinatorTest from './PlaybackCoordinator.test'
+import playbackStateBridgeTest from './PlaybackStateBridge.test'
 
 export default function testsuite() {
   abilityTest()
-}
+  musicPlaybackControllerTest()
+  playbackCoordinatorTest()
+  playbackStateBridgeTest()
+}

+ 90 - 3
entry/src/ohosTest/ets/test/MusicPlaybackController.test.ets

@@ -1,8 +1,73 @@
 import { describe, expect, it } from '@ohos/hypium'
 import { MusicPlaybackController } from '../../../main/ets/controller/MusicPlaybackController'
+import { PlaybackCoordinator } from '../../../main/ets/controller/PlaybackCoordinator'
+import { VideoItem } from '../../../main/ets/viewmodel/VideoItem'
 
 export default function musicPlaybackControllerTest() {
   describe('MusicPlaybackControllerTest', () => {
+    it('controllerPlayQueueUsesCoordinatorRuntimeWithoutActions', 0, async () => {
+      const controller = MusicPlaybackController.getInstance()
+      const coordinator = PlaybackCoordinator.getInstance()
+      const calls: string[] = []
+      const songs: VideoItem[] = [
+        new VideoItem('Song A', '1', '/music/a.flac', 0, 0, '2026-04-02'),
+        new VideoItem('Song B', '2', '/music/b.flac', 0, 0, '2026-04-02')
+      ]
+
+      controller.clearActions()
+      coordinator.clearRuntime()
+      coordinator.setRuntime({
+        playQueue: async (_queue, startIndex, source) => {
+          calls.push(`playQueue:${startIndex}:${source}`)
+        },
+        playOrPause: async () => {
+          calls.push('playOrPause')
+        },
+        playNext: async () => {
+          calls.push('playNext')
+        },
+        playPrevious: async () => {
+          calls.push('playPrevious')
+        },
+        seekTo: async (value, source) => {
+          calls.push(`seekTo:${value}:${source ?? ''}`)
+        }
+      })
+
+      await controller.playQueue(songs, 1, 'charts-count')
+      await controller.playOrPause()
+      await controller.seekTo('88', 'controller-test')
+
+      expect(calls.join(',')).assertEqual('playQueue:1:charts-count,playOrPause,seekTo:88:controller-test')
+      coordinator.clearRuntime()
+    })
+
+    it('controllerPlayQueuePropagatesOptionalPlayType', 0, async () => {
+      const controller = MusicPlaybackController.getInstance()
+      const coordinator = PlaybackCoordinator.getInstance()
+      const calls: string[] = []
+      const songs: VideoItem[] = [
+        new VideoItem('Song A', '1', '/music/a.flac', 0, 0, '2026-04-02')
+      ]
+
+      controller.clearActions()
+      coordinator.clearRuntime()
+      coordinator.setRuntime({
+        playQueue: async (_queue, startIndex, source, playType) => {
+          calls.push(`${startIndex}:${source}:${playType ?? -1}`)
+        },
+        playOrPause: async () => {},
+        playNext: async () => {},
+        playPrevious: async () => {},
+        seekTo: async () => {}
+      })
+
+      await controller.playQueue(songs, 0, 'find-view', 3)
+
+      expect(calls.join(',')).assertEqual('0:find-view:3')
+      coordinator.clearRuntime()
+    })
+
     it('dispatchRegisteredActions', 0, async () => {
       const controller = MusicPlaybackController.getInstance()
       const calls: string[] = []
@@ -26,7 +91,7 @@ export default function musicPlaybackControllerTest() {
         }
       })
 
-      controller.playOrPause()
+      await controller.playOrPause()
       await controller.playNext()
       await controller.playPrevious()
       await controller.setLoopMode()
@@ -62,7 +127,7 @@ export default function musicPlaybackControllerTest() {
       controller.setActions(actions)
       controller.clearActions(actions)
 
-      controller.playOrPause()
+      await controller.playOrPause()
       await controller.playNext()
       await controller.seekTo('1', 'cleared')
 
@@ -74,7 +139,7 @@ export default function musicPlaybackControllerTest() {
       const controller = MusicPlaybackController.getInstance()
 
       controller.clearActions()
-      controller.playOrPause()
+      await controller.playOrPause()
       await controller.playNext()
       await controller.playPrevious()
       await controller.setLoopMode()
@@ -244,5 +309,27 @@ export default function musicPlaybackControllerTest() {
       expect(MusicPlaybackController.resolveItemPlaybackToggleAction('/a.flac', '/b.flac', true, true))
         .assertEqual('toggle_current')
     })
+
+    it('buildInMemoryPlaylistPlayRequestUsesFindPayload', 0, () => {
+      const songs: VideoItem[] = [
+        new VideoItem('Song A', '1', '/music/a.flac', 0, 0, '2026-04-02'),
+        new VideoItem('Song B', '2', '/music/b.flac', 0, 0, '2026-04-02')
+      ]
+
+      const request = MusicPlaybackController.buildInMemoryPlaylistPlayRequest({
+        playlistId: 'charts-count-playlist',
+        playlistName: '听歌次数排行榜',
+        songs,
+        startIndex: 99,
+        isJump: true
+      })
+
+      expect(request.playlistId).assertEqual('find-charts-count-playlist')
+      expect(request.playlistName).assertEqual('听歌次数排行榜')
+      expect(request.songCount).assertEqual(2)
+      expect(request.startIndex).assertEqual(1)
+      expect(request.songFilePaths.length).assertEqual(0)
+      expect(request.isJump).assertTrue()
+    })
   })
 }

+ 247 - 0
entry/src/ohosTest/ets/test/PlaybackCoordinator.test.ets

@@ -0,0 +1,247 @@
+import { describe, expect, it } from '@ohos/hypium'
+import { PlaybackCoordinator, PlaybackRuntime } from '../../../main/ets/controller/PlaybackCoordinator'
+import { PlaybackStateBridge } from '../../../main/ets/controller/PlaybackStateBridge'
+import { VideoItem } from '../../../main/ets/viewmodel/VideoItem'
+
+export default function playbackCoordinatorTest() {
+  describe('PlaybackCoordinatorTest', () => {
+    it('playQueueCallsRuntimeAndUpdatesState', 0, async () => {
+      const bridge = PlaybackStateBridge.getInstance()
+      const coordinator = PlaybackCoordinator.getInstance()
+      const calls: string[] = []
+      const songs: VideoItem[] = [
+        new VideoItem('Song A', '1', '/music/a.flac', 0, 0, '2026-04-02'),
+        new VideoItem('Song B', '2', '/music/b.flac', 0, 0, '2026-04-02')
+      ]
+
+      const runtime: PlaybackRuntime = {
+        playQueue: async (_queue, startIndex, source) => {
+          calls.push(`playQueue:${startIndex}:${source}`)
+        },
+        playOrPause: async () => {
+          calls.push('playOrPause')
+        },
+        playNext: async () => {
+          calls.push('playNext')
+        },
+        playPrevious: async () => {
+          calls.push('playPrevious')
+        },
+        seekTo: async (value, source) => {
+          calls.push(`seekTo:${value}:${source ?? ''}`)
+        }
+      }
+
+      bridge.reset()
+      coordinator.clearRuntime()
+      coordinator.setRuntime(runtime)
+
+      await coordinator.playQueue(songs, 1, 'charts-count')
+
+      const snapshot = bridge.getSnapshot()
+      expect(snapshot.currentIndex).assertEqual(1)
+      expect(snapshot.currentSong?.filePath ?? '').assertEqual('/music/b.flac')
+      expect(calls.join(',')).assertEqual('playQueue:1:charts-count')
+      coordinator.clearRuntime()
+    })
+
+    it('playQueuePropagatesPlayTypeToRuntime', 0, async () => {
+      const coordinator = PlaybackCoordinator.getInstance()
+      const calls: string[] = []
+      const songs: VideoItem[] = [
+        new VideoItem('Song A', '1', '/music/a.flac', 0, 0, '2026-04-02')
+      ]
+
+      coordinator.clearRuntime()
+      coordinator.setRuntime({
+        playQueue: async (_queue, startIndex, source, playType) => {
+          calls.push(`${startIndex}:${source}:${playType ?? -1}`)
+        },
+        playOrPause: async () => {},
+        playNext: async () => {},
+        playPrevious: async () => {},
+        seekTo: async () => {}
+      })
+
+      await coordinator.playQueue(songs, 0, 'find-view', 3)
+
+      expect(calls.join(',')).assertEqual('0:find-view:3')
+      coordinator.clearRuntime()
+    })
+
+    it('playSongPropagatesStartIndexAndSourceAndUpdatesBridge', 0, async () => {
+      const coordinator = PlaybackCoordinator.getInstance()
+      const bridge = PlaybackStateBridge.getInstance()
+      const song = new VideoItem('Song C', '3', '/music/c.flac', 0, 0, '2026-04-02')
+      const runtimeCalls: string[] = []
+
+      bridge.reset()
+      coordinator.clearRuntime()
+      coordinator.setRuntime({
+        playQueue: async (_queue, startIndex, source) => {
+          runtimeCalls.push(`${startIndex}:${source}`)
+        },
+        playOrPause: async () => {},
+        playNext: async () => {},
+        playPrevious: async () => {},
+        seekTo: async () => {}
+      })
+
+      await coordinator.playSong(song, 'playlist-detail')
+
+      const snapshot = bridge.getSnapshot()
+      expect(runtimeCalls.join(',')).assertEqual('0:playlist-detail')
+      expect(snapshot.currentIndex).assertEqual(0)
+      expect(snapshot.currentSong?.filePath ?? '').assertEqual('/music/c.flac')
+      coordinator.clearRuntime()
+    })
+
+    it('clearRuntimeOnlyRemovesMatchingInstance', 0, async () => {
+      const coordinator = PlaybackCoordinator.getInstance()
+      let playOrPauseCount = 0
+
+      const runtimeA: PlaybackRuntime = {
+        playQueue: async () => {},
+        playOrPause: async () => {
+          playOrPauseCount++
+        },
+        playNext: async () => {},
+        playPrevious: async () => {},
+        seekTo: async () => {}
+      }
+
+      const runtimeB: PlaybackRuntime = {
+        playQueue: async () => {},
+        playOrPause: async () => {},
+        playNext: async () => {},
+        playPrevious: async () => {},
+        seekTo: async () => {}
+      }
+
+      coordinator.clearRuntime()
+      coordinator.setRuntime(runtimeA)
+      coordinator.clearRuntime(runtimeB)
+
+      await coordinator.playOrPause()
+      expect(playOrPauseCount).assertEqual(1)
+
+      coordinator.clearRuntime(runtimeA)
+      await coordinator.playOrPause()
+      expect(playOrPauseCount).assertEqual(1)
+    })
+
+    it('coordinatorTransportControlsUseRegisteredRuntimeOnly', 0, async () => {
+      const coordinator = PlaybackCoordinator.getInstance()
+      const calls: string[] = []
+
+      coordinator.clearRuntime()
+      coordinator.setRuntime({
+        playQueue: async () => {},
+        playOrPause: async () => {
+          calls.push('playOrPause')
+        },
+        playNext: async () => {
+          calls.push('playNext')
+        },
+        playPrevious: async () => {
+          calls.push('playPrevious')
+        },
+        seekTo: async (value, source) => {
+          calls.push(`seekTo:${value}:${source ?? ''}`)
+        }
+      })
+
+      await coordinator.playOrPause()
+      await coordinator.playNext()
+      await coordinator.playPrevious()
+      await coordinator.seekTo('1200', 'slider')
+
+      expect(calls.join(',')).assertEqual('playOrPause,playNext,playPrevious,seekTo:1200:slider')
+      coordinator.clearRuntime()
+    })
+
+    it('playQueueWithoutRuntimeKeepsBridgeState', 0, async () => {
+      const coordinator = PlaybackCoordinator.getInstance()
+      const bridge = PlaybackStateBridge.getInstance()
+      const initialSong = new VideoItem('Initial', 'init', '/music/initial.flac', 0, 0, '2026-04-02')
+
+      bridge.reset()
+      bridge.replaceQueue([initialSong], 0)
+      const before = bridge.getSnapshot()
+
+      coordinator.clearRuntime()
+      await coordinator.playQueue(
+        [new VideoItem('Song D', '4', '/music/d.flac', 0, 0, '2026-04-02')],
+        0,
+        'charts'
+      )
+
+      const after = bridge.getSnapshot()
+      expect(after.currentIndex).assertEqual(before.currentIndex)
+      expect(after.currentSong?.filePath ?? '').assertEqual(before.currentSong?.filePath ?? '')
+      expect(after.queue.length).assertEqual(before.queue.length)
+    })
+
+    it('playQueueRollsBackOnRuntimeError', 0, async () => {
+      const coordinator = PlaybackCoordinator.getInstance()
+      const bridge = PlaybackStateBridge.getInstance()
+      const previousSong = new VideoItem('Previous', 'prev', '/music/prev.flac', 0, 0, '2026-04-02')
+
+      bridge.reset()
+      bridge.replaceQueue([previousSong], 0)
+      const snapshotBefore = bridge.getSnapshot()
+
+      let threwError = false
+      coordinator.clearRuntime()
+      coordinator.setRuntime({
+        playQueue: async () => {
+          throw new Error('boom')
+        },
+        playOrPause: async () => {},
+        playNext: async () => {},
+        playPrevious: async () => {},
+        seekTo: async () => {}
+      })
+
+      try {
+        await coordinator.playQueue(
+          [
+            new VideoItem('Song E', '5', '/music/e.flac', 0, 0, '2026-04-02'),
+            new VideoItem('Song F', '6', '/music/f.flac', 0, 0, '2026-04-02')
+          ],
+          0,
+          'playlist'
+        )
+      } catch (error) {
+        threwError = true
+      }
+
+      expect(threwError).assertEqual(true)
+      const snapshotAfter = bridge.getSnapshot()
+      expect(snapshotAfter.queue.length).assertEqual(snapshotBefore.queue.length)
+      expect(snapshotAfter.currentIndex).assertEqual(snapshotBefore.currentIndex)
+      expect(snapshotAfter.currentSong?.filePath ?? '').assertEqual(snapshotBefore.currentSong?.filePath ?? '')
+      coordinator.clearRuntime()
+    })
+
+    it('playSongWrapsSingleSongQueue', 0, async () => {
+      const coordinator = PlaybackCoordinator.getInstance()
+      let queueLength = 0
+
+      coordinator.clearRuntime()
+      coordinator.setRuntime({
+        playQueue: async (queue) => {
+          queueLength = queue.length
+        },
+        playOrPause: async () => {},
+        playNext: async () => {},
+        playPrevious: async () => {},
+        seekTo: async () => {}
+      })
+
+      await coordinator.playSong(new VideoItem('Song A', '1', '/music/a.flac', 0, 0, '2026-04-02'), 'playlist-detail')
+      expect(queueLength).assertEqual(1)
+      coordinator.clearRuntime()
+    })
+  })
+}

+ 60 - 0
entry/src/ohosTest/ets/test/PlaybackStateBridge.test.ets

@@ -0,0 +1,60 @@
+import { AppStorage } from '@kit.ArkUI'
+import { describe, expect, it } from '@ohos/hypium'
+import { PlaybackStateBridge } from '../../../main/ets/controller/PlaybackStateBridge'
+import { VideoItem } from '../../../main/ets/viewmodel/VideoItem'
+
+export default function playbackStateBridgeTest() {
+  describe('PlaybackStateBridgeTest', () => {
+    it('replaceQueueSyncsCurrentSongAndIndex', 0, () => {
+      const bridge = PlaybackStateBridge.getInstance()
+      const songs: VideoItem[] = [
+        new VideoItem('Song A', '1', '/music/a.flac', 0, 0, '2026-04-02'),
+        new VideoItem('Song B', '2', '/music/b.flac', 0, 0, '2026-04-02')
+      ]
+
+      bridge.reset()
+      bridge.replaceQueue(songs, 99)
+
+      const snapshot = bridge.getSnapshot()
+      expect(snapshot.queue.length).assertEqual(2)
+      expect(snapshot.currentIndex).assertEqual(1)
+      expect(snapshot.currentSong?.filePath ?? '').assertEqual('/music/b.flac')
+      const storedQueue = AppStorage.get<VideoItem[]>('currentQueue') ?? []
+      expect(storedQueue.length).assertEqual(2)
+      expect(storedQueue[1]?.filePath ?? '').assertEqual('/music/b.flac')
+      expect(AppStorage.get<VideoItem>('currentSong')?.filePath ?? '').assertEqual('/music/b.flac')
+      expect(AppStorage.get<number>('currentQueueIndex')).assertEqual(1)
+    })
+
+    it('updatePlaybackStateDoesNotDropQueue', 0, () => {
+      const bridge = PlaybackStateBridge.getInstance()
+      const songs: VideoItem[] = [
+        new VideoItem('Song A', '1', '/music/a.flac', 0, 0, '2026-04-02')
+      ]
+
+      bridge.reset()
+      bridge.replaceQueue(songs, 0)
+      bridge.updatePlaybackState(true, 1350, 4200)
+
+      const snapshot = bridge.getSnapshot()
+      expect(snapshot.isPlaying).assertTrue()
+      expect(snapshot.positionMs).assertEqual(1350)
+      expect(snapshot.durationMs).assertEqual(4200)
+      expect(snapshot.currentSong?.filePath ?? '').assertEqual('/music/a.flac')
+      expect(AppStorage.get<boolean>('isPlaying')).assertTrue()
+    })
+
+    it('replaceQueueWithEmptyQueueResetsPlaybackState', 0, () => {
+      const bridge = PlaybackStateBridge.getInstance()
+      bridge.reset()
+
+      bridge.replaceQueue([], 0)
+      const snapshot = bridge.getSnapshot()
+
+      expect(snapshot.queue.length).assertEqual(0)
+      expect(snapshot.currentIndex).assertEqual(-1)
+      expect(snapshot.isPlaying).assertFalse()
+      expect(AppStorage.get<boolean>('isPlaying')).assertFalse()
+    })
+  })
+}