Android系统线控和歌曲信息屏显的那点事

目前Android系统中主流的音乐播放器都支持线控的功能,线控设备包括有线耳机和蓝牙耳机或蓝牙车机,当不方便操作手机的时候可以通过线控来控制音乐的播放暂停以及切歌。

同时当音乐播放的时候部分手机(如小米)会在系统的锁屏页面上展示各种歌曲信息,如歌曲名,歌手名,专辑图片甚至歌词,同时还可以提供一些播放控制的操作。

这些都是如何实现的呢?其中是否有坑?下面慢慢道来。

AudioManager配合RemoteControlClient

在Android 5.0之前的版本中,Android推荐使用AudioManager的一系列功能来实现线控和锁屏信息显示功能。实现线控很简单,通过下面代码即可。

    mAudioManager = (AudioManager) ctx.getSystemService(Context.AUDIO_SERVICE);

    mComponentName = new ComponentName(mContext.getPackageName(), MediaButtonReceiver.class.getName());
    mContext.getPackageManager().setComponentEnabledSetting(mComponentName,
            PackageManager.COMPONENT_ENABLED_STATE_ENABLED, PackageManager.DONT_KILL_APP);

    Intent mediaButtonIntent = new Intent(Intent.ACTION_MEDIA_BUTTON);
    mediaButtonIntent.setComponent(mComponentName);
    mPendingIntent = PendingIntent.getBroadcast(mContext, 0, mediaButtonIntent, PendingIntent.FLAG_CANCEL_CURRENT);
    mAudioManager.registerMediaButtonEventReceiver(mComponentName);

registerMediaButtonEventReceiver是抢占线控焦点的方法,Android系统同时只能为一个应用发送线控信息,只有抢占到线控焦点后才能让线控为自己的app所用。

MediaButtonReceiver是一个BroadcastReceiver,用来处理接收到的线控信息以实现具体的音乐控制,这个BroadcastReceiver是需要在Manifest.xml中注册的。

    <receiver
        android:name=".MediaButtonReceiver"
        android:enabled="true"
        android:exported="false">
        <intent-filter android:priority="2147483647">
            <action android:name="android.intent.action.MEDIA_BUTTON"/>
        </intent-filter>
    </receiver>  

监听android.intent.action.MEDIA_BUTTON这个action,通过intent.getParcelableExtra(Intent.EXTRA_KEY_EVENT)来获取具体的KeyEvent,从KeyEvent中通过event.getKeyCode()取出keycode,判断是哪种按键信息,如KeyEvent.KEYCODE_HEADSETHOOKKeyEvent.KEYCODE_MEDIA_PLAY_PAUSEKeyEvent.KEY_MEDIA_PLAYKeyEvent.KEY_MEDIA_PAUSE等等;通过event.getAction()取出按键操作进行判断是何种行为,如KeyEvent.ACTION_UPKeyEvent.ACTION_DOWN,然后进行相应的操作。

当使用完毕后需要通过mAudioManager.unregisterMediaButtonEventReceiver(mComponentName)解注册。

实现线控功能后要想再显示锁屏信息,就要用到RemoteControlClient了,这也是Android5.0之前推荐的系统API。首先需要初始化RemoteControlClient并向AudioManager进行注册

    mRemoteControlClient = new RemoteControlClient(mPendingIntent);
    mAudioManager.registerRemoteControlClient(mRemoteControlClient);

这里的mPendingIntent在上面注册线控时使用过,这里再次使用是为了共用MediaButtonReceiver来接收处理来自系统锁屏页面的音乐控制操作。但是能够接收哪些按键信息是需要特别指定的。

    int flags = RemoteControlClient.FLAG_KEY_MEDIA_PREVIOUS
             | RemoteControlClient.FLAG_KEY_MEDIA_NEXT
             | RemoteControlClient.FLAG_KEY_MEDIA_PLAY
             | RemoteControlClient.FLAG_KEY_MEDIA_PAUSE
             | RemoteControlClient.FLAG_KEY_MEDIA_PLAY_PAUSE
             | RemoteControlClient.FLAG_KEY_MEDIA_RATING;
    mRemoteControlClient.setTransportControlFlags(flags);  

