Selaa lähdekoodia

merge(master): sync latest master changes

onecold 4 kuukautta sitten
vanhempi
sitoutus
9527e8c000

+ 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 环境限制无法完成可靠编译,需要在实现阶段明确记录未完成项。

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

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

+ 0 - 273
entry/src/main/ets/controller/MusicPlaybackController.ets

@@ -1,273 +0,0 @@
-export interface MusicPlaybackControllerActions {
-  playOrPause: () => void | Promise<void>
-  playNext: () => void | Promise<void>
-  playPrevious: () => void | Promise<void>
-  setLoopMode: () => void | Promise<void>
-  seekTo: (value: string, source?: string) => void | Promise<void>
-}
-
-export interface LoopModeResolution {
-  playType: number
-  toastText: string
-}
-
-export interface SessionLoopModeResolution {
-  playType: number
-  toastText: string
-  reportedLoopMode: number
-}
-
-export interface SeekResolutionOptions {
-  requestedValue: string
-  activeDuration: number
-  cueTrackStartOffset?: number
-  cueTrackEndOffset?: number
-  isRemoteSong?: boolean
-}
-
-export interface SeekResolution {
-  canSeek: boolean
-  seekPos: number
-}
-
-export interface QueueIndexResolution {
-  nextIndex: number
-  reachedBoundary: boolean
-}
-
-export interface QueueSongIndexResolution {
-  index: number
-  shouldAppend: boolean
-}
-
-export interface CompletionActionResolution {
-  action: 'play_next' | 'replay_current' | 'stop_current' | 'random_next'
-  showBoundaryToast: boolean
-}
-
-export type NextPlaybackAction = 'random_play' | 'stop_at_end' | 'advance_queue'
-export type PreviousPlaybackAction = 'history_previous' | 'queue_previous'
-export type TogglePlaybackAction = 'pause' | 'resume_cast' | 'resume_local'
-export type ItemPlaybackToggleAction = 'toggle_current' | 'play_target'
-
-export class MusicPlaybackController {
-  private static instance?: MusicPlaybackController
-  private actions?: MusicPlaybackControllerActions
-
-  public static getInstance(): MusicPlaybackController {
-    if (!MusicPlaybackController.instance) {
-      MusicPlaybackController.instance = new MusicPlaybackController()
-    }
-    return MusicPlaybackController.instance
-  }
-
-  public static resolveNextLoopMode(playType: number): LoopModeResolution {
-    const nextType = playType >= 4 || playType < 0 ? 0 : playType + 1
-    if (nextType === 1) {
-      return { playType: 1, toastText: '单曲循环' }
-    }
-    if (nextType === 2) {
-      return { playType: 2, toastText: '单曲播完' }
-    }
-    if (nextType === 3) {
-      return { playType: 3, toastText: '随机播放' }
-    }
-    if (nextType === 4) {
-      return { playType: 4, toastText: '连续播放不循环' }
-    }
-    return { playType: 0, toastText: '连续循环播放' }
-  }
-
-  public static resolveSessionLoopMode(mode: number): SessionLoopModeResolution {
-    let reportedLoopMode = mode + 1
-    if (reportedLoopMode >= 4) {
-      reportedLoopMode = 0
-    }
-
-    if (reportedLoopMode === 1) {
-      return { playType: 1, toastText: '单曲循环', reportedLoopMode }
-    }
-    if (reportedLoopMode === 0) {
-      return { playType: 2, toastText: '单曲播完', reportedLoopMode }
-    }
-    if (reportedLoopMode === 3) {
-      return { playType: 3, toastText: '随机播放', reportedLoopMode }
-    }
-    if (reportedLoopMode === 2) {
-      return { playType: 0, toastText: '连续循环播放', reportedLoopMode }
-    }
-    return { playType: 4, toastText: '连续播放不循环', reportedLoopMode }
-  }
-
-  public static resolveAvSessionLoopMode(playType: number): number {
-    if (playType === 1) {
-      return 1
-    }
-    if (playType === 2) {
-      return 0
-    }
-    if (playType === 3) {
-      return 3
-    }
-    if (playType === 4) {
-      return 4
-    }
-    return 2
-  }
-
-  public static resolveSeekPosition(options: SeekResolutionOptions): SeekResolution {
-    let seekPos = Number.parseInt(options.requestedValue)
-    if (Number.isNaN(seekPos)) {
-      return { canSeek: false, seekPos: 0 }
-    }
-
-    const cueTrackStartOffset = options.cueTrackStartOffset ?? 0
-    const cueTrackEndOffset = options.cueTrackEndOffset ?? 0
-    const activeDuration = options.activeDuration
-    const isRemoteSong = options.isRemoteSong === true
-
-    if (activeDuration > 0) {
-      seekPos = Math.max(0, Math.min(seekPos, activeDuration - 200))
-    } else if (seekPos < 0) {
-      seekPos = 0
-    }
-
-    if (cueTrackStartOffset > 0 || cueTrackEndOffset > 0) {
-      seekPos += cueTrackStartOffset
-      if (cueTrackEndOffset > cueTrackStartOffset) {
-        seekPos = Math.min(seekPos, cueTrackEndOffset - 200)
-      }
-    }
-
-    if (isRemoteSong && activeDuration <= 0 && seekPos > 0) {
-      return { canSeek: false, seekPos }
-    }
-
-    return { canSeek: true, seekPos }
-  }
-
-  public static resolveSeekValueFromPercent(percent: number, activeDuration: number): number {
-    if (activeDuration <= 0) {
-      return 0
-    }
-    const clampedPercent = Math.max(0, Math.min(percent, 100))
-    return clampedPercent * (activeDuration / 100)
-  }
-
-  public static resolveNextQueueIndex(currentIndex: number, queueLength: number): QueueIndexResolution {
-    if (queueLength <= 0) {
-      return { nextIndex: -1, reachedBoundary: true }
-    }
-    if (currentIndex >= queueLength - 1) {
-      return { nextIndex: 0, reachedBoundary: true }
-    }
-    return { nextIndex: currentIndex + 1, reachedBoundary: false }
-  }
-
-  public static resolvePreviousQueueIndex(currentIndex: number, queueLength: number): QueueIndexResolution {
-    if (queueLength <= 0) {
-      return { nextIndex: -1, reachedBoundary: true }
-    }
-    if (currentIndex <= 0) {
-      return { nextIndex: queueLength - 1, reachedBoundary: true }
-    }
-    return { nextIndex: currentIndex - 1, reachedBoundary: false }
-  }
-
-  public static resolveQueueSongIndex(queueFilePaths: string[], targetFilePath: string): QueueSongIndexResolution {
-    const existingIndex = queueFilePaths.findIndex((filePath: string): boolean => filePath === targetFilePath)
-    if (existingIndex >= 0) {
-      return { index: existingIndex, shouldAppend: false }
-    }
-    return { index: queueFilePaths.length, shouldAppend: true }
-  }
-
-  public static shouldStopAtQueueEnd(playType: number, currentIndex: number, queueLength: number): boolean {
-    if (queueLength <= 0) {
-      return false
-    }
-    return playType === 4 && currentIndex >= queueLength - 1
-  }
-
-  public static resolveCompletionAction(playType: number, currentIndex: number,
-    queueLength: number): CompletionActionResolution {
-    if (playType === 1) {
-      return { action: 'replay_current', showBoundaryToast: false }
-    }
-    if (playType === 2) {
-      return { action: 'stop_current', showBoundaryToast: false }
-    }
-    if (playType === 3) {
-      return { action: 'random_next', showBoundaryToast: false }
-    }
-    if (MusicPlaybackController.shouldStopAtQueueEnd(playType, currentIndex, queueLength)) {
-      return { action: 'stop_current', showBoundaryToast: true }
-    }
-    return { action: 'play_next', showBoundaryToast: false }
-  }
-
-  public static resolveNextPlaybackAction(playType: number, currentIndex: number, queueLength: number): NextPlaybackAction {
-    if (playType === 3) {
-      return 'random_play'
-    }
-    if (MusicPlaybackController.shouldStopAtQueueEnd(playType, currentIndex, queueLength)) {
-      return 'stop_at_end'
-    }
-    return 'advance_queue'
-  }
-
-  public static resolvePreviousPlaybackAction(playType: number): PreviousPlaybackAction {
-    if (playType === 3) {
-      return 'history_previous'
-    }
-    return 'queue_previous'
-  }
-
-  public static resolveTogglePlaybackAction(isPlaying: boolean, isCastPlaying: boolean): TogglePlaybackAction {
-    if (isPlaying) {
-      return 'pause'
-    }
-    if (isCastPlaying) {
-      return 'resume_cast'
-    }
-    return 'resume_local'
-  }
-
-  public static resolveItemPlaybackToggleAction(currentVideoUrl: string, targetFilePath: string,
-    isPlaying: boolean, allowAnyCurrentPlaying: boolean = false): ItemPlaybackToggleAction {
-    if (currentVideoUrl === targetFilePath || (allowAnyCurrentPlaying && isPlaying)) {
-      return 'toggle_current'
-    }
-    return 'play_target'
-  }
-
-  public setActions(actions: MusicPlaybackControllerActions): void {
-    this.actions = actions
-  }
-
-  public clearActions(actions?: MusicPlaybackControllerActions): void {
-    if (!actions || this.actions === actions) {
-      this.actions = undefined
-    }
-  }
-
-  public playOrPause(): void {
-    this.actions?.playOrPause()
-  }
-
-  public async playNext(): Promise<void> {
-    await this.actions?.playNext?.()
-  }
-
-  public async playPrevious(): Promise<void> {
-    await this.actions?.playPrevious?.()
-  }
-
-  public async setLoopMode(): Promise<void> {
-    await this.actions?.setLoopMode?.()
-  }
-
-  public async seekTo(value: string, source?: string): Promise<void> {
-    await this.actions?.seekTo?.(value, source)
-  }
-}

