Repository Wiki
ldx123000/Hydrogen-Music

本地音乐库与云盘

本页说明 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

Loading diagram...

Source: MainActivity.java

Source: LocalMusicPlugin.java

Source: MusicDownloadPlugin.java

MainActivity.onCreate 在调用 super.onCreate 之前注册 LocalMusicPlugin 和 MusicDownloadPlugin,使 Capacitor bridge 能发现这两个插件。LocalMusicPlugin 负责“已存在的本地内容”和媒体元数据;MusicDownloadPlugin 负责“远程内容到本地文件”的转换,两者通过共享的 hydrogen_local_music 偏好存储和最终的本地媒体目录形成衔接。

设计边界与关键约束

  1. 授权边界由 Android 系统强制执行。 本地库不是扫描全盘,而是扫描用户选择并持久化授权的 tree URI。
  2. 前端契约优先于平台内部实现。 原生插件保持桌面端已有调用形状,降低 Android 特化逻辑对前端的侵入。
  3. 下载与 UI 调用解耦。 download 只负责接收参数、检查保存路径并启动后台线程;长耗时网络操作不阻塞插件调用线程。
  4. 媒体文件可用性优先。 MP3/FLAC 标签写入使用容错处理;标签写失败时仍保留原始音频。
  5. 云盘能力没有在已读 Android 源码中独立建模。 远程 URL 下载、封面 URL 和歌词文本是当前可验证的远程输入;不能据此推断云盘列表、同步或上传功能。

本地音乐目录授权流程

pickFolder 创建 ACTION_OPEN_DOCUMENT_TREE Intent,并同时请求读、写、持久化和前缀 URI 权限。用户取消时回调以 cancelled reject;结果没有 URI 时以 no-uri reject。得到 URI 后,插件尝试调用 takePersistableUriPermission,再将 URI 与显示名称保存到 hydrogen_local_music 偏好中。

java
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 和名称,并返回成功结果。

java
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。

java
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 调用,再在线程中执行真正下载。

Loading diagram...

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 会同时设置取消并清除暂停状态;下载循环随后根据标志停止或返回。扩展为多任务并发下载时,不能直接复用这组全局标志,否则一个任务的控制信号可能影响另一个任务。

java
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 清单与运行时能力

xml
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" 表明应用清单允许明文流量。媒体前台服务声明属于持续播放能力,保证后台媒体控制可用,但它不是本地扫描或云盘同步本身。

失败模式、边界条件与运维注意事项

  • 用户取消目录选择: pickFolderResult reject cancelled,调用方应把它视为用户主动退出,而不是系统故障。
  • 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 管理,并重新设计事件路由、临时文件生命周期和保存顺序。该建议是基于源码中“同一时刻只会有一个下载在跑”的明确约束,而不是当前实现已经支持的行为。

对于前端目录树解析、下载队列和云端服务协议,请参阅相应的前端或服务端目录页面;本页仅记录已在 Android 原生源码中得到证实的本地媒体与下载边界。

Sources

(4 files)
apps/android/android/app/src/main
apps/android/android/app/src/main/java/com/hydrogen/music