接下来要做的就是发送歌曲信息了。

    //设置当前播放状态和播放时间
    mRemoteControlClient.setPlaybackState(getPlayState(), MusicUtil.getCursongTime() * 1000, 1.0f);

    //设置歌曲信息,包括歌曲名,歌手名,专辑名,歌曲时长,专辑图等
    MetadataEditor md = mRemoteControlClient.editMetadata(true);
    md.putString(MediaMetadataRetriever.METADATA_KEY_TITLE, song.getName());
    md.putString(MediaMetadataRetriever.METADATA_KEY_ARTIST, song.getSinger());
    md.putString(MediaMetadataRetriever.METADATA_KEY_ALBUM, song.getAlbum());
    md.putLong(MediaMetadataRetriever.METADATA_KEY_DURATION, song.getDuration());
    md.putBitmap(MetadataEditor.BITMAP_KEY_ARTWORK, mAlbumCover);
    //适配MIUI系统锁屏歌词,MIUI自定义1000为歌词信息
    md.putString(1000, mLyric.toLrcString());
    md.apply();  

使用完毕后需要通过mAudioManager.unregisterRemoteControlClient(mRemoteControlClient)解注册RemoteControlClient。

MediaSession

Android5.0及以后的版本RemoteControlClient被Deprecate,Android推荐使用最新的MediaSession来统一管理线控和歌曲信息展示,这样一来,比使用AudioManager加RemoteControlClient要方便的多。由于MediaSession只有Android5.0以后才提供,要适配Android5.0之前的版本还是要兼容一下RemoteControlClient,但是我们惊喜的发现,support V4包已经加入了MediaSessionCompat的API,使用方法和MediaSession一样,这样我们就可以完全摒弃RemoteControlClient。只是它是非线程安全的,这点下面我们会讲到。首先看一下初始化过程。

    //这里同样要指明相应的MediaBottonReceiver,用来接收处理线控信息
    //Android5.0之前的版本线控信息直接通过BroadcastReceiver处理
    mComponentName = new ComponentName(mContext.getPackageName(), MediaButtonReceiver.class.getName());
    mContext.getPackageManager().setComponentEnabledSetting(mComponentName,
            PackageManager.COMPONENT_ENABLED_STATE_ENABLED, PackageManager.DONT_KILL_APP);

    Intent mediaButtonIntent = new Intent(Intent.ACTION_MEDIA_BUTTON);
    mediaButtonIntent.setComponent(mComponentName);
    mPendingIntent = PendingIntent.getBroadcast(mContext, 0, mediaButtonIntent, PendingIntent.FLAG_CANCEL_CURRENT);

    //由于非线程安全,这里要把所有的事件都放到主线程中处理,使用这个handler保证都处于主线程
    mHandler = new Handler(Looper.getMainLooper());

    mMediaSession = new MediaSessionCompat(mContext, "mbr", mComponentName, null);
    //指明支持的按键信息类型
    mMediaSession.setFlags(MediaSessionCompat.FLAG_HANDLES_MEDIA_BUTTONS |
            MediaSessionCompat.FLAG_HANDLES_TRANSPORT_CONTROLS);
    mMediaSession.setMediaButtonReceiver(mPendingIntent);

    //这里指定可以接收的来自锁屏页面的按键信息
    PlaybackStateCompat state = new PlaybackStateCompat.Builder().setActions(
            PlaybackStateCompat.ACTION_FAST_FORWARD | PlaybackStateCompat.ACTION_PAUSE | PlaybackStateCompat.ACTION_PLAY
                    | PlaybackStateCompat.ACTION_PLAY_PAUSE | PlaybackStateCompat.ACTION_SKIP_TO_NEXT
                    | PlaybackStateCompat.ACTION_SKIP_TO_PREVIOUS | PlaybackStateCompat.ACTION_STOP).build();
    mMediaSession.setPlaybackState(state);

    //在Android5.0及以后的版本中线控信息在这里处理
    mMediaSession.setCallback(new MediaSessionCompat.Callback() {
        @Override
        public boolean onMediaButtonEvent(Intent intent) {
            //通过Callback返回按键信息,为复用MediaButtonReceiver,直接调用它的onReceive()方法
            MediaButtonReceiver mMediaButtonReceiver = new MediaButtonReceiver();
            mMediaButtonReceiver.onReceive(mContext, intent);
            return true;
        }
    }, mHandler);    //把mHandler当做参数传入,保证按键事件处理在主线程

    //把MediaSession置为active,这样才能开始接收各种信息
    if (!mMediaSession.isActive()) {
        mMediaSession.setActive(true);
    }  

这里需要注意的两个问题: 1.从上面的初始化过程中可以看到,Android5.0之前和之后的版本处理按键信息的地方是不同的,为了适配所有系统版本,我们把两种注册方式都加入。 2.由于MediaSessionCompat为非线程安全,要求所有对MediaSessionCompat的调用都处于同一线程。然而Android5.0系统中提供的MediaSession确是线程安全的,看起来为了适配低版本还是要有所牺牲的。

