yumi-flutter/docs/android-room-music-execution-plan.md
2026-04-29 14:09:34 +08:00

549 lines
18 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Android 语言房音乐功能执行方案
整理时间2026-04-29
适用范围Android 单端,语言房内 MP3 背景音乐添加、管理与播放
参考文档:`/Users/nigger/Desktop/Android音乐添加流程.md`
## 1. 目标
在语言房内补齐一套可上线的 Android 音乐能力:
- 从房间操作面板进入音乐功能。
- 首次为空时引导扫描手机 MP3。
- 支持 Android 音频权限申请与本地 MP3 扫描。
- 按文件夹展示扫描结果,支持选择音乐添加到房间音乐列表。
- 支持已添加音乐的搜索、删除、排序、二次添加、取消已添加。
- 支持播放器:播放、暂停、停止、上一首、下一首、播放模式、音量、进度条拖动。
- 支持麦下仅自己听,麦上推流给房间其他用户听。
- 离房、切房、删除当前播放歌曲时正确停止并清理播放状态。
不纳入本次范围:
- iOS 权限和 iOS 媒体库适配。
- 在线音乐、歌词、专辑封面精修。
- 后台常驻播放通知栏。
- 服务端同步歌单。当前按用户本地持久化处理。
## 2. 项目现状
已有基础:
- `agora_rtc_engine: ^6.5.2` 已接入,实际锁定版本为 6.5.3。
- `lib/services/audio/rtc_manager.dart` 已有 `startAudioMixing(String localPath, int cycle)` 雏形。
- `rtc_manager.dart` 已监听 `onAudioMixingStateChanged`,但只维护了 `isMusicPlaying`
- `lib/shared/data_sources/models/sc_music_mode.dart` 已有基础音乐模型。
- `lib/shared/data_sources/sources/local/data_persistence.dart` 已有 `getUserRoomMusic()` / `setUserRoomMusic()`
- `sc_images/room/` 已有音乐相关图标:播放、暂停、上一首、下一首、音量、空态、播放模式、删除、添加、音乐入口等。
- 多语言中已有 `music``myMusic``localMusic``add``delete``search``edit``confirm``cancel` 等基础文案。
- `local_packages/on_audio_query-2.9.0``local_packages/on_audio_query_android-1.1.0` 已存在,可用于 Android MediaStore 音频扫描。
需要补齐:
- `pubspec.yaml` 尚未声明 `on_audio_query` 依赖。
- AndroidManifest 缺少 Android 13+ 的 `READ_MEDIA_AUDIO`
- `SCMusicMode` 字段不足,缺少稳定 id、content uri、文件夹路径、排序、添加时间等。
- 音乐入口在 `lib/ui_kit/widgets/room/room_menu_dialog.dart` 中曾经存在,但当前被注释。
- 没有独立音乐状态管理器,播放器状态不应继续堆进已经很长的 `RtcProvider`
- Agora 音频混流只封装了开始播放缺少暂停、恢复、停止、音量、进度、seek、播放完成切歌、麦上/麦下路由切换。
## 3. 总体技术方案
采用“扫描服务 + 歌单状态管理 + Agora 播放适配 + UI 页面”的拆分方式。
```mermaid
flowchart LR
A["房间菜单 音乐入口"] --> B["RoomMusicPage"]
B --> C["RoomMusicManager"]
C --> D["RoomMusicRepository"]
D --> E["DataPersistence"]
C --> F["LocalMusicScanner"]
F --> G["on_audio_query / MediaStore"]
C --> H["RoomMusicPlayer"]
H --> I["RtcProvider / Agora RtcEngine"]
```
推荐新增目录:
- `lib/services/music/`
- `lib/modules/room/music/`
- `lib/ui_kit/widgets/room/music/`
推荐新增核心文件:
- `lib/shared/data_sources/models/sc_music_mode.dart`:扩展现有模型。
- `lib/shared/data_sources/models/sc_music_folder_mode.dart`:新增本地扫描文件夹模型。
- `lib/services/music/local_music_scanner.dart`Android 本地 MP3 扫描。
- `lib/services/music/room_music_repository.dart`:已添加歌单本地持久化。
- `lib/services/music/room_music_manager.dart`:歌单、选择、播放状态总控。
- `lib/modules/room/music/room_music_page.dart`:音乐列表页。
- `lib/modules/room/music/room_music_folder_page.dart`:扫描后的文件夹列表页。
- `lib/modules/room/music/room_music_select_page.dart`:文件夹内音乐选择页。
- `lib/ui_kit/widgets/room/music/room_music_player_bar.dart`:播放器控制条。
- `lib/ui_kit/widgets/room/music/room_music_list_item.dart`:音乐列表项。
- `lib/ui_kit/widgets/room/music/room_music_empty_state.dart`:空状态。
## 4. 依赖与权限
### 4.1 pubspec
`pubspec.yaml` 增加:
```yaml
on_audio_query:
path: ./local_packages/on_audio_query-2.9.0
```
如果代码中直接使用 `package:path/path.dart` 做文件夹路径处理,建议把 `path` 显式加入依赖,避免依赖传递包:
```yaml
path: ^1.9.0
```
### 4.2 AndroidManifest
现有 Manifest 已有:
- `READ_EXTERNAL_STORAGE`
- `WRITE_EXTERNAL_STORAGE`
需要补充 Android 13+ 音频权限,并建议限制旧权限版本:
```xml
<uses-permission
android:name="android.permission.READ_EXTERNAL_STORAGE"
android:maxSdkVersion="32" />
<uses-permission
android:name="android.permission.WRITE_EXTERNAL_STORAGE"
android:maxSdkVersion="28" />
<uses-permission android:name="android.permission.READ_MEDIA_AUDIO" />
```
### 4.3 权限工具
`lib/shared/tools/sc_permission_utils.dart` 增加 `checkAudioPermission()`
- Android 13+:请求 `Permission.audio`
- Android 12 及以下:请求 `Permission.storage`
- 需要使用项目已有 `device_info_plus` 判断 SDK 版本。
- 权限拒绝时不进入扫描结果页,只保留在当前流程;后续可补“去设置开启”引导。
## 5. 数据模型
### 5.1 音乐项
扩展 `SCMusicMode`,兼容旧字段读取:
```dart
class SCMusicMode {
final String id; // 优先用 MediaStore id兜底用 localPath hash
final String title;
final String artist;
final int durationMs;
final String localPath; // SongModel.data
final String contentUri; // SongModel.uriAgora Android 推荐优先使用
final String folderPath;
final String fileName;
final int size;
final int sortIndex;
final int addedAt;
}
```
关键点:
- 扫描只保留 `fileExtension == mp3` 或文件名 `.mp3` 的音频。
- 播放时优先使用 `contentUri`,为空再用 `localPath`
- 持久化仍走 `DataPersistence.setUserRoomMusic(jsonEncode(list))`
- 读取旧数据时要兼容当前模型里的 `title``artist``duration``localPath`
### 5.2 文件夹模型
新增 `SCMusicFolderMode`
```dart
class SCMusicFolderMode {
final String id;
final String name;
final String path;
final int musicCount;
final int addedCount;
final List<SCMusicMode> songs;
}
```
文件夹统计规则:
- 使用 `p.dirname(song.localPath)` 作为文件夹路径。
- 单个文件夹只统计该文件夹当前层级直接包含的 MP3。
- 子文件夹内 MP3 归入子文件夹,不累计到父文件夹。
- 文件夹列表按名称或路径稳定排序。
## 6. 播放器方案
### 6.1 播放方式
统一使用 Agora `startAudioMixing`,原因是麦上需要把音乐推给远端。
麦下播放:
```dart
engine.startAudioMixing(
filePath: playPath,
loopback: true,
cycle: 1,
startPos: currentPositionMs,
);
```
麦上播放:
```dart
engine.startAudioMixing(
filePath: playPath,
loopback: false,
cycle: 1,
startPos: currentPositionMs,
);
```
这里的 `loopback` 是关键:
- `true`:只本地播放,只有自己听到。
- `false`:本地和远端都能听到。
### 6.2 麦上/麦下同步
当用户播放中上麦或下麦时,需要切换播放路由:
1. 记录当前播放位置:`getAudioMixingCurrentPosition()`
2. `stopAudioMixing()`
3. 根据 `RtcProvider.isOnMai()` 重新 `startAudioMixing(loopback: !isOnMai, startPos: position)`
4. 恢复播放态和进度轮询。
接入点建议:
-`RtcProvider` 上麦成功、下麦成功、角色切换成功后调用 `RoomMusicManager.syncPlaybackRouting(rtcProvider)`
- 离房清理流程调用 `RoomMusicManager.stopAndReset()`.
### 6.3 控制能力
`RoomMusicManager` 需要封装:
- `play(SCMusicMode item)`
- `pause()`
- `resume()`
- `stop()`
- `previous()`
- `next({bool fromAutoComplete = false})`
- `seek(Duration position)`
- `setVolume(int percent)`
- `switchPlayMode()`
- `deleteMusic(SCMusicMode item)`
- `reorder(int oldIndex, int newIndex)`
Agora 对应 API
- `startAudioMixing`
- `pauseAudioMixing`
- `resumeAudioMixing`
- `stopAudioMixing`
- `adjustAudioMixingVolume`
- `adjustAudioMixingPlayoutVolume`
- `adjustAudioMixingPublishVolume`
- `getAudioMixingDuration`
- `getAudioMixingCurrentPosition`
- `setAudioMixingPosition`
音量策略:
- UI 展示 0-100%。
- 默认同时调用 `adjustAudioMixingPlayoutVolume(volume)``adjustAudioMixingPublishVolume(volume)`
- 如果后续产品区分“自己听到的音量”和“房间听到的音量”,再拆成两个滑杆。
### 6.4 播放模式
定义:
```dart
enum RoomMusicPlayMode {
list,
shuffle,
single,
}
```
规则:
- 列表顺序播放:播放完成后按当前排序下一首,末尾回到第一首或停止,建议按原型做循环到第一首。
- 随机播放:播放完成后随机选择非当前歌曲;只有一首时播放自身。
- 单曲循环:播放完成后继续当前歌曲。
切换 Toast
- `列表顺序播放`
- `随机播放`
- `单曲循环播放`
### 6.5 播放完成
监听 `onAudioMixingStateChanged`
- `audioMixingStatePlaying`:更新为播放中。
- `audioMixingStatePaused`:更新为暂停。
- `audioMixingStateStopped` + `audioMixingReasonAllLoopsCompleted`:按播放模式自动下一首。
- `audioMixingStateStopped` + `audioMixingReasonStoppedByUser`:停止态。
- `audioMixingStateFailed`Toast 提示并停止当前播放,常见原因是文件被删除、路径无权限、格式不支持。
## 7. 页面与交互落地
### 7.1 房间操作面板入口
修改 `lib/ui_kit/widgets/room/room_menu_dialog.dart`
- 恢复 `音乐` 入口。
- 点击后关闭菜单,打开 `RoomMusicPage`
- 建议房主/管理员/普通用户都能看到入口;是否允许播放由房间配置 `allowMusic` 决定。如果产品要求强制限制,则判断 `currenRoom.roomProfile.roomSetting.allowMusic`
### 7.2 音乐列表页
`RoomMusicPage` 状态:
- 空列表:展示空态图标、`没有音乐``从手机扫描添加`
- 有列表:顶部 `添加`、搜索入口、编辑入口、总数、歌曲列表。
- 播放中:当前播放项高亮,底部显示播放器条。
- 编辑态:支持拖拽排序;也可保留左滑删除。
列表能力:
- 搜索按 `title` / `fileName` 包含匹配。
- 左滑删除,弹窗确认 `确定删除音乐?`
- 删除当前播放歌曲时,先 `stop()`,再删除并持久化。
- 拖拽排序后立即更新 `sortIndex` 并持久化。
### 7.3 添加流程
流程:
```mermaid
flowchart TD
A["点击 添加/从手机扫描添加"] --> B["检查音频权限"]
B -->|"拒绝"| C["停留当前页"]
B -->|"允许"| D["扫描 MediaStore MP3"]
D -->|"无结果"| E["Toast 未找到音乐"]
D -->|"有结果"| F["文件夹列表页"]
F --> G["选择文件夹"]
G --> H["音乐选择页"]
H --> I["勾选/取消勾选"]
I --> J["点击 添加"]
J --> K["更新已添加列表并返回音乐列表"]
```
扫描规则:
- 每次进入添加流程都重新调用 `querySongs()`
- 过滤 MP3。
- 过滤 `duration <= 0` 的异常项。
- 文件不存在或 `localPath` 为空的项不进入结果。
- 对同一个 MediaStore id 或同一路径去重。
选择规则:
- 已添加歌曲显示灰色勾选。
- 本次新选歌曲显示高亮勾选。
- 用户点击灰色勾选可取消已添加,提交后从列表移除。
- 只有存在“新增歌曲”时,添加按钮高亮;如果只有取消已添加,建议按钮文案仍为 `添加` 但可点击,否则无法完成取消动作。这里与原型“无新增则置灰”有轻微冲突,建议和产品确认;若严格按原型,则取消已添加后也需要允许提交,否则规则无法闭环。
## 8. 具体实施步骤
### 阶段 1基础依赖与数据层
预计 4-6 小时。
1. 修改 `pubspec.yaml`,接入 `on_audio_query`
2. 修改 `android/app/src/main/AndroidManifest.xml`,补 `READ_MEDIA_AUDIO`
3. 扩展 `SCPermissionUtils.checkAudioPermission()`
4. 扩展 `SCMusicMode`,新增 `SCMusicFolderMode`
5. 新增 `RoomMusicRepository`,封装读取、保存、去重、排序。
6. 运行 `flutter pub get` 和基础静态检查。
### 阶段 2扫描与添加流程
预计 6-8 小时。
1. 新增 `LocalMusicScanner`
2. 使用 `OnAudioQuery.checkAndRequest()` 或项目权限工具 + `querySongs()` 扫描。
3. 实现 MP3 过滤、文件夹分组、已添加数量统计。
4. 实现文件夹列表页。
5. 实现音乐选择页、全选、新增勾选、已添加灰色勾选、取消已添加。
6. 添加成功后更新本地持久化并返回音乐列表。
### 阶段 3音乐列表与 CRUD
预计 6-8 小时。
1. 实现 `RoomMusicPage` 空态、有数据态。
2. 接入搜索。
3. 接入左滑删除和二次确认。
4. 删除当前播放歌曲时停止播放。
5. 接入拖拽排序并持久化。
6. 接入口入口菜单。
### 阶段 4播放器与 Agora 混流
预计 8-12 小时。
1. 新增 `RoomMusicManager extends ChangeNotifier`
2.`main.dart``MultiProvider` 中注册。
3. 封装播放、暂停、恢复、停止、上下首。
4. 封装进度轮询,间隔建议 500-1000ms。
5. 封装 `seek` 与音量调节。
6. 实现三种播放模式。
7. 处理 `onAudioMixingStateChanged` 自动切歌与失败兜底。
8. 在上麦/下麦/离房流程接入播放路由同步和清理。
### 阶段 5UI 细节与真机验证
预计 6-10 小时。
1. 调整播放器底部高度,避免遮挡列表。
2. 接入播放中音乐 icon 旋转动画。
3. 补充多语言 key没有音乐、从手机扫描添加、未找到音乐、确定删除音乐、音乐已删除、总计歌曲、播放模式 Toast 等。
4. Android 12、Android 13+ 真机验证权限。
5. 真机验证麦上/麦下播放、闭麦不影响音乐输出。
6. 回归语言房退出、切房、最小化、重新进入房间。
## 9. 建议工时
按 Android 单端、GPT-5.5 + Codex 协作速度估算:
| 模块 | 预计耗时 |
|---|---:|
| 依赖、权限、数据模型、持久化 | 4-6 小时 |
| MP3 扫描、文件夹分组、添加选择 | 6-8 小时 |
| 已添加列表、搜索、删除、排序 | 6-8 小时 |
| Agora 播放器、进度、音量、模式、上下首 | 8-12 小时 |
| 房间入口、上麦/下麦同步、离房清理 | 4-6 小时 |
| Android 真机测试与修复 | 6-10 小时 |
总计建议预留34-50 小时。
如果先做 MVP
- 扫描 MP3
- 添加到列表
- 播放/暂停/停止
- 上下首
- 音量
- 进度条
- 删除
- 麦下本地播放、麦上推流播放
预计22-32 小时。
完整对齐原型并做稳定上线版本,建议按 40 小时左右排期。
## 10. 风险与处理
### 10.1 Android 10+ 路径访问
Agora 文档建议 Android 播放本地文件优先使用 `content://` URI。`on_audio_query``SongModel.uri` 可作为 `contentUri` 保存。
处理:
- 播放路径优先级:`contentUri` > `localPath`
- 如果 `audioMixingReasonCanNotOpen`,提示文件无法播放,并可从列表移除失效文件。
### 10.2 添加按钮规则冲突
原型写到“无新增音乐时添加按钮置灰”,同时又写到“点击置灰已添加可取消,点击添加后移除”。如果只取消已添加但没有新增,按钮置灰会导致无法提交移除。
建议:
- 只要本次选择结果和原已添加状态有差异,按钮即可点击。
- 如果产品坚持按钮必须叫 `添加`,仍然允许提交取消动作。
### 10.3 播放状态跨麦位变化
用户麦下播放时是 `loopback: true`,上麦后需要切到 `loopback: false`。Agora 没有直接切换 loopback 的接口,需要 stop/start。
处理:
- 切换前读取进度。
-`startPos` 恢复。
- 加 500ms 节流,避免连续上麦状态变化触发频繁 start。
### 10.4 当前播放歌曲被删除
处理:
- 删除前判断 `current?.id == target.id`
- 是当前歌曲则先 `stopAudioMixing()`
- 清空当前播放项和进度。
- 再持久化删除。
### 10.5 房间生命周期
处理:
- 离房、RTC disconnected 清理、App termination 释放前调用音乐停止。
- 切房时不保留上一房间播放状态。
- 最小化播放器仅影响 UI不影响播放。
## 11. 验收清单
权限与扫描:
- Android 13+ 首次添加触发音频权限弹窗。
- Android 12 及以下可正常读取本地音乐。
- 拒绝权限后不会进入扫描结果页。
- 无 MP3 时 Toast `未找到音乐`
- 扫描结果只包含 MP3。
- 文件夹数量符合“只统计当前层级直接音乐”的规则。
添加与列表:
- 空列表展示空态。
- 添加后返回音乐列表并展示总数。
- 二次添加能展示已添加数量。
- 已添加歌曲灰色勾选,新选择歌曲高亮勾选。
- 取消已添加并提交后,音乐列表移除对应歌曲。
- 搜索能按名称过滤。
- 左滑删除有确认弹窗。
- 删除成功 Toast `音乐已删除`
- 拖拽排序后重新进入页面仍保持顺序。
播放:
- 点击歌曲开始播放。
- 播放/暂停/恢复正常。
- 上一首/下一首正常。
- 进度条自动推进。
- 拖动进度条能 seek。
- 音量调节生效。
- 列表顺序、随机、单曲循环三种模式正常。
- 播放完成后按当前模式切歌。
- 删除当前播放歌曲后立即停止。
房间场景:
- 麦下播放只有自己听到。
- 播放中上麦后,其他用户能听到音乐。
- 播放中下麦后,其他用户不再听到音乐,本地继续播放。
- 闭麦不影响音乐推流。
- 离房后音乐停止。
- 切房后不会继续上一房间音乐。
## 12. 推荐落地顺序
优先顺序:
1. 数据模型、持久化、权限、扫描。
2. 文件夹页和选择页。
3. 音乐列表页和入口。
4. 基础播放、暂停、停止。
5. 上下首、进度、音量、模式。
6. 麦上/麦下同步。
7. 删除、排序、搜索和细节回归。
这样可以尽早在真机上验证最不确定的两件事Android MediaStore 扫描结果,以及 Agora 使用 `contentUri` 播放本地 MP3 的兼容性。