专栏首页QQ音乐技术团队的专栏Android系统线控和歌曲信息屏显的那点事

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),作者:lucienzhang

原文出处及转载信息见文内详细说明,如有侵权,请联系 yunjia_community@tencent.com 删除。

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

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

我来说两句

0 条评论
登录 后参与评论

相关文章

  • 一种Android App在Native层动态加载so库的方案

    这篇文章通过实战案例,介绍了一种有条理的组织Native层代码层级结构的方法。并且,在良好的代码层级、作用分工的基础上,实现了动态的按需加载、卸载so库。文章...

    QQ音乐技术团队
  • 记一次全民K歌的crash定位过程

    全民K歌4.6版本发布后,出现了一个与RecyclerView相关的Bug,作此记录。

    QQ音乐技术团队
  • 从源码出发浅析 Android TV 的焦点移动原理 (上篇)

    焦点(Focus)可以理解为选中态,在 Android TV上起很重要的作用。一个视图控件只有在获得焦点的状态下,才能响应按键的 Click 事件。

    QQ音乐技术团队
  • C#获取本机可用端口

    当我们要创建一个Tcp/UDP Server connection ,我们需要一个范围在1000到65535之间的端口 。但是本机一个端口只能一个程序监听,所以...

    张善友
  • golang adodb mssql数据库的query格式化奇葩问题

    用adodb驱动查询mssql数据。如果参数带有大括号。就会显示错误: ServeSrs sql db.Prepare error发生意外。 (语法错误或违反...

    xiny120
  • ionic2-list 原

    (adsbygoogle = window.adsbygoogle || []).push({});

    tianyawhl
  • 一位架构师用服务打动客户的故事之一

    ~~现在想想也觉得那近6个月的日子真开心,除了知识得到了系统性的查漏补缺,更得到了心智上的"收获"。

    安畅
  • 人工智能AI(4):线性代数之行列式

    行列式是数学中的一个函数,将一个的矩阵映射到一个标量,记作。 1 维基百科定义 行列式可以看做是有向面积或体积的概念在一般的欧几里得空间中的推广。或者说,在...

    企鹅号小编
  • 教你如何全键盘操作 Chrome 浏览器

    推荐两款插件, SurfingKeys 和 Steward,让你全键盘高效操作浏览器。老规矩,附视频教学。

    imroc
  • H5页面判断客户端是iOS或者Android并跳转对应链接唤起APP

    每个客户端都会有自己的 UA (userAgent)标识,可以用 JavaScript 获取客户端标识。

    德顺

扫码关注云+社区

领取腾讯云代金券