初始化过后线控就可以使用了。接下来处理屏显信息的发送。

    //同步当前的播放状态和播放时间
    PlaybackStateCompat.Builder stateBuilder = new PlaybackStateCompat.Builder();
    stateBuilder.setState(getPlayState(), getCurrentPlayTime(), 1.0f);
    mMediaSession.setPlaybackState(stateBuilder.build());

    //同步歌曲信息
    MediaMetadataCompat.Builder md = new MediaMetadataCompat.Builder();
    md.putString(MediaMetadataCompat.METADATA_KEY_TITLE, song.getName());
    md.putString(MediaMetadataCompat.METADATA_KEY_ARTIST, song.getSinger());
    md.putString(MediaMetadataCompat.METADATA_KEY_ALBUM, song.getAlbum());
    md.putLong(MediaMetadataCompat.METADATA_KEY_DURATION, song.getDuration());
    md.putBitmap(MediaMetadataCompat.METADATA_KEY_ART, mAlbumCover);
    mMediaSession.setMetadata(md.build());  

使用完毕后要通过mMediaSession.release()释放这个MediaSession。

遇到的坑

1.线控焦点的抢占

线控焦点是需要抢的!!!由于系统同时只会允许一个APP占用线控焦点,所以如果你抢占线控焦点后其他APP又去抢占,那我们的APP就无法收到线控控制信息了。这个时候我们就需要在合适的时机把线控焦点抢回来。合适的夺抢时机有两个:

(1) 当歌曲发起播放或从暂停恢复播放的时候去抢一下线控焦点,因为我们要播放音乐了,这个时候抢占无可厚非。 (2) Android系统建议线控焦点和音频焦点要同时使用,即抢占音频焦点的同时也要抢占线控焦点,音频焦点的丢失基本上也意味着线控焦点的丢失。不同于线控焦点,音频焦点的丢失和恢复都是可以被系统通知的,所以我们就可以根据音频焦点的状态来判断线控焦点的状态,当音频焦点丢失的时候不做任何操作,而当音频焦点恢复的时候就是我们重新抢占线控焦点的时候了。

2.双锁屏的问题

一些音乐APP如QQ音乐和轻听等会自定义自己的锁屏页面,这个锁屏页面是可以通过开关来打开后关闭的,这时候问题来了,为了避免同时出现两个锁屏页面,打开自定义锁屏的时候需要关闭系统锁屏页面,关闭自定义锁屏的时候需要重新打开系统锁屏,那如何收放自如的开关系统的锁屏页面呢?

大家也许注意到,上面再初始化MediaSessionCompat的时候调用了mMediaSession.setActive(true)开激活它,那是不是调用mMediaSession.setActive(false)一下就可以关掉系统锁屏了呢?试了一下,果然没有问题,锁屏页面可以随着setActive方法自由开启和关闭,但是发现一个问题,关闭锁屏后,线控也失效了。。。原因很简单,线控和屏显用的都是这一套MediaSession,线控自然也会随这个setActive方法开启和关闭。后来又试过mMediaSession.setActive(false)后再调用mMediaSession.setActive(true)把线控启动回来,但这时屏显也会跟着一起回来,而显示的是之前的歌曲信息。

这时就需要下猛药,直接调用mMediaSession.release()来关闭当前的MediaSessionCompat,然后马上重新初始化一个新的MediaSessionCompat,由于之前的屏显信息已经销毁掉,新的MediaSessionCompat就不会重新展示屏显,同时由于重新注册线控,可以重新接收线控信息。

3.MIUI的锁屏歌词显示

在介绍MediaSessionCompat发送屏显信息的时候,貌似没有跟RemoteControlClient一样发送适配MIUI屏显的歌词信息,这是因为构造屏显信息结构体的时候,MediaSessionCompat所使用的MediaMetadataCompat.Builder的putString()方法传递的Key是一个String,而RemoteControlClient所使用的MetadataEditor的putString()方法传递的Key是一个int,MIUI自定义的歌词item的Key就是一个int值1000,而把这个“1000”转为String传给MediaMetadataCompat.Builder显然不合适,那该怎么办呢?

经过和MIUI开发人员的确实得知MIUI并没有为MediaSession适配歌词item后,我们只能自己寻找出路。通过查看RemoteControlClient的源码发现它有一个私有成员就是MediaSession!!原来MediaSession本来就是存在的,并非是Android5.0后新出来的API,只不过之前都是通过RemoteControlClient进行了封装,了解了这一点后看到了一线希望,两种方法的屏显信息结构体MediaMetadataCompat.Builder和MetadataEditor是否也有什么关联呢?查看RemoteControlClient用到的MetadataEditor源码,发现有这么一段:

    public synchronized MetadataEditor putString(int key, String value)
            throws IllegalArgumentException {
        super.putString(key, value);
        if (mMetadataBuilder != null) {
            // MediaMetadata supports all the same fields as MetadataEditor
            String metadataKey = MediaMetadata.getKeyFromMetadataEditorKey(key);
            // But just in case, don't add things we don't understand
            if (metadataKey != null) {
                mMetadataBuilder.putText(metadataKey, value);
            }
        }

        return this;
    }  

