一、项目介绍
1. 背景与意义
在许多智能家居、车载系统、穿戴设备或自定义快捷工具中,我们往往需要跨应用地控制当前系统正在播放的音乐,而不是仅限于自己应用内的播放器。比如:
-
车载中控:接收方向盘“播放/暂停”按键指令,控制 Spotify、网易云音乐等第三方播放器
-
自定义桌面 Widget:在桌面小部件上放置播放/暂停按钮,操作系统正在播放的音乐
-
语音助手:“暂停音乐”指令,让系统当前活跃的音乐应用暂停播放
-
快捷手势:通过悬浮窗或手势识别触发播放/暂停,而无需打开音乐 App
要实现跨应用媒体控制,需要借助 Android 的**媒体会话(MediaSession)框架或直接发送媒体按键(Media Key)**事件。本项目将深度剖析主流方案,并提供完整可复用的 ThirdPartyMusicController
组件。
2. 功能目标
-
检测并连接到当前系统中所有活跃的媒体会话(第三方播放器)
-
提供统一接口
play()
、pause()
、toggle()
、next()
、previous()
-
支持 Android 5.0+(Lollipop)及以上系统
-
自动处理会话权限(Notification Listener 权限或 MediaSessionManager 权限)
-
兼容通过 MediaSessionManager + NotificationListenerService 或 AudioManager.sendMediaKeyEvent 两种方案
-
易于集成:应用只需初始化一次,即可在任意地方调用控制方法
二、相关知识
1. 媒体会话(MediaSession)架构
Android 5.0 引入了 MediaSession
API,用于统一管理媒体播放的状态和控制接口。核心组件:
-
MediaSession:媒体应用内部用于发布播放状态与接收控制命令
-
MediaController:外部应用通过它来连接并控制某个 MediaSession
-
MediaSessionManager:系统级管理器,列出当前所有激活的 MediaSession(需要 Notification Listener 权限或绑定)
媒体会话流程:
-
播放器(Spotify)创建并激活一个
MediaSession
,并通过MediaSession.setCallback()
接收控制命令 -
客户端(我们的应用)通过
MediaSessionManager.getActiveSessions()
获取所有活跃会话 -
用
MediaController
连接到特定MediaSession
,调用controller.getTransportControls().pause()
等方法发送命令
2. 媒体按键事件(Media Key Button)
Android 提供了对物理或虚拟媒体按键的统一分发。可以通过两种方式模拟媒体按键:
-
AudioManager.sendMediaKeyEvent()(API ≤ 27):直接让系统广播媒体键 Intent
-
Instrumentation 或 InputManager:程序内部生成 KeyEvent 并注入系统
优点:无需额外权限即可模拟“播放/暂停”按键,系统会把事件分发给当前活跃的媒体会话。
缺点:在某些 Android 10+ 设备上可能被限制(需获得 MEDIA_CONTENT_CONTROL
权限或使用系统签名)。
3. NotificationListenerService 权限
为了获取 MediaSessionManager.getActiveSessions()
列表,应用必须注册 NotificationListenerService
并在系统设置中授予“通知读取”权限。这样才能看到其他应用发布的媒体通知并获得对应的会话令牌。
4. 兼容性与权限
-
API21+:MediaSessionManager 可用,但需先绑定 NotificationListener
-
API19-20:MediaSessionManager 部分功能受限,只能通过发送媒体键模拟
-
Android 10+:部分厂商加固,需要兼顾 sendMediaKeyEvent 兼容性或采用官方推荐 MediaSession 接口
三、实现思路
方案一:MediaSessionManager + MediaController
-
申请并绑定 NotificationListenerService
-
在 Service 中监听
onNotificationPosted
,并在onListenerConnected()
获取初次会话列表:
val mgr = getSystemService(Context.MEDIA_SESSION_SERVICE) as MediaSessionManager
activeSessions = mgr.getActiveSessions(ComponentName(this, MyNotificationListener::class.java))
-
过滤出支持播放控制的会话(
PlaybackState.STATE_PLAYING
或TransportControls
支持 play/pause) -
初始化
MediaController
列表,保存到单例ThirdPartyMusicController
-
对外暴露方法
play()
,pause()
,toggle()
,next()
,previous()
,内部依次调用所有 controllers
方案二:AudioManager.sendMediaKeyEvent
-
获取
AudioManager
:
val audioManager = context.getSystemService(Context.AUDIO_SERVICE) as AudioManager
模拟 KEYCODE_MEDIA_PLAY_PAUSE 按键:
val down = KeyEvent(KeyEvent.ACTION_DOWN, KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE)
val up = KeyEvent(KeyEvent.ACTION_UP, KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE)
audioManager.dispatchMediaKeyEvent(down)
audioManager.dispatchMediaKeyEvent(up)
-
对外暴露同样的接口,并在内部执行上述模拟按键
综合方案
-
优先使用方案一(MediaSessionManager),权限允许时获取最精准的控制;
-
否则回退到方案二(MediaKey模拟)保证在无通知权限的场景下也可控制;
-
使用策略模式封装两个实现,调用者无需关心底层细节。
四、环境与依赖
// app/build.gradle
apply plugin: 'com.android.application'
apply plugin: 'kotlin-android'
android {
compileSdkVersion 34
defaultConfig {
applicationId "com.example.thirdpartycontroller"
minSdkVersion 21
targetSdkVersion 34
}
buildFeatures { viewBinding true }
kotlinOptions { jvmTarget="1.8" }
}
dependencies {
implementation 'androidx.appcompat:appcompat:1.6.1'
implementation 'androidx.core:core-ktx:1.10.1'
}
五、整合代码
// =======================================================
// 文件: AndroidManifest.xml
// 描述: 注册 Activity、NotificationListenerService
// =======================================================
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="com.example.thirdpartycontroller">
<!-- 需要通知监听权限 -->
<uses-permission android:name="android.permission.BIND_NOTIFICATION_LISTENER_SERVICE"/>
<application
android:label="ThirdPartyController"
android:theme="@style/Theme.AppCompat.Light.NoActionBar">
<activity android:name=".MainActivity"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.MAIN"/>
<category android:name="android.intent.category.LAUNCHER"/>
</intent-filter>
</activity>
<service android:name=".MyNotificationListener"
android:permission="android.permission.BIND_NOTIFICATION_LISTENER_SERVICE"
android:exported="true">
<intent-filter>
<action android:name="android.service.notification.NotificationListenerService"/>
</intent-filter>
</service>
</application>
</manifest>
// =======================================================
// 文件: MainActivity.kt
// 描述: 演示界面:Play/Pause/Next/Prev 四个按钮
// =======================================================
package com.example.thirdpartycontroller
import android.content.Intent
import android.net.Uri
import android.os.Bundle
import android.provider.Settings
import android.widget.Toast
import androidx.appcompat.app.AppCompatActivity
import com.example.thirdpartycontroller.databinding.ActivityMainBinding
class MainActivity : AppCompatActivity() {
private lateinit var binding: ActivityMainBinding
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
binding = ActivityMainBinding.inflate(layoutInflater)
setContentView(binding.root)
// 检查是否已授权通知监听
if (!ThirdPartyMusicController.hasNotificationListenerPermission(this)) {
startActivity(Intent(Settings.ACTION_NOTIFICATION_LISTENER_SETTINGS))
Toast.makeText(this, "请授权“通知读取权限”以便控制播放器", Toast.LENGTH_LONG).show()
}
// 按钮事件
binding.btnPlay.setOnClickListener { ThirdPartyMusicController.play(this) }
binding.btnPause.setOnClickListener { ThirdPartyMusicController.pause(this) }
binding.btnToggle.setOnClickListener { ThirdPartyMusicController.toggle(this) }
binding.btnNext.setOnClickListener { ThirdPartyMusicController.next(this) }
binding.btnPrevious.setOnClickListener{ ThirdPartyMusicController.previous(this)}
}
}
// =======================================================
// 文件: ActivityMainBinding (res/layout/activity_main.xml)
// 描述: 包含五个按钮
// =======================================================
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:orientation="vertical"
android:gravity="center"
android:padding="24dp"
android:layout_width="match_parent"
android:layout_height="match_parent">
<Button android:id="@+id/btnPlay" android:layout_width="wrap_content" android:layout_height="wrap_content" android:text="Play"/>
<Button android:id="@+id/btnPause" android:layout_width="wrap_content" android:layout_height="wrap_content" android:text="Pause"/>
<Button android:id="@+id/btnToggle" android:layout_width="wrap_content" android:layout_height="wrap_content" android:text="Toggle"/>
<Button android:id="@+id/btnNext" android:layout_width="wrap_content" android:layout_height="wrap_content" android:text="Next"/>
<Button android:id="@+id/btnPrevious" android:layout_width="wrap_content" android:layout_height="wrap_content" android:text="Previous"/>
</LinearLayout>
// =======================================================
// 文件: MyNotificationListener.kt
// 描述: 通知监听服务,用于获取活跃 MediaSession
// =======================================================
package com.example.thirdpartycontroller
import android.annotation.SuppressLint
import android.content.ComponentName
import android.content.Context
import android.service.notification.NotificationListenerService
import android.service.notification.StatusBarNotification
import android.media.session.MediaController
import android.media.session.MediaSessionManager
@SuppressLint("OverrideAbstract")
class MyNotificationListener : NotificationListenerService() {
override fun onListenerConnected() {
super.onListenerConnected()
ThirdPartyMusicController.initController(applicationContext, this)
}
override fun onNotificationPosted(sbn: StatusBarNotification) {
// 新的媒体通知到来时,重新 init
if (sbn.isOngoing) {
ThirdPartyMusicController.initController(applicationContext, this)
}
}
}
// =======================================================
// 文件: ThirdPartyMusicController.kt
// 描述: 核心控制类,封装两种方案
// =======================================================
package com.example.thirdpartycontroller
import android.content.Context
import android.media.AudioManager
import android.media.session.MediaController
import android.media.session.MediaSessionManager
import android.os.Build
import android.view.KeyEvent
object ThirdPartyMusicController {
private var controllers: List<MediaController> = emptyList()
private var audioManager: AudioManager? = null
/** 初始化:绑定 NotificationListenerService 后调用 */
fun initController(ctx: Context, listener: NotificationListenerService) {
audioManager = ctx.getSystemService(Context.AUDIO_SERVICE) as AudioManager
// 尝试用 MediaSessionManager
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
val mgr = ctx.getSystemService(Context.MEDIA_SESSION_SERVICE) as MediaSessionManager
val comp = ComponentName(ctx, MyNotificationListener::class.java)
controllers = mgr.getActiveSessions(comp)
}
}
/** 检查通知监听权限 */
fun hasNotificationListenerPermission(ctx: Context): Boolean {
val enabled = android.provider.Settings.Secure.getString(
ctx.contentResolver, "enabled_notification_listeners"
)
val me = ctx.packageName + "/" + MyNotificationListener::class.java.canonicalName
return enabled != null && enabled.contains(me)
}
/** 播放 */
fun play(ctx: Context) = sendCommand(MediaCommand.PLAY, ctx)
/** 暂停 */
fun pause(ctx: Context) = sendCommand(MediaCommand.PAUSE, ctx)
/** 切换(播放/暂停) */
fun toggle(ctx: Context) = sendCommand(MediaCommand.TOGGLE, ctx)
/** 下一首 */
fun next(ctx: Context) = sendCommand(MediaCommand.NEXT, ctx)
/** 上一首 */
fun previous(ctx: Context) = sendCommand(MediaCommand.PREVIOUS, ctx)
private fun sendCommand(cmd: MediaCommand, ctx: Context) {
// 优先 MediaSession
if (controllers.isNotEmpty()) {
controllers.forEach { ctrl ->
when (cmd) {
MediaCommand.PLAY -> ctrl.transportControls.play()
MediaCommand.PAUSE -> ctrl.transportControls.pause()
MediaCommand.TOGGLE -> ctrl.transportControls.playPause()
MediaCommand.NEXT -> ctrl.transportControls.skipToNext()
MediaCommand.PREVIOUS -> ctrl.transportControls.skipToPrevious()
}
}
} else {
// 回退到媒体按键模拟
sendMediaKeyEvent(cmd, ctx)
}
}
private fun sendMediaKeyEvent(cmd: MediaCommand, ctx: Context) {
val am = audioManager ?: ctx.getSystemService(Context.AUDIO_SERVICE) as AudioManager
val key = when (cmd) {
MediaCommand.PLAY, MediaCommand.TOGGLE -> KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE
MediaCommand.PAUSE -> KeyEvent.KEYCODE_MEDIA_PAUSE
MediaCommand.NEXT -> KeyEvent.KEYCODE_MEDIA_NEXT
MediaCommand.PREVIOUS -> KeyEvent.KEYCODE_MEDIA_PREVIOUS
}
val down = KeyEvent(KeyEvent.ACTION_DOWN, key)
val up = KeyEvent(KeyEvent.ACTION_UP, key)
am.dispatchMediaKeyEvent(down)
am.dispatchMediaKeyEvent(up)
}
private enum class MediaCommand { PLAY, PAUSE, TOGGLE, NEXT, PREVIOUS }
}
六、代码解读
-
MyNotificationListener
-
继承
NotificationListenerService
,在onListenerConnected
与新媒体通知到来时调用ThirdPartyMusicController.initController()
,获取并缓存所有活跃的MediaController
。
-
-
ThirdPartyMusicController
-
单例封装两种控制方案:
-
方案一:MediaSessionManager
-
getActiveSessions()
返回当前系统所有发布媒体会话的MediaController
列表; -
调用
controller.transportControls
发送play()
、pause()
等指令。
-
-
方案二:AudioManager 媒体键发送
-
调用
AudioManager.dispatchMediaKeyEvent()
模拟媒体按键事件,保证在未授权通知监听时仍可控制。
-
-
-
对外统一方法:
play()
,pause()
,toggle()
,next()
,previous()
。
-
-
MainActivity
-
按钮点击时直接调用
ThirdPartyMusicController
,无需关注底层实现; -
首次启动时检查并引导用户开启“通知读取”权限以支持方案一。
-
-
权限与兼容
-
BIND_NOTIFICATION_LISTENER_SERVICE
在 Manifest 中注册; -
在 Android 设置中手动开启应用的“通知访问”权限;
-
API21+ 优先使用 MediaSession,API19-20 则退回媒体键。
-
七、性能与优化
-
会话刷新
-
当媒体播放器切换或应用被重启时,需在
onNotificationPosted
或定时任务中重新调用initController()
。
-
-
权限回退
-
若用户撤销通知监听权限,应实时检测
hasNotificationListenerPermission()
,并清空controllers
列表,回退到媒体键方案。
-
-
多会话冲突
-
系统中可能同时存在多个活跃会话(QQ 音乐、网易云、Spotify),可根据包名或 playbackState 过滤优先级最高的那一项。
-
八、项目总结与拓展
-
本文实现了跨应用的第三方音乐控制,兼顾精确控制(MediaSession)和极简兼容(MediaKey)。
-
通过统一的组件接口,业务层无需关心底层技术差异,极大提高了可维护性。
拓展方向
-
UI 反馈:检测当前是否正在播放(
controller.playbackState.state
),并更新按钮状态; -
其他命令:支持快进、快退、设置播放位置;
-
会话优先级:按最近使用、按包名白名单等方式选取要控制的主体应用;
-
可视化 Widget:把控制按钮做成桌面 Widget,实现桌面直接操作。
九、FAQ
Q1:为何需要 Notification Listener 权限?
A1:系统出于隐私安全考虑,将第三方应用的 MediaSession 与通知绑定。只有授权后,才能获得活跃会话列表。
Q2:AudioManager.sendMediaKeyEvent 是否可靠?
A2:在大多数 Android 设备上正常工作,但某些厂商在 Android10+ 对此做了限制。建议优先使用 MediaSession。
Q3:如何过滤只控制某个特定播放器?
A3:在 controllers
列表中,可按 controller.packageName
过滤,仅调用指定包名的会话。
Q4:MediaSessionManager 需要什么权限?
A4:需要“通知监听”权限,且仅在 API21+ 可用。API19-20 上 MediaSessionManager 功能受限。
Q5:如何处理音频焦点?
A5:在控制播放/暂停时,可结合 AudioManager.requestAudioFocus()
或 AudioFocusRequest
,确保焦点合理切换。