Repository Wiki
ldx123000/Hydrogen-Music

播放运行时与跨端生命周期

本页说明 Hydrogen Music 在 Android 端如何把 Capacitor/JavaScript 播放状态连接到原生媒体会话、通知栏控制和前台服务,并覆盖应用退出这一类由前端触发的生命周期操作。

Purpose and Scope

本页聚焦播放运行时的 Android 原生边界:MediaNotificationPlugin 如何创建并维护 MediaSession,如何接收通知栏、锁屏和媒体按键动作并回传前端,以及 MediaPlaybackService 如何通过前台服务保持媒体通知和播放进程的可用性。同时说明 AppControlPlugin.exitApp() 的退出语义,以及这些组件在 AndroidManifest.xml 中的声明和权限。

播放列表、音频解码器、前端播放器状态机、下载实现和本地音乐扫描不在本页展开;这些属于独立的业务或数据能力。由于当前可见源码材料只覆盖了原生生命周期边界,本页不会推断未读取的前端调用方、具体音频引擎或完整通知构建细节。

Overview

Android 端的播放运行时采用“Capacitor 插件负责媒体状态与事件、Service 负责前台存活”的分工:

  • MediaNotificationPlugin 暴露 MediaNotification 插件,接收 show、setPlaying、setProgress 等 JS 调用,并维护标题、艺术家、专辑、曲目标识、时长、位置和播放状态。
  • 插件在 load() 中创建 Android MediaSession,注册 BroadcastReceiver,并将播放、暂停、上一首、下一首等动作通过 notifyListeners("mediaAction", data, true) 送回前端。
  • MediaPlaybackService 不自行创建通知;它读取同进程中由插件写入的静态 currentNotification,在 onStartCommand() 中调用 startForeground(),从而让系统将应用视为正在进行媒体播放。
  • 服务返回 START_STICKY,但停止时在 onDestroy() 中显式移除前台通知;这避免清空播放后媒体卡片仍由前台服务粘住。
  • AppControlPlugin 将退出决策留给前端,实际在 UI 线程调用 finishAndRemoveTask(),并在失败时退回 finish()。

Architecture

Loading diagram...

Sources:

图中的关系直接对应已读取的原生实现:插件持有 MediaSession 和 BroadcastReceiver,服务只接管前台通知生命周期;两者通过同进程静态 MediaPlaybackService.currentNotification 衔接。AndroidManifest.xml 将服务声明为 foregroundServiceType="mediaPlayback",并声明网络、通知和媒体前台服务权限。

设计边界与原因

通知功能被设计成“增强能力”而不是 App 启动的硬依赖。MediaNotificationPlugin.load() 对初始化整体捕获 Throwable,失败时只记录错误;这意味着 WebView 或 Android 媒体 API 差异不会直接阻止应用启动。相反,一旦用户实际播放,前台服务承担的是系统生命周期职责:普通 NotificationManager.notify() 不足以保证后台进程和媒体卡片持续存在,因此服务使用 startForeground() 注册当前通知。

核心生命周期与控制流

1. 插件加载阶段

MediaNotificationPlugin.load() 首先取得插件上下文;在 Android 5.0(LOLLIPOP)及以上创建名为 HydrogenMusic 的 MediaSession,安装回调并激活会话。回调把系统媒体操作转换成插件内部的 emit() 调用:onPlay()、onPause()、onSkipToNext() 和 onSkipToPrevious() 分别映射为 play、pause、next 和 previous。

随后插件设置 FLAG_HANDLES_MEDIA_BUTTONS 与 FLAG_HANDLES_TRANSPORT_CONTROLS。这一步不是装饰性配置:源码注释明确指出,若不声明能力,通知栏按钮和媒体/蓝牙按键不会被转交给该会话。最后,插件注册四个显式 action 的 BroadcastReceiver;Android 13 及以上使用 Context.RECEIVER_NOT_EXPORTED,旧版本使用兼容的两参数重载。

2. 显示或刷新媒体通知

show(PluginCall) 读取标题、艺术家、专辑和 playing 状态;曲目变化时重置 positionMs 和 metadataDurationMs,避免系统控件继续使用上一首歌的播放位置。时长通过私有 readLong() 读取,而不是直接假设 Capacitor 桥返回 Java Long:实现兼容 Number 和可解析字符串,并在异常时使用 fallback。

