2026-04-02-playback-controller-decouple-localmusic-design.md 9.3 KB

播放控制脱离 LocalMusic 宿主设计

背景

当前项目里的统一播放控制入口是 MusicPlaybackController,但它本质上仍然只是一个动作分发器或事件桥接层:

  • playOrPauseplayNextplayPreviousseekTo 等动作依赖 LocalMusic 在页面生命周期里注册 actions。
  • 起播一首歌或一组歌时,页面侧通常还是通过 EVENT_PLAYLIST_PLAY 发送请求,再由 LocalMusic 监听并执行真实播放。
  • 播放器实例、播放队列、当前歌曲、当前索引、AVSession、投播联动等核心逻辑都沉在 LocalMusic 内部。

这导致一个结构性问题:任何页面想直接调用 controller 播放,实际上仍然依赖 LocalMusic 充当“播放器宿主页面”。

本阶段目标

第一阶段只解决“播放核心依赖 LocalMusic 宿主”的问题,不做整站 UI 重构。

完成标准:

  • 任意页面可以直接通过 MusicPlaybackController 起播单曲或队列。
  • 任意页面可以直接通过 MusicPlaybackController 调用 play/pause/next/previous/seek
  • LocalMusic 不再负责接收 EVENT_PLAYLIST_PLAY 作为主播放入口。
  • 迷你播放条、全屏播放器、播放页显隐、列表高亮等 UI 显示状态,暂时仍保留在 NewIndex/LocalMusic 层。
  • 允许 LocalMusic 继续常驻隐藏挂载,但它不再是播放指令的宿主。

非目标:

  • 本阶段不把迷你播放条状态完全抽成独立 store。
  • 本阶段不强行拆完 LocalMusic 的所有播放相关字段。
  • 本阶段不重写投播、歌词、AVSession 的全部实现方式,只调整宿主归属和调用链。

推荐方案

采用“抽离 PlaybackCoordinator,保留现有 UI 宿主”的方案。

核心思想:

  • MusicPlaybackController 升级成真正的统一控制入口。
  • 新增 PlaybackCoordinator 承接真实播放核心逻辑。
  • LocalMusic 从“播放逻辑宿主”降级为“播放 UI 容器”。
  • 页面层不再依赖 EVENT_PLAYLIST_PLAY -> LocalMusic 这条链路起播。

不采用以下方案:

  • 仅继续做事件桥接。这样 controller 仍然不是实际播放入口,后续还要返工。
  • 一次性把 UI 状态和核心状态全部独立。首轮改动面过大,容易同时打坏迷你播放条、投播、AVSession 与歌词联动。

目标架构

1. MusicPlaybackController

职责:

  • 提供全局统一 API。
  • 对外暴露直接控制接口,而不是依赖页面注册 actions。
  • 将页面请求转发给 PlaybackCoordinator

第一阶段期望接口:

  • playSong(song: VideoItem, options?)
  • playQueue(songs: VideoItem[], startIndex: number, options?)
  • playOrPause()
  • playNext()
  • playPrevious()
  • seekTo(value: string, source?: string)

兼容策略:

  • 旧的 setActions/clearActions 暂时保留,但标记为过渡能力。
  • controller 内部优先走 PlaybackCoordinator;仅对尚未迁出的能力允许短期回退。

2. PlaybackCoordinator

新增协调层,负责播放核心链路。

职责:

  • 持有当前播放队列、当前索引、当前歌曲、基础播放状态。
  • 承接起播单曲、起播队列、切歌、暂停/恢复、seek。
  • 协调底层 player、AVSession、投播模块与播放元数据同步。
  • 向 UI 层暴露可订阅的最小播放状态。

第一阶段必须迁出的逻辑:

  • doPlay 主链路及其前后置准备逻辑。
  • 当前歌曲切换。
  • 播放队列切换。
  • play/pause/next/previous/seek 的控制主路径。

第一阶段允许仍留在 UI 层的逻辑:

  • 播放页打开关闭。
  • 迷你播放条显示/隐藏状态。
  • 纯视觉动画与页面交互状态。

3. LocalMusic

迁移后职责:

  • 读取 PlaybackCoordinator 暴露的播放状态并渲染 UI。
  • 负责全屏播放器、播放列表界面、迷你播放条相关视觉层逻辑。
  • 保留页面内交互,但交互结果改为调用 controller,而不是直接执行播放核心流程。

迁移后不再承担的职责:

  • 监听 EVENT_PLAYLIST_PLAY 作为主播放入口。
  • 持有独占的起播核心流程。
  • 作为 controller actions 的唯一宿主。

4. NewIndex

保持当前职责:

  • 承载页面级 UI 容器。
  • 继续持有迷你播放条显示层状态。
  • 通过共享播放状态决定何时展示播放 UI。

数据流

迁移前:

  1. 页面点击歌曲。
  2. 页面发事件或调用桥接逻辑。
  3. LocalMusic 监听事件。
  4. LocalMusic 执行 doPlay 与后续播放流程。

