本地音乐库与云盘
本页说明 Android 端“本地音乐库与云盘”相关的原生能力:通过系统目录选择器授权本地音乐目录、扫描并读取媒体元数据,以及把远程音乐异步下载到设备可访问的位置。当前已读源码中没有独立的云盘客户端或云端同步实现;“云盘”在本页范围内主要体现为远程音频下载后进入本地音乐库的衔接。
Purpose and Scope
本页覆盖以下端到端能力:
LocalMusicPlugin暴露的本地目录授权、目录状态查询、清除授权,以及封面/歌词读取能力。MusicDownloadPlugin暴露的下载、暂停、恢复和取消能力,以及下载后的标签写入、封面/歌词落盘和媒体索引处理。MainActivity对两个 Capacitor 插件的注册方式。- Android 权限、
MediaPlaybackService的媒体前台服务声明,以及网络访问基础配置。
本页不展开 Web 前端的 locaMusic.js、downloadManager、云盘服务 API、远程音乐搜索接口或播放通知的完整实现;这些内容需要对应源码或独立目录支持。当前证据也不足以证明存在真正的云盘同步、上传、冲突解决或双向同步流程,因此不对这些行为作推断。
Overview
Android 采用分区存储,应用不能像桌面端一样直接遍历任意文件系统路径。LocalMusicPlugin 因此使用 Storage Access Framework(SAF)的 ACTION_OPEN_DOCUMENT_TREE,让用户显式选择一个目录,并把 tree URI 持久化到 SharedPreferences。后续本地音乐能力只围绕该授权目录工作,避免访问用户未授权的位置。
本地文件与 WebView 之间通过 Capacitor 插件桥接:插件读取 content:// URI,并将前端需要的结构化结果或 data: URL 返回给 JavaScript。插件注释明确要求返回结构与桌面端 dirTree.js 保持一致,以便前端复用目录树、音乐节点和索引逻辑。
下载链路则由 MusicDownloadPlugin 接管:入口快速向 JavaScript 返回,实际 HTTP 下载在后台线程执行;文件先写入应用私有 cache,下载完成后再写入 MP3/FLAC 标签、保存封面或歌词,最后复制到 Android 10+ 的公共 Downloads 或旧版本的 SAF 授权目录。这个两阶段设计避免了直接对 content:// 目标进行复杂的随机写入,同时让标签处理失败不会破坏已经下载的音频文件。
Architecture
Source: MainActivity.java
Source: LocalMusicPlugin.java
Source: MusicDownloadPlugin.java
MainActivity.onCreate 在调用 super.onCreate 之前注册 LocalMusicPlugin 和 MusicDownloadPlugin,使 Capacitor bridge 能发现这两个插件。LocalMusicPlugin 负责“已存在的本地内容”和媒体元数据;MusicDownloadPlugin 负责“远程内容到本地文件”的转换,两者通过共享的 hydrogen_local_music 偏好存储和最终的本地媒体目录形成衔接。
设计边界与关键约束
- 授权边界由 Android 系统强制执行。 本地库不是扫描全盘,而是扫描用户选择并持久化授权的 tree URI。
- 前端契约优先于平台内部实现。 原生插件保持桌面端已有调用形状,降低 Android 特化逻辑对前端的侵入。
- 下载与 UI 调用解耦。
download只负责接收参数、检查保存路径并启动后台线程;长耗时网络操作不阻塞插件调用线程。 - 媒体文件可用性优先。 MP3/FLAC 标签写入使用容错处理;标签写失败时仍保留原始音频。
- 云盘能力没有在已读 Android 源码中独立建模。 远程 URL 下载、封面 URL 和歌词文本是当前可验证的远程输入;不能据此推断云盘列表、同步或上传功能。
本地音乐目录授权流程
pickFolder 创建 ACTION_OPEN_DOCUMENT_TREE Intent,并同时请求读、写、持久化和前缀 URI 权限。用户取消时回调以 cancelled reject;结果没有 URI 时以 no-uri reject。得到 URI 后,插件尝试调用 takePersistableUriPermission,再将 URI 与显示名称保存到 hydrogen_local_music 偏好中。
1@PluginMethod
2public void pickFolder(PluginCall call) {
3 Intent intent = new Intent(Intent.ACTION_OPEN_DOCUMENT_TREE);
4 intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION
5 | Intent.FLAG_GRANT_WRITE_URI_PERMISSION
6 | Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION
7 | Intent.FLAG_GRANT_PREFIX_URI_PERMISSION);
8 startActivityForResult(call, intent, "pickFolderResult");
9}Source: LocalMusicPlugin.java
持久化授权的目的不是只支持当前 Activity 生命周期,而是让应用重启后仍能读取同一个目录。getFolder 会再次检查 URI 是否仍拥有持久化权限;如果偏好中没有 URI 或权限已经失效,则返回空对象,而不是返回一个看似可用但实际不可读的路径。clearFolder 只移除保存的 URI 和名称,并返回成功结果。
1@PluginMethod
2public void getFolder(PluginCall call) {
3 String uri = prefs().getString(KEY_TREE_URI, null);
4 if (uri == null || !hasPersistedPermission(uri)) {
5 call.resolve(new JSObject()); // 空对象 = 尚未设置
6 return;
7 }
8 JSObject ret = new JSObject();
9 ret.put("uri", uri);
10 ret.put("name", prefs().getString(KEY_TREE_NAME, ""));
11 call.resolve(ret);
12}Source: LocalMusicPlugin.java
音频类型与元数据读取
源码维护了一个与桌面端 MUSIC_TYPES 对齐的扩展名集合,包括 mp3、flac、wav、m4a、aac、ogg、opus、wma、ape、aiff、aif、alac、dsf、mp4 和 mka。扩展名通过 extOf 去掉点号并使用 Locale.ROOT 转为小写,避免大小写导致同一格式被分类为不同类型。
封面读取通过后台线程执行 MediaMetadataRetriever。优先读取音频内嵌图片,解码后压缩为质量 85 的 JPEG,并返回 data:image/jpeg;base64,...;若没有内嵌图片,再调用同目录侧车封面读取逻辑。所有异常都降级为空 dataUrl,因此缺少封面不会让本地音乐调用失败。歌词读取也在后台线程执行,优先尝试内嵌歌词,再退回同名 .lrc 侧车文件;源码将来源标记为 embedded 或 sidecar。
1@PluginMethod
2public void getMusicImage(PluginCall call) {
3 final String raw = call.getString("uri", null);
4 if (raw == null || raw.isEmpty()) {
5 JSObject empty = new JSObject();
6 empty.put("dataUrl", "");
7 call.resolve(empty);
8 return;
9 }
10 new Thread(() -> {
11 MediaMetadataRetriever mmr = new MediaMetadataRetriever();
12 final Uri mediaUri = Uri.parse(raw);
13 try {
14 mmr.setDataSource(getContext(), mediaUri);
15 byte[] pic = mmr.getEmbeddedPicture();
16 JSObject ret = new JSObject();
17 if (pic != null && pic.length > 0) {
18 Bitmap bmp = BitmapFactory.decodeByteArray(pic, 0, pic.length);
19 if (bmp != null) {
20 ByteArrayOutputStream bos = new ByteArrayOutputStream();
21 bmp.compress(Bitmap.CompressFormat.JPEG, 85, bos);
22 ret.put("dataUrl", "data:image/jpeg;base64,"
23 + Base64.encodeToString(bos.toByteArray(), Base64.NO_WRAP));
24 bmp.recycle();
25 } else {
26 ret.put("dataUrl", "");
27 }
28 } else {
29 String sidecar = readSidecarCover(mediaUri);
30 ret.put("dataUrl", sidecar == null ? "" : sidecar);
31 }
32 call.resolve(ret);
33 } catch (Throwable error) {
34 JSObject ret = new JSObject();
35 ret.put("dataUrl", "");
36 call.resolve(ret);
37 } finally {
38 try { mmr.release(); } catch (Throwable ignored) { }
39 }
40 }).start();
41}Source: LocalMusicPlugin.java
下载到本地音乐库的核心流程
下载入口接收 URL、歌曲名、格式、歌曲 ID、专辑、封面 URL、歌手和歌词等字段。Android 10 以下如果没有已授权的保存目录,会立即发出 noSavePath 错误;其他情况则先 resolve 调用,再在线程中执行真正下载。
Source: MusicDownloadPlugin.java
Source: MusicDownloadPlugin.java
runDownload 的顺序是:规范化扩展名并清理文件名;把远程内容写入 cache;对 MP3 写入 ID3v2.3、对 FLAC 写入 Vorbis Comment,其它格式不改动音频数据;将封面另存为同名 .jpg;在 Android 10+ 使用 MediaStore Downloads,否则使用 SAF tree;保存失败时带出 saveFailed:<reason>;成功后触发媒体更新/扫描;若启用 saveLyricFile,再写入同名 .lrc;最后发送成功事件并删除临时文件。
下载控制与并发模型
暂停、恢复、取消通过三个 volatile 静态标志控制:pauseRequested、cancelRequested 和当前 activeId。源码注释明确假设同一时刻只有一个下载在运行,因此这些状态不是按任务对象隔离的队列模型。调用 cancel 会同时设置取消并清除暂停状态;下载循环随后根据标志停止或返回。扩展为多任务并发下载时,不能直接复用这组全局标志,否则一个任务的控制信号可能影响另一个任务。
1@PluginMethod
2public void pause(PluginCall call) {
3 pauseRequested = true;
4 call.resolve();
5}
6
7@PluginMethod
8public void resume(PluginCall call) {
9 pauseRequested = false;
10 call.resolve();
11}
12
13@PluginMethod
14public void cancel(PluginCall call) {
15 cancelRequested = true;
16 pauseRequested = false;
17 call.resolve();
18}Source: MusicDownloadPlugin.java
API 与配置参考
LocalMusic Capacitor API
| 方法 | 输入 | 成功结果 | 失败或边界行为 |
|---|---|---|---|
pickFolder(call) | 无 | { uri, name } | 用户取消为 cancelled;结果无 URI 为 no-uri |
getFolder(call) | 无 | 已授权时返回 { uri, name } | 未设置或持久化权限失效时返回 {} |
clearFolder(call) | 无 | 空成功结果 | 移除 tree_uri 与 tree_name |
getMusicImage(call) | uri | { dataUrl } | 空 URI、无封面或异常时 dataUrl 为空字符串 |
getMusicLyric(call) | uri | { lyric, source? } | 内嵌歌词与侧车歌词都不存在时歌词为空 |
scan(call) | type | { type, count, metadata }(由插件注释定义) | 本页已读片段未展示扫描实现的全部细节 |
插件使用的持久化键是 hydrogen_local_music 偏好文件中的 tree_uri 和 tree_name。音频扩展名集合是代码内常量,并非外部配置。
MusicDownload Capacitor API
| 方法 | 输入 | 返回/事件 | 关键行为 |
|---|---|---|---|
download(call) | url、name、type、id、album、coverUrl、歌手字段、歌词字段、saveLyricFile | 立即 resolve;后台发送进度、成功或错误事件 | Android 10 以下没有授权目录时发 noSavePath |
pause(call) | 无 | 空成功结果 | 设置全局暂停标志 |
resume(call) | 无 | 空成功结果 | 清除全局暂停标志 |
cancel(call) | 无 | 空成功结果 | 设置取消标志并清除暂停标志 |
下载器的固定参数包括 BUFFER_SIZE = 64 * 1024。HTTP 连接的连接超时为 15 秒、读取超时为 30 秒,并开启重定向跟随;非 2xx 响应被视为下载失败。
Android 清单与运行时能力
1<application
2 android:allowBackup="true"
3 android:icon="@mipmap/ic_launcher"
4 android:label="@string/app_name"
5 android:roundIcon="@mipmap/ic_launcher_round"
6 android:supportsRtl="true"
7 android:usesCleartextTraffic="true"
8 android:theme="@style/AppTheme">
9
10 <activity
11 android:name=".MainActivity"
12 android:launchMode="singleTask"
13 android:screenOrientation="portrait"
14 android:exported="true">
15 </activity>
16</application>
17
18<uses-permission android:name="android.permission.INTERNET" />
19<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
20<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
21<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK" />Source: AndroidManifest.xml
Source: AndroidManifest.xml
INTERNET 支持远程音频和封面 URL;usesCleartextTraffic="true" 表明应用清单允许明文流量。媒体前台服务声明属于持续播放能力,保证后台媒体控制可用,但它不是本地扫描或云盘同步本身。
失败模式、边界条件与运维注意事项
- 用户取消目录选择:
pickFolderResultrejectcancelled,调用方应把它视为用户主动退出,而不是系统故障。 - URI 权限失效:
getFolder返回空对象;前端应重新引导用户选择目录,而不是继续使用旧 URI。 - 保存路径缺失: Android 10 以下下载前检查
tree_uri,缺少时发送noSavePath并结束当前调用。 - 网络失败: 连接异常、非 2xx 响应或读流失败最终归入
downloadFailed;取消则优先报告cancelled。 - 目标写入失败: MediaStore 或 SAF 复制失败报告
saveFailed:<copyError>,保留真实原因字符串以便前端提示和排查。 - 标签写入失败: MP3/FLAC 标签写入被单独捕获,失败不阻止音频文件落盘。该策略牺牲部分元数据完整性,换取已下载内容仍可播放。
- 封面读取失败:
getMusicImage以空dataUrl成功返回,不把缺少图片升级为整体调用失败。 - 临时文件清理:
runDownload的finally删除 cache 中的临时音频并清空activeId;封面和歌词临时文件也在各自复制后删除。 - 并发限制: 下载控制状态是静态全局变量,源码只支持一个活动下载。不要把它当作多下载队列或可重入任务管理器。
- 媒体索引一致性: 文件先落盘、再写标签,因此成功保存后会尝试更新 MediaStore,并调用
MediaScannerConnection.scanFile,降低系统元数据缓存缺失导致封面不可见的风险。
扩展点与实现边界
若要加入新的本地格式,至少需要同时检查扩展名集合、媒体解析能力、下载格式规范化和标签写入分支;当前源码明确只对 MP3 和 FLAC 做标签写入,其它格式保持原样。若要实现真正的云盘同步,则需要新增可验证的云端认证、文件列表、上传/下载状态及冲突处理代码;当前已读实现没有这些接口,因此不应仅通过 MusicDownloadPlugin 的远程 URL 下载推导出同步能力。
若要改造成多任务下载,应将 activeId、暂停和取消状态从静态共享变量改为按任务 ID 管理,并重新设计事件路由、临时文件生命周期和保存顺序。该建议是基于源码中“同一时刻只会有一个下载在跑”的明确约束,而不是当前实现已经支持的行为。
Related Links
对于前端目录树解析、下载队列和云端服务协议,请参阅相应的前端或服务端目录页面;本页仅记录已在 Android 原生源码中得到证实的本地媒体与下载边界。