封面 URL 变化时调用 loadCover(nextCover),之后设置 visible = true、调用 updateNotification() 并 resolve 调用。若过程出现异常,插件使用 call.reject("显示媒体通知失败: ...") 将失败返回给 JS,而不是吞掉调用结果。

setPlaying(PluginCall) 只更新播放布尔值;只有通知已可见时才刷新原生通知。setProgress(PluginCall) 将位置规范化为不小于零的值,并在不可见、没有 MediaSession 或系统版本低于 Lollipop 时直接 resolve。这种短路使“进度推送”可以由前端周期性调用,而在通知尚未建立时不会产生额外的原生错误。

3. 前台服务阶段

服务的 onStartCommand() 不读取 Intent 参数,也不创建通知。它检查静态 currentNotification 是否为空;有通知时用固定的 MediaNotificationPlugin.NOTIFICATION_ID 调用 startForeground(),随后返回 START_STICKY。因此,通知构建责任留在插件,服务只承担系统要求的前台托管责任。

服务是非绑定服务:onBind() 固定返回 null。销毁时按 API 版本选择 stopForeground(STOP_FOREGROUND_REMOVE) 或旧版的 stopForeground(true),确保前台服务注册的通知随服务移除。startForeground() 和清理过程都捕获 Throwable 并写入标准错误日志,说明实现优先保证宿主生命周期不因通知操作异常而崩溃。

Loading diagram...

Source: MediaNotificationPlugin.java

Source: MediaPlaybackService.java

原生入口与系统声明

AndroidManifest.xml 将 MainActivity 设置为 singleTask、竖屏并允许多项配置变化;这为宿主 Activity 的生命周期提供了稳定入口。媒体服务声明为非导出(exported="false"),并标记 foregroundServiceType="mediaPlayback",使其只由应用自身使用,同时满足 Android 14+ 对媒体前台服务类型的声明要求。

相关权限包括:

权限或声明源码用途
android.permission.INTERNET封面 URL 等网络访问所需;具体网络加载实现位于插件中。
android.permission.POST_NOTIFICATIONSAndroid 13+ 通知权限声明。
android.permission.FOREGROUND_SERVICE使用前台服务的基础权限。
android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK媒体播放类型的前台服务权限。
foregroundServiceType="mediaPlayback"将 MediaPlaybackService 标识为媒体播放前台服务。
xml
1<service 2 android:name=".MediaPlaybackService" 3 android:enabled="true" 4 android:exported="false" 5 android:foregroundServiceType="mediaPlayback" /> 6 7<uses-permission android:name="android.permission.INTERNET" /> 8<uses-permission android:name="android.permission.POST_NOTIFICATIONS" /> 9<uses-permission android:name="android.permission.FOREGROUND_SERVICE" /> 10<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK" />

Source: AndroidManifest.xml

使用示例

从前端显示媒体通知

插件注释给出的 JS 合约是 MediaNotification.show({title,artist,album,coverUrl,playing})、setPlaying({playing}) 和 hide()。当前读取范围中没有前端调用文件,因此下面只引用原生侧实际处理的 Capacitor 参数读取代码,不虚构前端调用方。

java
1@PluginMethod 2public void show(PluginCall call) { 3 try { 4 title = call.getString("title", ""); 5 artist = call.getString("artist", ""); 6 album = call.getString("album", ""); 7 playing = Boolean.TRUE.equals(call.getBoolean("playing", Boolean.FALSE)); 8 String nextMediaId = call.getString("mediaId", ""); 9 if (nextMediaId != null && !nextMediaId.isEmpty() && !nextMediaId.equals(mediaId)) { 10 positionMs = 0; 11 metadataDurationMs = 0; 12 mediaId = nextMediaId; 13 } 14 long nextDuration = readLong(call, "duration", -1L); 15 if (nextDuration > 0) durationMs = nextDuration; 16 visible = true; 17 updateNotification(); 18 call.resolve(); 19 } catch (Throwable error) { 20 call.reject("显示媒体通知失败: " + error.getMessage()); 21 } 22}

Source: MediaNotificationPlugin.java

回传后台媒体动作

