18 KiB
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 页面”的拆分方式。
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 增加:
on_audio_query:
path: ./local_packages/on_audio_query-2.9.0
如果代码中直接使用 package:path/path.dart 做文件夹路径处理,建议把 path 显式加入依赖,避免依赖传递包:
path: ^1.9.0
4.2 AndroidManifest
现有 Manifest 已有:
READ_EXTERNAL_STORAGEWRITE_EXTERNAL_STORAGE
需要补充 Android 13+ 音频权限,并建议限制旧权限版本:
<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,兼容旧字段读取:
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.uri,Agora 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:
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,原因是麦上需要把音乐推给远端。
麦下播放:
engine.startAudioMixing(
filePath: playPath,
loopback: true,
cycle: 1,
startPos: currentPositionMs,
);
麦上播放:
engine.startAudioMixing(
filePath: playPath,
loopback: false,
cycle: 1,
startPos: currentPositionMs,
);
这里的 loopback 是关键:
true:只本地播放,只有自己听到。false:本地和远端都能听到。
6.2 麦上/麦下同步
当用户播放中上麦或下麦时,需要切换播放路由:
- 记录当前播放位置:
getAudioMixingCurrentPosition()。 stopAudioMixing()。- 根据
RtcProvider.isOnMai()重新startAudioMixing(loopback: !isOnMai, startPos: position)。 - 恢复播放态和进度轮询。
接入点建议:
- 在
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:
startAudioMixingpauseAudioMixingresumeAudioMixingstopAudioMixingadjustAudioMixingVolumeadjustAudioMixingPlayoutVolumeadjustAudioMixingPublishVolumegetAudioMixingDurationgetAudioMixingCurrentPositionsetAudioMixingPosition
音量策略:
- UI 展示 0-100%。
- 默认同时调用
adjustAudioMixingPlayoutVolume(volume)和adjustAudioMixingPublishVolume(volume)。 - 如果后续产品区分“自己听到的音量”和“房间听到的音量”,再拆成两个滑杆。
6.4 播放模式
定义:
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 添加流程
流程:
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 小时。
- 修改
pubspec.yaml,接入on_audio_query。 - 修改
android/app/src/main/AndroidManifest.xml,补READ_MEDIA_AUDIO。 - 扩展
SCPermissionUtils.checkAudioPermission()。 - 扩展
SCMusicMode,新增SCMusicFolderMode。 - 新增
RoomMusicRepository,封装读取、保存、去重、排序。 - 运行
flutter pub get和基础静态检查。
阶段 2:扫描与添加流程
预计 6-8 小时。
- 新增
LocalMusicScanner。 - 使用
OnAudioQuery.checkAndRequest()或项目权限工具 +querySongs()扫描。 - 实现 MP3 过滤、文件夹分组、已添加数量统计。
- 实现文件夹列表页。
- 实现音乐选择页、全选、新增勾选、已添加灰色勾选、取消已添加。
- 添加成功后更新本地持久化并返回音乐列表。
阶段 3:音乐列表与 CRUD
预计 6-8 小时。
- 实现
RoomMusicPage空态、有数据态。 - 接入搜索。
- 接入左滑删除和二次确认。
- 删除当前播放歌曲时停止播放。
- 接入拖拽排序并持久化。
- 接入口入口菜单。
阶段 4:播放器与 Agora 混流
预计 8-12 小时。
- 新增
RoomMusicManager extends ChangeNotifier。 - 在
main.dart的MultiProvider中注册。 - 封装播放、暂停、恢复、停止、上下首。
- 封装进度轮询,间隔建议 500-1000ms。
- 封装
seek与音量调节。 - 实现三种播放模式。
- 处理
onAudioMixingStateChanged自动切歌与失败兜底。 - 在上麦/下麦/离房流程接入播放路由同步和清理。
阶段 5:UI 细节与真机验证
预计 6-10 小时。
- 调整播放器底部高度,避免遮挡列表。
- 接入播放中音乐 icon 旋转动画。
- 补充多语言 key:没有音乐、从手机扫描添加、未找到音乐、确定删除音乐、音乐已删除、总计歌曲、播放模式 Toast 等。
- Android 12、Android 13+ 真机验证权限。
- 真机验证麦上/麦下播放、闭麦不影响音乐输出。
- 回归语言房退出、切房、最小化、重新进入房间。
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. 推荐落地顺序
优先顺序:
- 数据模型、持久化、权限、扫描。
- 文件夹页和选择页。
- 音乐列表页和入口。
- 基础播放、暂停、停止。
- 上下首、进度、音量、模式。
- 麦上/麦下同步。
- 删除、排序、搜索和细节回归。
这样可以尽早在真机上验证最不确定的两件事:Android MediaStore 扫描结果,以及 Agora 使用 contentUri 播放本地 MP3 的兼容性。