迁移后:

  1. 页面点击歌曲。
  2. 页面直接调用 MusicPlaybackController.playSong(...)playQueue(...)
  3. controller 直接调用 PlaybackCoordinator
  4. PlaybackCoordinator 更新播放队列、当前歌曲、底层播放器与共享播放状态。
  5. LocalMusic/NewIndex 作为 UI 消费者响应状态变化。

关键变化:

  • 页面到播放核心的入口从“事件 + LocalMusic”改为“controller + coordinator”。
  • LocalMusic 从命令执行者变成状态消费者。

状态拆分原则

为降低风险,第一阶段只抽“核心播放状态”,不抽“全部 UI 状态”。

核心播放状态建议包含:

  • 当前歌曲 currentSong
  • 播放队列 songList
  • 当前索引 curIndex
  • 是否正在播放 isPlaying
  • 当前进度/时长
  • 当前播放模式

继续留在 UI 层的状态:

  • 播放页是否展开
  • 迷你播放条显隐
  • 页面动画状态
  • 手势、滚动、弹窗、浮层相关状态

判断标准:

  • 影响实际播放行为的状态,属于 coordinator。
  • 只影响视觉表现的状态,留在 UI。

分阶段迁移计划

阶段 1:抽出起播入口

目标:

  • LocalMusic 中抽离 doPlay 主链路到 PlaybackCoordinator
  • 支持 playSong(song)playQueue(songs, startIndex)
  • 首批页面切到新入口:ChartsCount、歌单页、发现页等最直接的列表页。

验收:

  • 不经 EVENT_PLAYLIST_PLAY 也能直接起播。
  • 首次起播后迷你播放条和播放器界面仍能正常响应。

阶段 2:抽出基础控制动作

目标:

  • playOrPauseplayNextplayPreviousseekToLocalMusic 挪到 PlaybackCoordinator
  • MusicPlaybackController.setActions/clearActions 进入兼容态,不再作为主路径。

验收:

  • 页面级 controller 调用不再依赖 LocalMusic 注册 actions。

阶段 3:UI 状态改为消费共享状态

目标:

  • LocalMusic 改为从共享状态读取当前歌曲、索引、播放状态、进度等。
  • 清理 UI 层对核心播放状态的直接写入。

验收:

  • LocalMusic 只做 UI 响应,不再承担核心状态源头角色。

阶段 4:清理旧事件桥接

目标:

  • 清理 EVENT_PLAYLIST_PLAY 作为本地主播放主链的职责。
  • 清理为桥接页面播放而存在的过渡数据仓库和 pending request 逻辑。

验收:

  • 本地播放主链全部走 controller -> coordinator。

兼容与回退策略

为了避免一次改坏所有场景,第一阶段允许短期双轨:

  • 新页面入口优先调用 controller -> coordinator
  • 未迁移完的少数路径可保留旧逻辑,直到对应页面切换完成。

但有两条硬约束:

  • 不允许新增新的 EVENT_PLAYLIST_PLAY -> LocalMusic 依赖。
  • controller 对外文档和新代码一律以 direct API 为准。

风险与约束

1. 强耦合字段风险

LocalMusic 当前很可能把播放器状态、UI 状态、动画状态、投播状态混在同一个大组件中。

应对:

  • 第一阶段先只迁移真正决定播放行为的字段。
  • 对一时拆不开的字段,先通过只读映射或桥接读取,避免一轮就全部改写。

2. AVSession / 投播联动风险

如果 AVSession 与投播流程深度耦合 LocalMusic 内部字段,直接搬迁会有回归风险。

应对:

  • 优先保证 coordinator 成为“命令主入口”。
  • AVSession 与投播在第一阶段可通过适配层接入 coordinator,不要求同一轮完全重构。

3. UI 不同步风险

若 coordinator 成为状态源,但 LocalMusic 仍在局部直接写旧字段,容易出现 UI 与实际播放状态分裂。

应对:

  • 每迁一类动作,就同步把对应 UI 读取切到共享状态。
  • 禁止同一字段同时存在“coordinator 写”和“UI 自己写”两套主路径。

测试与验证

第一阶段验证重点不是全量 UI,而是播放主链切换是否成功。

必须验证:

  • controller 直接调用可起播单曲。
  • controller 直接调用可起播队列。
  • play/pause/next/previous/seek 不依赖 LocalMusic actions。
  • LocalMusic 不再作为 EVENT_PLAYLIST_PLAY 的主执行入口。
  • 迷你播放条和全屏播放器仍能跟随共享状态更新。

建议测试层次:

  • 控制器与 coordinator 的纯逻辑测试。
  • 队列切换与索引切换的回归测试。
  • AVSession / 投播的冒烟验证。
  • 真机手工验证:统计页、歌单页、发现页直接起播。

决策结论

本阶段采用以下明确决策:

  • 可以接受 LocalMusic 继续常驻隐藏挂载。
  • 不再接受 LocalMusic 作为播放指令宿主。
  • 先保留 NewIndex/LocalMusic 的 UI 显示状态。
  • 先抽播放核心,再逐步抽状态与显示层。

这保证第一阶段的目标聚焦为:让 controller 真正变成任何页面都能直接调用的播放入口,同时把风险控制在可验证范围内。