通知按钮和媒体按键最终都会进入 emit()。keepAlive=true 是关键行为:源码明确要求应用退到后台时仍能把动作交回前端。

java
1private void emit(String action) { 2 try { 3 System.out.println("[MediaNotification] 收到动作 -> " + action); 4 JSObject data = new JSObject(); 5 data.put("action", action); 6 notifyListeners("mediaAction", data, true); 7 System.out.println("[MediaNotification] 已回传前端: " + action); 8 } catch (Throwable error) { 9 System.err.println("[MediaNotification] 事件回传失败: " + error.getMessage()); 10 } 11}

Source: MediaNotificationPlugin.java

前台服务挂载与销毁

java
1@Override 2public int onStartCommand(Intent intent, int flags, int startId) { 3 try { 4 if (currentNotification != null) { 5 startForeground(MediaNotificationPlugin.NOTIFICATION_ID, currentNotification); 6 } 7 } catch (Throwable error) { 8 System.err.println("[MediaPlaybackService] startForeground 失败: " + error.getMessage()); 9 } 10 return START_STICKY; 11} 12 13@Override 14public void onDestroy() { 15 try { 16 if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { 17 stopForeground(STOP_FOREGROUND_REMOVE); 18 } else { 19 stopForeground(true); 20 } 21 } catch (Throwable error) { 22 System.err.println("[MediaPlaybackService] 摘除前台通知失败: " + error.getMessage()); 23 } 24 super.onDestroy(); 25}

Source: MediaPlaybackService.java

Source: MediaPlaybackService.java

API 参考

MediaNotificationPlugin.show(PluginCall call): void

显示或刷新媒体通知。读取 title、artist、album、playing、mediaId、duration 和 coverUrl;新 mediaId 会将位置归零;成功后 resolve,异常时 reject。源码未声明 Java 返回值以外的 checked exception,内部以 Throwable 兜底。

MediaNotificationPlugin.setPlaying(PluginCall call): void

读取 playing 布尔值并更新通知。若当前 visible 为 false,只更新状态而不创建通知;成功 resolve,异常 reject。

MediaNotificationPlugin.setProgress(PluginCall call): void

读取毫秒级 position 与可选 duration。负位置归零;当通知不可见、会话为空或系统低于 Android 5.0 时直接成功返回。源码注释说明该方法用于前端按秒级节流持续推送,从而让 PlaybackState 的位置和速度可用于系统媒体卡片。

MediaNotificationPlugin.load(): void

初始化 MediaSession 与动作接收器。媒体 API 初始化和接收器注册异常会被记录,不向外抛出,以保证通知增强能力不会阻断 App 启动。

MediaPlaybackService.onStartCommand(Intent, int, int): int

若 currentNotification 非空,以固定通知 ID 8801 调用 startForeground(),并始终返回 START_STICKY。Intent、flags 和 startId 当前不参与分支判断。

MediaPlaybackService.onDestroy(): void

按系统版本移除前台状态和通知,然后调用 super.onDestroy()。

AppControlPlugin.exitApp(PluginCall call): void

先 resolve Capacitor 调用,再切换到 UI 线程执行 finishAndRemoveTask();如果该操作失败,退回 finish()。退出是当前 Activity/任务的关闭,不应被描述为停止音频引擎,因为读取到的实现没有直接操作播放器或服务停止逻辑。

退出与跨端生命周期

AppControlPlugin 的职责很窄:它没有引入官方 @capacitor/app,而是为前端提供一个确定的 AppControl.exitApp() 原生入口。先 resolve 再异步结束 Activity,避免前端调用因 Activity 关闭而无法收到完成响应。finishAndRemoveTask() 优先移除任务;兼容性异常时使用 finish(),因此最坏情况下仍能关闭当前 Activity。

java
1@PluginMethod 2public void exitApp(PluginCall call) { 3 call.resolve(); 4 getActivity().runOnUiThread(() -> { 5 try { 6 getActivity().finishAndRemoveTask(); 7 } catch (Throwable ignored) { 8 getActivity().finish(); 9 } 10 }); 11}

Source: AppControlPlugin.java

数据与状态关系