+ 59 - 59
entry/src/main/ets/pages/NewIndex.ets

@@ -589,8 +589,8 @@ struct NewIndex {
     try {
       const shouldShow = await UpdateLogManager.checkAndShowUpdateLog();
       if (shouldShow) {
-        this.isShowUpdateDialog = !this.isShowUpdateDialog
-        UpdateLogManager.markCurrentVersionShown()
+          this.isShowUpdateDialog = !this.isShowUpdateDialog
+          UpdateLogManager.markCurrentVersionShown()
       }
       // this.isShowUpdateDialog = true;//调试期间显示更新日志,测试完成后请删除
     } catch (error) {
@@ -2395,15 +2395,15 @@ struct NewIndex {
             .effectStrategy(SymbolEffectStrategy.HIERARCHICAL)
             .alignSelf(ItemAlign.Center)
             .margin({ left: 25 })
-
+          
           Text('创建歌单')
             .margin({ left: 10, right: 20 })
             .fontSize(14)
             .fontColor($r('app.color.index_tab_font_color'))
             .fontWeight(480)
-
+          
           Blank()
-
+          
           Image($r('app.media.arrow_right'))
             .width(22)
             .height(22)
@@ -2419,7 +2419,7 @@ struct NewIndex {
         this.showCreatePlaylistDialog()
       })
     }
-
+    
     // 歌单列表
     ForEach(this.playlistList, (playlist: Playlist,index:number) => {
       ListItem() {
@@ -2432,7 +2432,7 @@ struct NewIndex {
               .borderRadius(4)
               .fillColor(this.themeColor)
               .clip(true)
-
+            
             Column() {
               Text(playlist.name)
                 .margin({ left: 10, right: 20 })
@@ -2441,7 +2441,7 @@ struct NewIndex {
                 .fontWeight(480)
                 .maxLines(1)
                 .textOverflow({ overflow: TextOverflow.Ellipsis })
-
+              
               Text(`${playlist.songCount}首`)
                 .margin({ left: 10, right: 20 })
                 .fontSize(12)
@@ -2449,9 +2449,9 @@ struct NewIndex {
                 .opacity(0.7)
             }
             .alignItems(HorizontalAlign.Start)
-
+            
             Blank()
-
+            
             Image($r('app.media.arrow_right'))
               .width(22)
               .height(22)
@@ -2556,10 +2556,10 @@ struct NewIndex {
             // 账户封面或默认图标
             Stack() {
               Image(account.coverPath?account.coverPath:getCloudDiskIcon(account.webType))
-                .width(25)
-                .height(25)
-                .borderRadius(4)
-                .objectFit(ImageFit.Cover)
+                  .width(25)
+                  .height(25)
+                  .borderRadius(4)
+                  .objectFit(ImageFit.Cover)
             }
             .margin({ left: 20 })
 
@@ -2747,7 +2747,7 @@ struct NewIndex {
         content: '编辑'
       })
         .onClick(async() => {
-          this.showRemoteDriveAccountDialog(true,account)
+         this.showRemoteDriveAccountDialog(true,account)
         })
 
       MenuItem({
@@ -3109,36 +3109,36 @@ struct NewIndex {
 
   @Builder
   webDavAccountBuilder(isEditMode?: boolean,account?: WebDavAccount,driveType?:number) {
-    RemoteDriveAccountDialog({
-      isEditMode: isEditMode,
-      account: account,
-      initialDriveType: driveType,
-      onCancel: () => {
-        // 取消添加
-        this.getUIContext().getPromptAction().closeCustomDialog(this.addDavDialogId)
-      },
-      onConfirm: (account: WebDavAccount) => {
-        if(this.webDavAccounts.length>=1&&!Utility.isNoble()&&!isEditMode){
-          ToastUtil.showToast('普通用户只能添加一个网盘账户,请开通会员')
-          return
-        }
-        this.getUIContext().getPromptAction().closeCustomDialog(this.addDavDialogId)
-
-        if (isEditMode && account?.id) {
-          // 编辑模式:更新现有账户
-          this.webdavManager.editAccount(account).then(() => {
-            ToastUtil.showToast('修改成功')
-            // 重新加载网盘账户列表,观察着模式去更新了,所以这里不需要更新,先注释掉
-            // this.loadWebDavAccounts()
-            LogUtil.info('heanup NewIndex', '网盘账户修改成功:', account.name)
-            this.logAccount('info', `网盘账户修改成功: ${account.name}`)
-          }).catch((error: Error) => {
-            LogUtil.error('heanup NewIndex', `修改网盘账户失败: ${error.message}`)
-            ToastUtil.showToast('修改失败')
-            this.logAccount('error', `修改网盘账户失败: ${error.message}`)
-          })
-        } else {
-          // 添加模式:创建新账户
+     RemoteDriveAccountDialog({
+       isEditMode: isEditMode,
+       account: account,
+       initialDriveType: driveType,
+       onCancel: () => {
+         // 取消添加
+         this.getUIContext().getPromptAction().closeCustomDialog(this.addDavDialogId)
+       },
+       onConfirm: (account: WebDavAccount) => {
+         if(this.webDavAccounts.length>=1&&!Utility.isNoble()&&!isEditMode){
+           ToastUtil.showToast('普通用户只能添加一个网盘账户,请开通会员')
+           return
+         }
+         this.getUIContext().getPromptAction().closeCustomDialog(this.addDavDialogId)
+
+         if (isEditMode && account?.id) {
+           // 编辑模式:更新现有账户
+           this.webdavManager.editAccount(account).then(() => {
+             ToastUtil.showToast('修改成功')
+             // 重新加载网盘账户列表,观察着模式去更新了,所以这里不需要更新,先注释掉
+             // this.loadWebDavAccounts()
+             LogUtil.info('heanup NewIndex', '网盘账户修改成功:', account.name)
+             this.logAccount('info', `网盘账户修改成功: ${account.name}`)
+           }).catch((error: Error) => {
+             LogUtil.error('heanup NewIndex', `修改网盘账户失败: ${error.message}`)
+             ToastUtil.showToast('修改失败')
+             this.logAccount('error', `修改网盘账户失败: ${error.message}`)
+           })
+         } else {
+           // 添加模式:创建新账户
           this.webdavManager.insertAccount(
             account.name,
             account.host,
@@ -3164,20 +3164,20 @@ struct NewIndex {
             account.baiduRefreshToken,
             account.baiduTokenExpiresAt
           ).then(() => {
-            ToastUtil.showToast('添加成功')
-            // 重新加载网盘账户列表 观察着模式去更新了,所以这里不需要更新,先注释掉
-            // this.loadWebDavAccounts()
-            LogUtil.info('heanup NewIndex', '网盘账户添加成功:', account.name)
-            this.logAccount('info', `网盘账户添加成功: ${account.name}`)
-          }).catch((error: Error) => {
-            LogUtil.error('heanup NewIndex', `添加网盘账户失败: ${error.message}`)
-            ToastUtil.showToast('添加失败')
-            this.logAccount('error', `添加网盘账户失败: ${error.message}`)
-          })
-        }
-      }
-
-    });
+             ToastUtil.showToast('添加成功')
+             // 重新加载网盘账户列表 观察着模式去更新了,所以这里不需要更新,先注释掉
+             // this.loadWebDavAccounts()
+             LogUtil.info('heanup NewIndex', '网盘账户添加成功:', account.name)
+             this.logAccount('info', `网盘账户添加成功: ${account.name}`)
+           }).catch((error: Error) => {
+             LogUtil.error('heanup NewIndex', `添加网盘账户失败: ${error.message}`)
+             ToastUtil.showToast('添加失败')
+             this.logAccount('error', `添加网盘账户失败: ${error.message}`)
+           })
+         }
+       }
+
+     });
 
   }
 

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

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

@@ -2905,7 +2905,7 @@ export struct FindView {
         textColor: $r('app.color.text_color'),
         buttonColor: this.getCardBackgroundColor()
       })
-        .opacity(0.7)
+        .opacity(0.8)
         .layoutWeight(1)
         .onClick(() => {
           void this.handleActionButtonTap('top')
@@ -2918,7 +2918,7 @@ export struct FindView {
         textColor: $r('app.color.text_color'),
         buttonColor: this.getCardBackgroundColor()
       })
-        .opacity(0.7)
+        .opacity(0.8)
         .layoutWeight(1)
         .onClick(() => {
           void this.handleActionButtonTap('local-random')
@@ -2931,7 +2931,7 @@ export struct FindView {
         textColor: $r('app.color.text_color'),
         buttonColor: this.getCardBackgroundColor()
       })
-        .opacity(0.7)
+        .opacity(0.8)
         .layoutWeight(1)
         .onClick(() => {
           void this.handleActionButtonTap('cloud-random')

Tiedoston diff-näkymää rajattu, sillä se on liian suuri
+ 290 - 360
entry/src/main/ets/view/LocalMusic.ets


+ 195 - 35
entry/src/main/ets/view/RemoteMusicPage.ets

@@ -24,8 +24,8 @@ 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';
@@ -37,6 +37,7 @@ 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';
 
 const NAVIDROME_PLAYLIST_ID = 'navidrome-playlist';
 const NAVIDROME_SEARCH_LIMIT = 500;
@@ -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)) {

+ 0 - 367
entry/src/main/ets/view/player/PlayerControls.ets

@@ -1,367 +0,0 @@
-import { PlayStatus } from '../../common/PlayStatus'
-import { PointLightButton } from '../PointLight/PointLightButton'
-import { PointLightDefaultButton } from '../PointLight/PointLightDeFaultButton'
-
-@Component
-export struct PlayerControls {
-  // 播控支持紧凑模式,给横屏封面页复用同一套独立组件。
-  @Prop compactMode: boolean = false
-  // 整体透明度和底部偏移由外部页面控制,避免组件再持有布局状态。
-  @Prop controlOpacity: number = 1
-  @Prop bottomOffset: number = 70
-  // 是否展示底部功能操作区,HiCar 等场景沿用原有隐藏逻辑。
-  @Prop showBottomRow: boolean = true
-  // 播控区需要的播放态、进度态都作为纯数据输入。
-  @Prop isLandscape: boolean = false
-  @Prop progressValue: number = 0
-  @Prop progressMaxValue: number = 100
-  @Prop slideEnable: boolean = false
-  @Prop currentTime: string = '00:00'
-  @Prop totalTime: string = '00:00'
-  @Prop controlPlayStatus: number = PlayStatus.INIT
-  @Prop isCircleBtn: boolean = false
-  @Prop isPlayerLoading: boolean = false
-  @Prop playType: number = 0
-  @Prop fastForwardSeconds: string = '10'
-  @Prop isShowBackFast: boolean = false
-  // 编辑、收藏等按钮是否展示,由外部决定当前歌曲是否可操作。
-  @Prop showEditButton: boolean = false
-  @Prop isFavorite: boolean = false
-
-  // 频谱区域也改成可注入 builder,后续可以继续抽到独立组件而不动页面骨架。
-  @BuilderParam spectrumBuilder: () => void
-
-  // 播控内部只触发动作,不再持有具体业务逻辑。
-  onPlayPrevious: () => void = (): void => {}
-  onPlayOrPause: () => void = (): void => {}
-  onPlayNext: () => void = (): void => {}
-  onToggleMore: () => void = (): void => {}
-  onSetLoopMode: () => void = (): void => {}
-  onSeek: (value: number) => void = (_value: number): void => {}
-  onBackFast: () => void = (): void => {}
-  onForwardFast: () => void = (): void => {}
-  onRotate: () => void = (): void => {}
-  onToggleEdit: () => void = (): void => {}
-  onToggleEqualizer: () => void = (): void => {}
-  onToggleFavorite: () => void = (): void => {}
-  onOpenPlaylist: () => void = (): void => {}
-
-  private resolvePlayModeIcon(): Resource {
-    // 播放模式图标统一在独立组件里收口,减少 LocalMusic 内部判断分支。
-    if (this.playType === 0) {
-      return $r('app.media.loop')
-    }
-    if (this.playType === 1) {
-      return $r('app.media.single')
-    }
-    if (this.playType === 2) {
-      return $r('app.media.normal_play')
-    }
-    if (this.playType === 3) {
-      return $r('app.media.random')
-    }
-    return $r('app.media.noloop')
-  }
-
-  private resolveFastForwardText(): string {
-    // 统一把快进秒数当成字符串展示,避免上层状态类型变化时影响 UI 组件。
-    return this.fastForwardSeconds
-  }
-
-  @Builder
-  private MoreButtonContent() {
-    // 更多按钮沿用原有资源,保持现有视觉风格不变。
-    Image($r('app.media.menu'))
-      .width(24)
-  }
-
-  @Builder
-  private PreviousButtonContent() {
-    Image($r('app.media.ic_previous'))
-      .width(32)
-      .aspectRatio(1)
-  }
-
-  @Builder
-  private NextButtonContent() {
-    Image($r('app.media.ic_next'))
-      .width(32)
-      .aspectRatio(1)
-  }
-
-  @Builder
-  private PlayOrPauseButtonContent() {
-    Stack() {
-      // 播放暂停按钮单独保留加载圈,兼容远程歌曲缓冲场景。
-      Image(this.controlPlayStatus === PlayStatus.PLAY
-        ? (this.isCircleBtn ? $r('app.media.hm_pause') : $r('app.media.ic_public_play'))
-        : (this.isCircleBtn ? $r('app.media.hm_play2') : $r('app.media.ic_public_pause')))
-        .width(this.isLandscape ? 38 : 40)
-        .fillColor(Color.White)
-        .aspectRatio(1)
-
-      if (this.isPlayerLoading) {
-        Progress({ value: 0, total: 100, type: ProgressType.Ring })
-          .width(this.isLandscape ? 40 : 43)
-          .height(this.isLandscape ? 40 : 43)
-          .color(Color.White)
-          .style({ strokeWidth: 5, status: ProgressStatus.LOADING })
-      }
-    }
-    .width(this.isLandscape ? 38 : 41)
-    .height(this.isLandscape ? 38 : 41)
-  }
-
-  @Builder
-  private CenterControls() {
-    Row() {
-      PointLightButton({
-        builder: () => {
-          this.MoreButtonContent()
-        },
-        isPx: false,
-        builderHeight: 26,
-        builderWidth: 26,
-      })
-        .onClick(() => {
-          this.onToggleMore()
-        })
-
-      PointLightButton({
-        builder: () => {
-          this.PreviousButtonContent()
-        },
-        isPx: false,
-        builderHeight: 26,
-        builderWidth: 26,
-      })
-        .onClick(() => {
-          this.onPlayPrevious()
-        })
-
-      PointLightButton({
-        builder: () => {
-          this.PlayOrPauseButtonContent()
-        },
-        isPx: false,
-        builderHeight: this.isLandscape ? 38 : 41,
-        builderWidth: this.isLandscape ? 38 : 41,
-      })
-        .onClick(() => {
-          this.onPlayOrPause()
-        })
-
-      PointLightButton({
-        builder: () => {
-          this.NextButtonContent()
-        },
-        isPx: false,
-        builderHeight: 26,
-        builderWidth: 26,
-      })
-        .onClick(() => {
-          this.onPlayNext()
-        })
-
-      PointLightButton({
-        builder: () => {
-          Image(this.resolvePlayModeIcon())
-            .width(24)
-            .aspectRatio(1)
-        },
-        isPx: false,
-        builderHeight: 26,
-        builderWidth: 26,
-      })
-        .onClick(() => {
-          this.onSetLoopMode()
-        })
-    }
-    .width('95%')
-    .justifyContent(FlexAlign.SpaceEvenly)
-  }
-
-  @Builder
-  private ProgressControls() {
-    Column() {
-      Row() {
-        Stack() {
-          SymbolGlyph($r('sys.symbol.arrow_counterclockwise'))
-            .fontColor([Color.White])
-            .fontSize(21)
-            .effectStrategy(1)
-          Text(this.resolveFastForwardText())
-            .fontColor(Color.White)
-            .fontSize(10)
-            .fontWeight(FontWeight.Bold)
-        }
-        .padding({ left: 12, bottom: 6 })
-        .visibility(this.isShowBackFast ? Visibility.Visible : Visibility.None)
-        .clickEffect({ level: ClickEffectLevel.MIDDLE, scale: 0.6 })
-        .onClick(() => {
-          this.onBackFast()
-        })
-
-        Slider({
-          value: this.progressValue,
-          min: 0,
-          max: this.progressMaxValue,
-          step: 1,
-          style: SliderStyle.InSet
-        })
-          .height(20)
-          .blockColor('rgba(255,255,255,1)')
-          .trackColor('rgba(255,255,255,0.3)')
-          .selectedColor(Color.White)
-          .trackThickness(4)
-          .showSteps(false)
-          .showTips(true)
-          .layoutWeight(1)
-          .enabled(this.slideEnable)
-          .onChange((value: number, mode: SliderChangeMode) => {
-            if (mode !== 2) {
-              return
-            }
-            this.onSeek(value)
-          })
-
-        Stack() {
-          SymbolGlyph($r('sys.symbol.arrow_clockwise'))
-            .fontColor([Color.White])
-            .fontSize(21)
-            .effectStrategy(1)
-          Text(this.resolveFastForwardText())
-            .fontColor(Color.White)
-            .fontSize(10)
-            .fontWeight(FontWeight.Bold)
-        }
-        .padding({ right: 10, bottom: 6 })
-        .visibility(this.isShowBackFast ? Visibility.Visible : Visibility.None)
-        .clickEffect({ level: ClickEffectLevel.MIDDLE, scale: 0.6 })
-        .onClick(() => {
-          this.onForwardFast()
-        })
-      }
-      .justifyContent(FlexAlign.Center)
-      .padding({ left: 20, right: 20 })
-      .width(this.isLandscape ? '88%' : '95%')
-
-      Row() {
-        Text(this.currentTime)
-          .fontSize(10)
-          .fontColor(Color.White)
-          .margin({ left: 25 })
-        Blank()
-        Text(this.totalTime)
-          .fontSize(10)
-          .fontColor(Color.White)
-          .margin({ right: 25 })
-      }
-      .width(this.isLandscape ? '85%' : '90%')
-      .height(10)
-    }
-    .width('100%')
-    .alignItems(HorizontalAlign.Center)
-  }
-
-  @Builder
-  private BottomActionRow() {
-    Row() {
-      PointLightDefaultButton({
-        isSysBol: true,
-        pointColor: Color.White,
-        imageResource: $r('sys.symbol.rectangle_portrait_rotate'),
-        isPx: false,
-        builderHeight: 22,
-        builderWidth: 22,
-      })
-        .onClick(() => {
-          this.onRotate()
-        })
-
-      if (this.showEditButton) {
-        // 编辑标签只在当前有歌曲时展示,避免空态按钮触发无效动作。
-        PointLightDefaultButton({
-          isSysBol: true,
-          pointColor: Color.White,
-          imageResource: $r('sys.symbol.rename'),
-          isPx: false,
-          builderHeight: 22,
-          builderWidth: 22,
-        })
-          .onClick(() => {
-            this.onToggleEdit()
-          })
-      }
-
-      PointLightDefaultButton({
-        isSysBol: true,
-        pointColor: Color.White,
-        imageResource: $r('sys.symbol.slider_vertical_3'),
-        isPx: false,
-        builderHeight: 22,
-        builderWidth: 22,
-      })
-        .onClick(() => {
-          this.onToggleEqualizer()
-        })
-
-      PointLightDefaultButton({
-        isSysBol: true,
-        pointColor: Color.White,
-        imageResource: this.isFavorite ? $r('sys.symbol.heart_fill') : $r('sys.symbol.heart'),
-        isPx: false,
-        builderHeight: 22,
-        builderWidth: 22,
-      })
-        .onClick(() => {
-          this.onToggleFavorite()
-        })
-
-      PointLightDefaultButton({
-        isSysBol: true,
-        pointColor: Color.White,
-        imageResource: $r('sys.symbol.music_note_list'),
-        isPx: false,
-        builderHeight: 26,
-        builderWidth: 26,
-      })
-        .onClick(() => {
-          this.onOpenPlaylist()
-        })
-    }
-    .width(this.isLandscape ? '88%' : '95%')
-    .justifyContent(FlexAlign.SpaceAround)
-    .visibility(this.showBottomRow ? Visibility.Visible : Visibility.None)
-    .height(30)
-    .animation({
-      duration: 666,
-      curve: 'ease-in-out'
-    })
-  }
-
-  @Builder
-  private FullControls() {
-    Column() {
-      // 频谱显示是否启用由外部 builder 自己判断,组件本身不再持有该业务状态。
-      this.spectrumBuilder()
-      this.ProgressControls()
-      this.CenterControls()
-      this.BottomActionRow()
-    }
-    .justifyContent(FlexAlign.Center)
-    .alignItems(HorizontalAlign.Center)
-    .opacity(this.controlOpacity)
-    .position({ bottom: this.bottomOffset })
-  }
-
-  build() {
-    if (this.compactMode) {
-      // 横屏封面区域只复用紧凑播控,不再在 LocalMusic 里额外维护一套 UI。
-      this.CenterControls()
-    } else {
-      // 竖屏和完整播放页统一走独立播控组件。
-      this.FullControls()
-    }
-  }
-}

+ 0 - 67
entry/src/main/ets/view/player/PlayerPage.ets

@@ -1,67 +0,0 @@
-import { deviceInfo } from '@kit.BasicServicesKit'
-import { hdsEffect } from '@kit.UIDesignKit'
-
-@Component
-export struct PlayerPage {
-  // 播放页拖拽关闭时的位移状态,由 LocalMusic 统一维护,页面只负责消费。
-  @Prop translateY: number = 0
-  // 是否启用播放页背景流光效果,避免页面组件反向依赖 LocalMusic 的实现细节。
-  @Prop enableBackgroundEffect: boolean = false
-  // 是否显示背景流光效果,和启用开关拆开,便于后续继续裁剪状态依赖。
-  @Prop showBackgroundEffect: boolean = false
-  // 背景流光控制器仍然由 LocalMusic 持有,独立页面只做承载。
-  @Prop bgController: hdsEffect.ShaderEffectController | undefined = undefined
-
-  // 页面拖拽和销毁清理动作通过回调下沉给 LocalMusic,保证业务状态仍在一个地方维护。
-  onPanUpdate: (event?: GestureEvent) => void = (_event?: GestureEvent): void => {}
-  onPanEnd: (event?: GestureEvent) => void = (_event?: GestureEvent): void => {}
-  onDisappearCleanup: () => void = (): void => {}
-
-  // 实际播放器内容由外部 builder 传入,这样播放页文件独立后还能平滑迁移剩余内容。
-  @BuilderParam contentBuilder: () => void
-
-  build() {
-    Column() {
-      // 播放页只负责壳层结构,内部内容逐步从 LocalMusic 迁移。
-      this.contentBuilder()
-    }
-    .transition(TransitionEffect.asymmetric(
-      TransitionEffect.opacity(1),
-      TransitionEffect.OPACITY
-    ))
-    .visualEffect(this.enableBackgroundEffect && this.showBackgroundEffect && deviceInfo.sdkApiVersion >= 20 &&
-      this.bgController
-      ? new hdsEffect.HdsEffectBuilder()
-        .shaderEffect({
-          effectType: hdsEffect.EffectType.UV_BACKGROUND_FLOW_LIGHT,
-          animation: {
-            duration: 10000,
-            iterations: -1,
-            autoPlay: true,
-            onFinish: () => {
-              console.info('Succeeded in finishing')
-            }
-          },
-          controller: this.bgController,
-        })
-        .buildEffect()
-      : undefined)
-    .height('100%')
-    .width('100%')
-    .onDisAppear(() => {
-      // 页面消失时统一回调外部清理拖拽中间态,避免残留到下一次打开。
-      this.onDisappearCleanup()
-    })
-    .translate({ y: this.translateY })
-    .gesture(
-      // 播放页的关闭/切歌手势放在独立页面里承载,但具体业务判断仍交给 LocalMusic。
-      PanGesture()
-        .onActionUpdate((event?: GestureEvent) => {
-          this.onPanUpdate(event)
-        })
-        .onActionEnd((event?: GestureEvent) => {
-          this.onPanEnd(event)
-        })
-    )
-  }
-}

+ 0 - 115
entry/src/main/ets/view/player/SongDetailSheet.ets

@@ -1,115 +0,0 @@
-import { StrUtil } from '@pura/harmony-utils'
-import { CommonConstants } from '../../common/constants/CommonConstants'
-import { Utility } from '../../common/util/Utility'
-import { VideoItem } from '../../viewmodel/VideoItem'
-
-@Component
-export struct SongDetailSheet {
-  @Prop item: VideoItem | undefined = undefined
-  @Prop themeColor: string = CommonConstants.DEFAULT_THEME_COLOR
-  @Prop opacityItem: number = 1
-  @Prop totalTime: string = '00:00'
-  @Prop useFallbackDuration: boolean = false
-
-  private resolveText(value: string | undefined, fallback: string = ''): string {
-    return StrUtil.isNotEmpty(value) ? value as string : fallback
-  }
-
-  private resolveDurationText(): string {
-    // 播放页更多里的详情允许回退到实时总时长,本地列表详情则优先展示文件元数据。
-    if (StrUtil.isNotEmpty(this.item?.duration)) {
-      return this.item?.duration as string
-    }
-    return this.useFallbackDuration ? this.totalTime : '未知'
-  }
-
-  @Builder
-  private detailRow(label: string, value: string, fontSize: number = 14) {
-    // 详情项统一走同一个 builder,保证 LocalMusic 和播放页弹层展示结构一致。
-    Row() {
-      Text(label)
-        .fontSize(14)
-        .fontColor($r('app.color.text_color'))
-        .margin({ left: 22 })
-      Text(value)
-        .fontSize(fontSize)
-        .margin({ left: 10 })
-        .fontColor($r('app.color.text_color'))
-        .layoutWeight(1)
-    }
-    .width('100%')
-    .margin({ top: 10, bottom: 10 })
-    .justifyContent(FlexAlign.Start)
-  }
-
-  build() {
-    Scroll() {
-      Column() {
-        if (this.item) {
-          // 详情弹层先保留原有字段展示顺序,优先做无感拆分,不改用户认知。
-          Row() {
-            Stack() {
-              Image(StrUtil.isEmpty(this.item?.pixelMapPath) ? $r('app.media.alt') : this.item?.pixelMapPath)
-                .fillColor(this.themeColor)
-                .height(80)
-                .width(80)
-                .borderRadius(10)
-                .opacity(this.opacityItem)
-                .margin({ left: 22 })
-            }
-
-            Column() {
-              Column() {
-                Text(this.resolveText(this.item?.name))
-                  .fontSize(16)
-                  .maxLines(1)
-                  .animation({
-                    duration: 555,
-                    curve: 'Linear',
-                  })
-                  .fontColor($r('app.color.text_color'))
-                  .margin({ left: 15 })
-              }
-              .height('100%')
-              .width('100%')
-              .justifyContent(FlexAlign.Center)
-              .alignItems(HorizontalAlign.Start)
-            }
-            .height('100%')
-          }
-          .width('100%')
-          .height(90)
-          .justifyContent(FlexAlign.SpaceBetween)
-
-          this.detailRow('艺术家:', this.resolveText(this.item?.artist, '未知歌手'))
-          this.detailRow('专辑名:', this.resolveText(this.item?.album, '未知专辑'))
-          this.detailRow('时长:', this.resolveDurationText())
-          this.detailRow('采样率:', Utility.convertToKHz(this.item?.sampleRate))
-          this.detailRow('比特率:', this.resolveText(this.item?.bit_rate))
-          this.detailRow('位深:', `${this.resolveText(this.item?.bits_per_raw_sample)} bits`)
-          this.detailRow('声道:', `${this.resolveText(this.item?.channels)}`)
-          this.detailRow('声道布局:', `${this.resolveText(this.item?.channel_layout)}`)
-          this.detailRow('起始播放点:', `${this.resolveText(this.item?.start_time)}`)
-          this.detailRow('风格:', this.resolveText(this.item?.genre))
-          this.detailRow('发行时间:', this.resolveText(this.item?.year))
-          this.detailRow('质量评分:', `${this.item?.probe_score ?? ''}`)
-          this.detailRow('音轨号:', this.resolveText(this.item?.track))
-          this.detailRow('碟号:', this.resolveText(this.item?.disc))
-          this.detailRow('专辑艺术家:', this.resolveText(this.item?.ALBUMARTIST))
-          this.detailRow('作曲家:', this.resolveText(this.item?.COMPOSER))
-          this.detailRow('作词家:', this.resolveText(this.item?.LYRICIST))
-          this.detailRow('注释:', this.resolveText(this.item?.COMMENT))
-          this.detailRow('格式:', Utility.formatMimeType(this.item?.mimeType))
-          this.detailRow('播放次数:', `${this.item?.playCount ?? ''}`)
-          this.detailRow('流数量:', `${this.item?.nb_streams ?? ''}`)
-          this.detailRow('文件大小:', this.resolveText(this.item?.size))
-          this.detailRow('文件名:', this.resolveText(this.item?.fileName))
-          this.detailRow('修改时间:', this.resolveText(this.item?.cTime))
-          this.detailRow('存放目录:', this.resolveText(this.item?.filePath), 12)
-        }
-      }
-    }
-    .width('100%')
-    .height('100%')
-  }
-}

+ 0 - 82
entry/src/main/ets/view/player/SongEditFormState.ets

@@ -1,82 +0,0 @@
-import { VideoItem } from '../../viewmodel/VideoItem'
-
-// 编辑标签表单状态单独抽成纯数据对象,方便 LocalMusic 和独立播放页共用同一套字段映射。
-export class SongEditFormState {
-  title: string = ''
-  artist: string = ''
-  album: string = ''
-  lyricContent: string = ''
-  year: string = ''
-  genre: string = ''
-  track: string = ''
-  albumArtist: string = ''
-  composer: string = ''
-  lyricist: string = ''
-  comment: string = ''
-  disc: string = ''
-  imagePath: string = ''
-}
-
-export function createEmptySongEditFormState(): SongEditFormState {
-  // 新建空表单时统一走这里,避免字段默认值分散在多个页面里。
-  return new SongEditFormState()
-}
-
-export function createSongEditFormState(item?: VideoItem): SongEditFormState {
-  const form = createEmptySongEditFormState()
-  if (!item) {
-    return form
-  }
-
-  // 播放页和 LocalMusic 复用同一套字段映射,后续只需要维护这一处。
-  form.title = item.name ?? ''
-  form.artist = item.artist ?? ''
-  form.album = item.album ?? ''
-  form.lyricContent = item.lyricContent ?? ''
-  form.year = item.year ?? ''
-  form.genre = item.genre ?? ''
-  form.track = item.track ?? ''
-  form.albumArtist = item.ALBUMARTIST ?? ''
-  form.composer = item.COMPOSER ?? ''
-  form.lyricist = item.LYRICIST ?? ''
-  form.comment = item.COMMENT ?? ''
-  form.disc = item.disc ?? ''
-  return form
-}
-
-export function resetSongEditFormState(form: SongEditFormState): void {
-  // 关闭编辑面板时统一清空所有可编辑字段,防止下次打开残留旧值。
-  form.title = ''
-  form.artist = ''
-  form.album = ''
-  form.lyricContent = ''
-  form.year = ''
-  form.genre = ''
-  form.track = ''
-  form.albumArtist = ''
-  form.composer = ''
-  form.lyricist = ''
-  form.comment = ''
-  form.disc = ''
-  form.imagePath = ''
-}
-
-export function applySongEditFormStateToItem(target: VideoItem | undefined, form: SongEditFormState): void {
-  if (!target) {
-    return
-  }
-
-  // 保存成功后统一把表单值回写到歌曲对象,避免多个页面各自维护同步逻辑。
-  target.name = form.title
-  target.artist = form.artist
-  target.album = form.album
-  target.lyricContent = form.lyricContent
-  target.year = form.year
-  target.genre = form.genre
-  target.track = form.track
-  target.ALBUMARTIST = form.albumArtist
-  target.COMPOSER = form.composer
-  target.LYRICIST = form.lyricist
-  target.COMMENT = form.comment
-  target.disc = form.disc
-}

+ 0 - 387
entry/src/main/ets/view/player/SongTagEditorSheet.ets

@@ -1,387 +0,0 @@
-import { fileUri } from '@kit.CoreFileKit'
-import { StrUtil } from '@pura/harmony-utils'
-import { CommonConstants } from '../../common/constants/CommonConstants'
-import { VideoItem } from '../../viewmodel/VideoItem'
-
-@Component
-export struct SongTagEditorSheet {
-  @Prop item: VideoItem | undefined = undefined
-  @Prop themeColor: string = CommonConstants.DEFAULT_THEME_COLOR
-  @Prop opacityItem: number = 1
-  @Prop tempLyricContent: string = ''
-  @Link titleStr: string
-  @Link ablumStr: string
-  @Link artistStr: string
-  @Link lyricConStr: string
-  @Link yearStr: string
-  @Link trackStr: string
-  @Link genreStr: string
-  @Link imagePathStr: string
-  @Link albumArtistStr: string
-  @Link composerStr: string
-  @Link lyricistStr: string
-  @Link commentStr: string
-  @Link discStr: string
-  onSelectCover: (item: VideoItem) => Promise<void> = async (_item: VideoItem): Promise<void> => {}
-  onFetchCover: (item: VideoItem) => Promise<void> = async (_item: VideoItem): Promise<void> => {}
-  onFetchLyric: (item: VideoItem) => Promise<void> = async (_item: VideoItem): Promise<void> => {}
-  onRepairMessy: (item: VideoItem) => Promise<void> = async (_item: VideoItem): Promise<void> => {}
-  onSelectLocalLyric: () => void = (): void => {}
-  onClearLyric: () => void = (): void => {}
-  onConvertLyricToTraditional: () => void = (): void => {}
-  onConvertLyricToSimplified: () => void = (): void => {}
-  onSave: (item: VideoItem) => Promise<void> = async (_item: VideoItem): Promise<void> => {}
-  onCancel: () => void = (): void => {}
-
-  private resolveLyricText(): string {
-    // 编辑时优先展示正在编辑的歌词,没有修改时再回退到临时歌词文本。
-    return StrUtil.isNotEmpty(this.lyricConStr) ? this.lyricConStr : this.tempLyricContent
-  }
-
-  @Builder
-  private textInputRow(label: string, value: string, onChange: (value: string) => void) {
-    // 文本类标签字段统一走一个 builder,避免多处重复布局代码。
-    Row() {
-      Text(label)
-        .fontSize(14)
-        .fontColor($r('app.color.text_color'))
-        .margin({ left: 22 })
-      TextInput({ text: value })
-        .height(40)
-        .maxLines(1)
-        .fontSize(14)
-        .layoutWeight(1)
-        .fontColor($r('app.color.text_color'))
-        .margin({ right: 20, left: 10 })
-        .onChange((text: string) => {
-          onChange(text)
-        })
-    }
-    .width('100%')
-    .margin({ top: 10, bottom: 10 })
-    .justifyContent(FlexAlign.Start)
-  }
-
-  @Builder
-  private numberInputRow(label: string, value: string, onChange: (value: string) => void) {
-    // 数值类字段单独收口,避免在通用输入行里混入条件类型判断。
-    Row() {
-      Text(label)
-        .fontSize(14)
-        .fontColor($r('app.color.text_color'))
-        .margin({ left: 22 })
-      TextInput({ text: value })
-        .height(40)
-        .maxLines(1)
-        .fontSize(14)
-        .type(InputType.Number)
-        .layoutWeight(1)
-        .fontColor($r('app.color.text_color'))
-        .margin({ right: 20, left: 10 })
-        .onChange((text: string) => {
-          onChange(text)
-        })
-    }
-    .width('100%')
-    .margin({ top: 10, bottom: 10 })
-    .justifyContent(FlexAlign.Start)
-  }
-
-  build() {
-    Scroll() {
-      Column() {
-        if (this.item) {
-          // 组件只负责 UI 和用户操作分发,真正的副作用仍留在调用方处理。
-          Row() {
-            Stack() {
-              Image(StrUtil.isNotEmpty(this.imagePathStr) ? (this.imagePathStr.startsWith('http') ?
-                this.imagePathStr : fileUri.getUriFromPath(this.imagePathStr)) :
-                (StrUtil.isEmpty(this.item?.pixelMapPath) ? $r('app.media.add_image2') : this.item?.pixelMapPath))
-                .fillColor(this.themeColor)
-                .height(88)
-                .width(88)
-                .clickEffect({ level: ClickEffectLevel.LIGHT, scale: 0.6 })
-                .borderRadius(12)
-                .clip(true)
-                .opacity(this.opacityItem)
-                .margin({ left: 25 })
-            }
-            .clickEffect({ level: ClickEffectLevel.LIGHT, scale: 0.6 })
-            .onClick(async () => {
-              await this.onSelectCover(this.item as VideoItem)
-            })
-            .width('28%')
-
-            Row() {
-              Column() {
-                Text(this.item?.name)
-                  .fontSize(17)
-                  .maxLines(1)
-                  .textOverflow({ overflow: TextOverflow.MARQUEE })
-                  .animation({
-                    duration: 555,
-                    curve: 'Linear',
-                  })
-                  .fontColor(this.themeColor)
-                  .margin({ left: 8 })
-                Row() {
-                  Text(this.item?.artist)
-                    .fontSize(14)
-                    .maxLines(1)
-                    .textOverflow({ overflow: TextOverflow.MARQUEE })
-                    .padding({ top: 8 })
-                    .fontColor(this.themeColor)
-                    .margin({ left: 8 })
-                }
-              }
-              .height('100%')
-              .width(100)
-              .layoutWeight(1)
-              .justifyContent(FlexAlign.Center)
-              .alignItems(HorizontalAlign.Start)
-
-              Column() {
-                Button({ type: ButtonType.Capsule, stateEffect: true }) {
-                  Row() {
-                    SymbolGlyph($r('sys.symbol.picture'))
-                      .fontSize(18)
-                      .fontColor([Color.White])
-                    Text('获取封面')
-                      .margin({ left: 4 })
-                      .fontSize(11)
-                      .fontColor(Color.White)
-                      .fontWeight(480)
-                      .textAlign(TextAlign.Center)
-                  }
-                  .justifyContent(FlexAlign.Center)
-                }
-                .height(38)
-                .width(100)
-                .clickEffect({ level: ClickEffectLevel.MIDDLE, scale: 0.6 })
-                .backgroundColor(this.themeColor)
-                .stateEffect(true)
-                .margin({ right: 35 })
-                .onClick(async () => {
-                  await this.onFetchCover(this.item as VideoItem)
-                })
-
-                Button({ type: ButtonType.Capsule, stateEffect: true }) {
-                  Row() {
-                    SymbolGlyph($r('sys.symbol.input_mode'))
-                      .fontSize(18)
-                      .fontColor([Color.White])
-                    Text('获取歌词')
-                      .margin({ left: 4 })
-                      .fontSize(11)
-                      .fontColor(Color.White)
-                      .fontWeight(480)
-                      .textAlign(TextAlign.Center)
-                  }
-                  .justifyContent(FlexAlign.Center)
-                }
-                .height(38)
-                .width(100)
-                .clickEffect({ level: ClickEffectLevel.MIDDLE, scale: 0.6 })
-                .backgroundColor(this.themeColor)
-                .stateEffect(true)
-                .margin({ top: 9, right: 35 })
-                .onClick(async () => {
-                  await this.onFetchLyric(this.item as VideoItem)
-                })
-              }
-              .justifyContent(FlexAlign.Start)
-            }
-            .height('100%')
-            .justifyContent(FlexAlign.Start)
-            .layoutWeight(1)
-          }
-          .width('100%')
-          .height(98)
-
-          Row() {
-            Text('标题:')
-              .fontSize(14)
-              .fontColor($r('app.color.text_color'))
-              .margin({ left: 22 })
-
-            TextInput({ text: this.titleStr })
-              .height(40)
-              .maxLines(1)
-              .fontSize(14)
-              .layoutWeight(1)
-              .fontColor($r('app.color.text_color'))
-              .margin({ right: 15, left: 10 })
-              .onChange((val: string) => {
-                this.titleStr = val
-              })
-
-            Button('修复乱码', { type: ButtonType.Capsule, stateEffect: true })
-              .clickEffect({ level: ClickEffectLevel.MIDDLE, scale: 0.6 })
-              .backgroundColor(this.themeColor)
-              .fontSize(10)
-              .margin({ right: 20 })
-              .onClick(async () => {
-                await this.onRepairMessy(this.item as VideoItem)
-              })
-          }
-          .width('100%')
-          .margin({ top: 10, bottom: 10 })
-          .justifyContent(FlexAlign.Start)
-
-          this.textInputRow('艺术家:', this.artistStr, (value: string) => {
-            this.artistStr = value
-          })
-          this.textInputRow('专辑名:', this.ablumStr, (value: string) => {
-            this.ablumStr = value
-          })
-
-          Row() {
-            Column() {
-              Column() {
-                Text('歌词:')
-                  .fontSize(14)
-                  .fontColor($r('app.color.text_color'))
-                  .margin({ top: 9, left: 22 })
-
-                Button({ type: ButtonType.Capsule, stateEffect: true }) {
-                  SymbolGlyph($r('sys.symbol.trash_fill'))
-                    .fontSize(25)
-                    .alignSelf(ItemAlign.Center)
-                    .margin({ left: 22, top: 25 })
-                }
-                .backgroundColor(Color.Transparent)
-                .visibility(StrUtil.isEmpty(this.tempLyricContent) && StrUtil.isEmpty(this.lyricConStr) ?
-                  Visibility.None : Visibility.Visible)
-                .clickEffect({ level: ClickEffectLevel.MIDDLE, scale: 0.6 })
-                .onClick(() => {
-                  this.onClearLyric()
-                })
-
-                Button({ type: ButtonType.Capsule, stateEffect: true }) {
-                  SymbolGlyph($r('sys.symbol.plus'))
-                    .fontSize(25)
-                    .alignSelf(ItemAlign.Center)
-                    .margin({ left: 22, top: 25 })
-                }
-                .backgroundColor(Color.Transparent)
-                .clickEffect({ level: ClickEffectLevel.MIDDLE, scale: 0.6 })
-                .onClick(() => {
-                  this.onSelectLocalLyric()
-                })
-
-                Button({ type: ButtonType.Capsule, stateEffect: true }) {
-                  SymbolGlyph($r('sys.symbol.traditional_square'))
-                    .fontSize(25)
-                    .alignSelf(ItemAlign.Center)
-                    .margin({ left: 22, top: 25 })
-                }
-                .backgroundColor(Color.Transparent)
-                .visibility(StrUtil.isEmpty(this.tempLyricContent) && StrUtil.isEmpty(this.lyricConStr) ?
-                  Visibility.None : Visibility.Visible)
-                .clickEffect({ level: ClickEffectLevel.MIDDLE, scale: 0.6 })
-                .onClick(() => {
-                  this.onConvertLyricToTraditional()
-                })
-
-                Button({ type: ButtonType.Capsule, stateEffect: true }) {
-                  Image($r('app.media.jianti'))
-                    .height(25)
-                    .fillColor($r('app.color.text_color'))
-                    .alignSelf(ItemAlign.Center)
-                    .margin({ left: 22, top: 25 })
-                }
-                .backgroundColor(Color.Transparent)
-                .visibility(StrUtil.isEmpty(this.tempLyricContent) && StrUtil.isEmpty(this.lyricConStr) ?
-                  Visibility.None : Visibility.Visible)
-                .clickEffect({ level: ClickEffectLevel.MIDDLE, scale: 0.6 })
-                .onClick(() => {
-                  this.onConvertLyricToSimplified()
-                })
-              }
-              .layoutWeight(1)
-            }
-
-            TextArea({ text: this.resolveLyricText() })
-              .type(TextAreaType.NORMAL)
-              .height(StrUtil.isEmpty(this.lyricConStr) && StrUtil.isEmpty(this.tempLyricContent) ? 100 : 'auto')
-              .fontSize(14)
-              .layoutWeight(1)
-              .fontColor($r('app.color.text_color'))
-              .margin({ right: 20, left: 10 })
-              .onChange((val: string) => {
-                this.lyricConStr = val
-              })
-          }
-          .width('100%')
-          .height(StrUtil.isEmpty(this.lyricConStr) && StrUtil.isEmpty(this.tempLyricContent) ? 120 : 'auto')
-          .margin({ top: 10, bottom: 10 })
-          .justifyContent(FlexAlign.Start)
-
-          this.numberInputRow('年份:', this.yearStr, (value: string) => {
-            this.yearStr = value
-          })
-          this.numberInputRow('音轨号:', this.trackStr, (value: string) => {
-            this.trackStr = value
-          })
-          this.numberInputRow('碟号:', this.discStr, (value: string) => {
-            this.discStr = value
-          })
-          this.textInputRow('风格:', this.genreStr, (value: string) => {
-            this.genreStr = value
-          })
-          this.textInputRow('专辑艺术家:', this.albumArtistStr, (value: string) => {
-            this.albumArtistStr = value
-          })
-          this.textInputRow('作曲:', this.composerStr, (value: string) => {
-            this.composerStr = value
-          })
-          this.textInputRow('作词:', this.lyricistStr, (value: string) => {
-            this.lyricistStr = value
-          })
-          this.textInputRow('注释:', this.commentStr, (value: string) => {
-            this.commentStr = value
-          })
-
-          Row() {
-            Button('保存')
-              .fontColor(Color.White)
-              .layoutWeight(1)
-              .height(50)
-              .width(100)
-              .clickEffect({ level: ClickEffectLevel.MIDDLE, scale: 0.6 })
-              .backgroundColor(this.themeColor)
-              .stateEffect(true)
-              .margin({ right: 20, bottom: 20 })
-              .onClick(async () => {
-                await this.onSave(this.item as VideoItem)
-              })
-
-            Button('取消')
-              .fontColor(Color.White)
-              .layoutWeight(1)
-              .height(50)
-              .width(100)
-              .clickEffect({ level: ClickEffectLevel.MIDDLE, scale: 0.6 })
-              .backgroundColor(this.themeColor)
-              .stateEffect(true)
-              .margin({ left: 20, bottom: 20 })
-              .onClick(() => {
-                this.onCancel()
-              })
-          }
-          .margin({
-            left: 38,
-            right: 38,
-            top: 10,
-            bottom: 10
-          })
-          .width(250)
-          .height(60)
-        }
-      }
-      .margin({ bottom: 20 })
-    }
-    .width('100%')
-    .height('100%')
-  }
-}

+ 0 - 248
entry/src/ohosTest/ets/test/MusicPlaybackController.test.ets

@@ -1,248 +0,0 @@
-import { describe, expect, it } from '@ohos/hypium'
-import { MusicPlaybackController } from '../../../main/ets/controller/MusicPlaybackController'
-
-export default function musicPlaybackControllerTest() {
-  describe('MusicPlaybackControllerTest', () => {
-    it('dispatchRegisteredActions', 0, async () => {
-      const controller = MusicPlaybackController.getInstance()
-      const calls: string[] = []
-
-      controller.clearActions()
-      controller.setActions({
-        playOrPause: () => {
-          calls.push('playOrPause')
-        },
-        playNext: () => {
-          calls.push('playNext')
-        },
-        playPrevious: () => {
-          calls.push('playPrevious')
-        },
-        setLoopMode: () => {
-          calls.push('setLoopMode')
-        },
-        seekTo: (value: string, source?: string) => {
-          calls.push(`seekTo:${value}:${source ?? ''}`)
-        }
-      })
-
-      controller.playOrPause()
-      await controller.playNext()
-      await controller.playPrevious()
-      await controller.setLoopMode()
-      await controller.seekTo('88', 'test')
-
-      expect(calls.join(',')).assertEqual('playOrPause,playNext,playPrevious,setLoopMode,seekTo:88:test')
-      controller.clearActions()
-    })
-
-    it('clearActionsStopsFurtherDispatch', 0, async () => {
-      const controller = MusicPlaybackController.getInstance()
-      let count = 0
-
-      const actions = {
-        playOrPause: () => {
-          count++
-        },
-        playNext: () => {
-          count++
-        },
-        playPrevious: () => {
-          count++
-        },
-        setLoopMode: () => {
-          count++
-        },
-        seekTo: (_value: string, _source?: string) => {
-          count++
-        }
-      }
-
-      controller.clearActions()
-      controller.setActions(actions)
-      controller.clearActions(actions)
-
-      controller.playOrPause()
-      await controller.playNext()
-      await controller.seekTo('1', 'cleared')
-
-      expect(count).assertEqual(0)
-      controller.clearActions()
-    })
-
-    it('missingActionsAreSafeNoop', 0, async () => {
-      const controller = MusicPlaybackController.getInstance()
-
-      controller.clearActions()
-      controller.playOrPause()
-      await controller.playNext()
-      await controller.playPrevious()
-      await controller.setLoopMode()
-      await controller.seekTo('50', 'noop')
-
-      expect(true).assertTrue()
-    })
-
-    it('advanceLoopModeWithBoundedCycle', 0, () => {
-      expect(MusicPlaybackController.resolveNextLoopMode(0).playType).assertEqual(1)
-      expect(MusicPlaybackController.resolveNextLoopMode(1).playType).assertEqual(2)
-      expect(MusicPlaybackController.resolveNextLoopMode(2).playType).assertEqual(3)
-      expect(MusicPlaybackController.resolveNextLoopMode(3).playType).assertEqual(4)
-      expect(MusicPlaybackController.resolveNextLoopMode(4).playType).assertEqual(0)
-    })
-
-    it('resolveLoopModeToastText', 0, () => {
-      expect(MusicPlaybackController.resolveNextLoopMode(0).toastText).assertEqual('单曲循环')
-      expect(MusicPlaybackController.resolveNextLoopMode(1).toastText).assertEqual('单曲播完')
-      expect(MusicPlaybackController.resolveNextLoopMode(2).toastText).assertEqual('随机播放')
-      expect(MusicPlaybackController.resolveNextLoopMode(3).toastText).assertEqual('连续播放不循环')
-      expect(MusicPlaybackController.resolveNextLoopMode(4).toastText).assertEqual('连续循环播放')
-    })
-
-    it('resolveSessionLoopModeMapping', 0, () => {
-      expect(MusicPlaybackController.resolveSessionLoopMode(0).playType).assertEqual(1)
-      expect(MusicPlaybackController.resolveSessionLoopMode(1).playType).assertEqual(0)
-      expect(MusicPlaybackController.resolveSessionLoopMode(2).playType).assertEqual(3)
-      expect(MusicPlaybackController.resolveSessionLoopMode(3).playType).assertEqual(2)
-    })
-
-    it('resolveAvSessionLoopModeMapping', 0, () => {
-      expect(MusicPlaybackController.resolveAvSessionLoopMode(0)).assertEqual(2)
-      expect(MusicPlaybackController.resolveAvSessionLoopMode(1)).assertEqual(1)
-      expect(MusicPlaybackController.resolveAvSessionLoopMode(2)).assertEqual(0)
-      expect(MusicPlaybackController.resolveAvSessionLoopMode(3)).assertEqual(3)
-      expect(MusicPlaybackController.resolveAvSessionLoopMode(4)).assertEqual(4)
-    })
-
-    it('resolveSeekPositionClampsByDuration', 0, () => {
-      const result = MusicPlaybackController.resolveSeekPosition({
-        requestedValue: '5000',
-        activeDuration: 3000
-      })
-
-      expect(result.canSeek).assertTrue()
-      expect(result.seekPos).assertEqual(2800)
-    })
-
-    it('resolveSeekPositionAppliesCueOffsets', 0, () => {
-      const result = MusicPlaybackController.resolveSeekPosition({
-        requestedValue: '1000',
-        activeDuration: 5000,
-        cueTrackStartOffset: 2000,
-        cueTrackEndOffset: 6000
-      })
-
-      expect(result.canSeek).assertTrue()
-      expect(result.seekPos).assertEqual(3000)
-    })
-
-    it('resolveSeekPositionBlocksUnknownDurationRemoteSeek', 0, () => {
-      const result = MusicPlaybackController.resolveSeekPosition({
-        requestedValue: '1000',
-        activeDuration: 0,
-        isRemoteSong: true
-      })
-
-      expect(result.canSeek).assertFalse()
-      expect(result.seekPos).assertEqual(1000)
-    })
-
-    it('resolveSeekValueFromPercentClampsInput', 0, () => {
-      expect(MusicPlaybackController.resolveSeekValueFromPercent(25, 4000)).assertEqual(1000)
-      expect(MusicPlaybackController.resolveSeekValueFromPercent(-10, 4000)).assertEqual(0)
-      expect(MusicPlaybackController.resolveSeekValueFromPercent(120, 4000)).assertEqual(4000)
-      expect(MusicPlaybackController.resolveSeekValueFromPercent(50, 0)).assertEqual(0)
-    })
-
-    it('resolveNextQueueIndexWrapsAtEnd', 0, () => {
-      expect(MusicPlaybackController.resolveNextQueueIndex(1, 3).nextIndex).assertEqual(2)
-      expect(MusicPlaybackController.resolveNextQueueIndex(2, 3).nextIndex).assertEqual(0)
-      expect(MusicPlaybackController.resolveNextQueueIndex(2, 3).reachedBoundary).assertTrue()
-    })
-
-    it('resolvePreviousQueueIndexWrapsAtStart', 0, () => {
-      expect(MusicPlaybackController.resolvePreviousQueueIndex(2, 3).nextIndex).assertEqual(1)
-      expect(MusicPlaybackController.resolvePreviousQueueIndex(0, 3).nextIndex).assertEqual(2)
-      expect(MusicPlaybackController.resolvePreviousQueueIndex(0, 3).reachedBoundary).assertTrue()
-    })
-
-    it('resolveQueueSongIndexUsesExistingQueuePosition', 0, () => {
-      const resolution = MusicPlaybackController.resolveQueueSongIndex(
-        ['/music/a.flac', '/music/b.flac', '/music/c.flac'],
-        '/music/b.flac'
-      )
-
-      expect(resolution.index).assertEqual(1)
-      expect(resolution.shouldAppend).assertFalse()
-    })
-
-    it('resolveQueueSongIndexAppendsMissingSong', 0, () => {
-      const resolution = MusicPlaybackController.resolveQueueSongIndex(
-        ['/music/a.flac', '/music/b.flac'],
-        '/music/c.flac'
-      )
-
-      expect(resolution.index).assertEqual(2)
-      expect(resolution.shouldAppend).assertTrue()
-    })
-
-    it('stopAtQueueEndOnlyForNonLoopSequentialMode', 0, () => {
-      expect(MusicPlaybackController.shouldStopAtQueueEnd(4, 2, 3)).assertTrue()
-      expect(MusicPlaybackController.shouldStopAtQueueEnd(4, 1, 3)).assertFalse()
-      expect(MusicPlaybackController.shouldStopAtQueueEnd(0, 2, 3)).assertFalse()
-      expect(MusicPlaybackController.shouldStopAtQueueEnd(4, 0, 0)).assertFalse()
-    })
-
-    it('resolveCompletionActionByPlayMode', 0, () => {
-      expect(MusicPlaybackController.resolveCompletionAction(0, 1, 3).action).assertEqual('play_next')
-      expect(MusicPlaybackController.resolveCompletionAction(1, 1, 3).action).assertEqual('replay_current')
-      expect(MusicPlaybackController.resolveCompletionAction(2, 1, 3).action).assertEqual('stop_current')
-      expect(MusicPlaybackController.resolveCompletionAction(3, 1, 3).action).assertEqual('random_next')
-    })
-
-    it('resolveCompletionActionStopsAtBoundaryForModeFour', 0, () => {
-      const resolution = MusicPlaybackController.resolveCompletionAction(4, 2, 3)
-
-      expect(resolution.action).assertEqual('stop_current')
-      expect(resolution.showBoundaryToast).assertTrue()
-    })
-
-    it('resolveNextPlaybackActionForRandomMode', 0, () => {
-      const resolution = MusicPlaybackController.resolveNextPlaybackAction(3, 1, 5)
-
-      expect(resolution).assertEqual('random_play')
-    })
-
-    it('resolveNextPlaybackActionStopsAtQueueBoundary', 0, () => {
-      const resolution = MusicPlaybackController.resolveNextPlaybackAction(4, 2, 3)
-
-      expect(resolution).assertEqual('stop_at_end')
-    })
-
-    it('resolveNextPlaybackActionAdvancesQueueByDefault', 0, () => {
-      const resolution = MusicPlaybackController.resolveNextPlaybackAction(0, 1, 3)
-
-      expect(resolution).assertEqual('advance_queue')
-    })
-
-    it('resolvePreviousPlaybackActionUsesHistoryOnlyForRandomMode', 0, () => {
-      expect(MusicPlaybackController.resolvePreviousPlaybackAction(3)).assertEqual('history_previous')
-      expect(MusicPlaybackController.resolvePreviousPlaybackAction(0)).assertEqual('queue_previous')
-    })
-
-    it('resolveTogglePlaybackAction', 0, () => {
-      expect(MusicPlaybackController.resolveTogglePlaybackAction(true, false)).assertEqual('pause')
-      expect(MusicPlaybackController.resolveTogglePlaybackAction(false, true)).assertEqual('resume_cast')
-      expect(MusicPlaybackController.resolveTogglePlaybackAction(false, false)).assertEqual('resume_local')
-    })
-
-    it('resolveItemPlaybackToggleAction', 0, () => {
-      expect(MusicPlaybackController.resolveItemPlaybackToggleAction('/a.flac', '/a.flac', false))
-        .assertEqual('toggle_current')
-      expect(MusicPlaybackController.resolveItemPlaybackToggleAction('/a.flac', '/b.flac', false))
-        .assertEqual('play_target')
-      expect(MusicPlaybackController.resolveItemPlaybackToggleAction('/a.flac', '/b.flac', true, true))
-        .assertEqual('toggle_current')
-    })
-  })
-}

Kaikkia tiedostoja ei voida näyttää, sillä liian monta tiedostoa muuttui tässä diffissä