在putString方法中,int类型的key值会通过MediaMetadata.getKeyFromMetadataEditorKey(key)方法转换为String型,然后再放到一个mMetadataBuilder中,而这个mMetadataBuilder正是MediaSessionCompat所用到的MediaMetadataCompat.Builder类型!!这么看来,int类型的key值是有办法映射到String类型的,只需要通过MediaMetadata.getKeyFromMetadataEditorKey(key)方法。继续查看源码发现这个转换方法被hide掉,无法直接调用,只能反射了。

    MediaMetadataCompat.Builder md = new MediaMetadataCompat.Builder();
    try {
        if (mLyric != null && Util4Common.isSupportMIUILockScreen()) {
            Class<?> MediaMetadataInstance = null;
            try {
                MediaMetadataInstance = Class.forName(MediaMetadata.class.getName());
            } catch (Exception e) {
                e.printStackTrace();
            }
            try {
                Method method = MediaMetadataInstance.getMethod("getKeyFromMetadataEditorKey", int.class);
                String key = (String) method.invoke(null, 1000);

                md.putString(key, mLyric.toLrcString());
            } catch (Exception e) {
                e.printStackTrace();
            }
        }
    } catch (Exception e) {
        e.printStackTrace();
    }
    mMediaSession.setMetadata(md.build());  

经测试完美解决问题。

总结

通过这一段时间的学习总结,MediaSession的使用在音乐APP里可以有很多可扩展的想象,比如我们可以通过不断刷新Title来达到模拟显示滚动歌词的效果;不断刷新专辑图来做歌手写真轮播等等。同时,MediaSession也可以应用到基于TV的APP中,Android TV原生的Now Playing Card就是通过MediaSession来控制的,可以在Android TV的主界面显示目前正在播放的歌曲的歌曲名,歌手名,专辑图以及播放状态。然而MediaSession使用起来可能需要针对第三方厂商对Android Rom的修改做一些适配,也增加了它的使用成本。

原文发布于微信公众号 - QQ音乐技术团队(gh_287053a877e6)

原文发表时间:2016-10-24

本文参与腾讯云自媒体分享计划,欢迎正在阅读的你也加入,一起分享。

发表于

我来说两句

0 条评论
登录 后参与评论

相关文章

来自专栏何俊林

如何进行网络视频截图/获取视频的缩略图

小编导读:获取视频的缩略图,截图正在播放的视频某一帧,是在音视频开发中,常遇到的问题。本文是主要用于点播中截图视频,同时还可以获取点播视频的缩略图进行显示,留下...

1.1K7
来自专栏炉边夜话

很幽默的讲解六种Socket I/O模型

信息来源:幻影论坛     作  者: flyinwuhan (制怒·三思而后行)

1171
来自专栏帘卷西风的专栏

编写简易斜45度地图编辑器

      最近在研究cocos2dx的地图,最开始使用的是Tiled,这个编辑器做比较小的地图还是比较强大的,不过做大地图的时候,有一些功能不太方便并且有缺陷...

1093
来自专栏醉梦轩

解决Android模拟器中修改IMSI后无法上网问题

2843
来自专栏iOSDevLog

用Kotlin破解Android版微信小游戏-跳一跳成果跳一跳思路源码使用方法参考来源Android 插件 免PC

3806
来自专栏xx_Cc的学习总结专栏

六天完成一个简单iOS App - 第四天

2787
来自专栏非著名程序员

CoordinatorLayout的使用如此简单

曾在网上找了一些关于CoordinatorLayout的教程,大部分文章都是把CoordinatorLayout、AppbarLayout、Collapsing...

21810
来自专栏Android先生

RxJava2 实战知识梳理(2) - 计算一段时间内数据的平均值

今天,我们继续跟着 RxJava-Android-Samples 的脚步,一起看一下RxJava2在实战当中的应用,在这个项目中,第二个的例子的描述如下...

934
来自专栏即时通讯技术

手把手教你读取Android版微信和手Q的聊天记录(仅作技术研究学习)

特别说明:本文内容仅用于即时通讯技术研究和学习之用,请勿用于非法用途。如本文内容有不妥之处,请联系JackJiang进行处理!

6512
来自专栏Android 开发学习

音频开发ijkplayer小结 android

2262

扫码关注云+社区

领取腾讯云代金券