当前源码显示,通知运行时状态集中在 MediaNotificationPlugin 的实例字段中,而前台服务只持有一份静态 Notification 引用。关键状态包括:

  • 内容:title、artist、album、mediaId、coverUrlLoaded。
  • 播放进度:durationMs、positionMs。
  • 渲染优化:metadataDurationMs,用于避免每秒重建带位图的 metadata。
  • 可见性:playing 和 visible。
  • 系统桥接:mediaSession、actionReceiver、单线程 executor。
Loading diagram...

Sources:

失败模式、边界条件与并发

已实现的失败隔离

  1. MediaSession、接收器注册和通知能力初始化统一捕获 Throwable,失败只输出错误日志;这把媒体通知从启动关键路径中隔离出来。
  2. show、setPlaying、setProgress 对 Capacitor 调用异常使用 call.reject() 或直接 resolve,避免异常穿透到 JS 桥。
  3. emit 回传失败只记录错误,不让系统广播回调崩溃。
  4. startForeground 与 stopForeground 都有独立兜底日志,服务仍按既定生命周期返回或调用父类。
  5. Android 版本差异显式处理:Lollipop 保护 MediaSession,Android 13 保护 receiver 注册,Nougat 保护 stopForeground 重载。

边界值

  • readLong() 对缺失值使用 fallback;duration 只有大于 0 时才覆盖当前时长。
  • setProgress() 将负位置改为 0。
  • mediaId 非空且发生变化才触发切歌重置;相同或空标识不会重置位置。
  • setPlaying() 在通知不可见时不刷新系统通知。
  • onStartCommand() 在 currentNotification == null 时不会调用 startForeground(),但仍返回 START_STICKY。源码未显示谁在何时填充该静态字段,调用顺序应由未读取的完整通知构建流程保证。

并发与性能

插件显式创建 Executors.newSingleThreadExecutor(),表明封面或通知相关后台工作采用单线程执行器;当前读取范围不足以确认其完整提交、关闭和异常处理策略,因此不能进一步推断任务调度语义。源码同时维护 metadataDurationMs,避免每秒因进度刷新而重建包含位图的 metadata;这减少系统侧刷新和封面重复处理。

跨组件共享的 currentNotification 是静态字段,依赖“插件和服务在同一进程”的前提。源码没有看到同步锁或 volatile 声明,因此多线程可见性和服务启动时序应作为运维排查点;本文不将其推断为线程安全设计。

配置与运维要点

配置项类型当前值作用
MediaNotificationPlugin.CHANNEL_IDStringhydrogen_music_playback通知渠道标识。
MediaNotificationPlugin.NOTIFICATION_IDint8801前台服务使用的通知 ID。
android:foregroundServiceTypeManifest 属性mediaPlayback声明媒体播放前台服务类型。
android:exported(服务)Manifest 属性false禁止其他应用直接导出并启动该服务。
android:usesCleartextTrafficManifest 属性true允许应用明文流量;该设置来自 Manifest,但当前材料没有证明具体播放或封面请求是否使用明文 URL。

排查通知不出现或按钮无响应时,应按以下顺序核对:权限是否满足、MediaNotificationPlugin.load() 是否成功、MediaSession 是否 active、通知是否已经使 visible=true、currentNotification 是否在服务启动前被写入,以及服务是否确实进入 onStartCommand()。排查通知残留时,应确认走到了 MediaPlaybackService.onDestroy() 的 stopForeground(...REMOVE) 分支,而不能只调用普通通知取消。

Extension Points

扩展媒体动作时,需要同时修改系统入口和前端事件语义:在 MediaSession.Callback 或 BroadcastReceiver 中增加实际 action 映射,再通过 emit() 保持统一的 mediaAction 事件回传。扩展通知内容时,应沿用 show() 对曲目变化、时长转换和封面 URL 变化的边界处理;不要把每秒进度更新设计成每秒重建完整 metadata,因为现有字段 metadataDurationMs 已明确承担该优化。

修改服务时,应保持服务与通知插件的职责分离:服务负责 startForeground/stopForeground,插件负责通知和媒体会话。若需要改变停止策略,还必须重新验证前台服务注册通知的移除行为。

相关链接

测试文件、完整前端调用点以及音频播放器实现未在本页读取范围内发现,因此相关保证和集成细节应在对应的播放器或测试目录文档中补充。

Sources

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