Android 媒體播放框架MediaSession分析與實(shí)踐

版權(quán)聲明:本文為博主原創(chuàng)文章,未經(jīng)博主允許不得轉(zhuǎn)載
源碼:AnliaLee/BauzMusic
首發(fā)地址:Anlia_掘金
大家要是看到有錯(cuò)誤的地方或者有啥好的建議,歡迎留言評(píng)論

前言

最近一直在忙著學(xué)習(xí)和研究音樂(lè)播放器,發(fā)現(xiàn)介紹MediaSession框架的資料非常少,更多的是一些源碼和開(kāi)源庫(kù),這對(duì)于初學(xué)者來(lái)說(shuō)不是很友好,可能看著看著就繞暈了,遂博主決定動(dòng)手寫點(diǎn)這方面的博客分享給大家

參考資料
googlesamples/android-UniversalMusicPlayer
Media Apps Overview(有前輩翻譯后的版本Android媒體應(yīng)用(一)


MediaSession框架簡(jiǎn)介

我們先來(lái)看看如何設(shè)計(jì)一款音樂(lè)播放App的架構(gòu),傳統(tǒng)的做法是這樣的:

  • 注冊(cè)一個(gè)Service,用于異步獲取音樂(lè)庫(kù)數(shù)據(jù)、音樂(lè)控制等,在Service中我們可能還需要自定義一些狀態(tài)值回調(diào)接口用于流程控制
  • 通過(guò)廣播(其他方式如接口、Messenger都可以)實(shí)現(xiàn)ActivityService之間的通信,使得用戶可以通過(guò)界面上的組件控制音樂(lè)的播放、暫停、拖動(dòng)進(jìn)度條等操作

如果我們的音樂(lè)播放器還需要支持通知欄快捷控制音樂(lè)播放的功能,那么又得新增一套廣播和相應(yīng)的接口去響應(yīng)通知欄按鈕的事件

如果還需要支持多端(電視、手表、耳機(jī)等)控制同一個(gè)播放器,那么整個(gè)系統(tǒng)架構(gòu)可能會(huì)變得非常復(fù)雜,我們要花費(fèi)大量的時(shí)間和精力去設(shè)計(jì)、優(yōu)化代碼的結(jié)構(gòu)。那么有什么方法可以節(jié)省這些工作,提高我們的效率,然后還可以優(yōu)雅地實(shí)現(xiàn)上述這些功能呢?

GoogleAndroid 5.0中加入了MediaSession框架(在support-v4中同樣提供了相應(yīng)的兼容包,相關(guān)的類以Compat結(jié)尾,Api基本相同),專門用來(lái)解決媒體播放時(shí)界面和Service通訊的問(wèn)題,意在規(guī)范上述這些功能的流程。使用這個(gè)框架我們可以減少一些流程復(fù)雜的開(kāi)發(fā)工作,例如使用各種廣播來(lái)控制播放器,而且其代碼可讀性、結(jié)構(gòu)耦合度方面都控制得非常好,因此推薦大家嘗試下這個(gè)框架。下面我們就開(kāi)始介紹MediaSession框架的核心成員和使用流程


MediaSession框架的使用

常用成員類概述

MediaSession框架中有四個(gè)常用的成員類,它們是整個(gè)流程控制的核心

  • MediaBrowser
    媒體瀏覽器,用來(lái)連接MediaBrowserService訂閱數(shù)據(jù),通過(guò)它的回調(diào)接口我們可以獲取和Service的連接狀態(tài)以及獲取在Service中異步獲取的音樂(lè)庫(kù)數(shù)據(jù)。媒體瀏覽器一般創(chuàng)建于客戶端(可以理解為各個(gè)終端負(fù)責(zé)控制音樂(lè)播放的界面)中

  • MediaBrowserService
    瀏覽器服務(wù),提供onGetRoot(控制客戶端媒體瀏覽器的連接請(qǐng)求,通過(guò)返回值決定是否允許該客戶端連接服務(wù))和onLoadChildren(媒體瀏覽器向Service發(fā)送數(shù)據(jù)訂閱時(shí)調(diào)用,一般在這執(zhí)行異步獲取數(shù)據(jù)的操作,最后將數(shù)據(jù)發(fā)送至媒體瀏覽器的回調(diào)接口中)這兩個(gè)抽象方法
    同時(shí)MediaBrowserService還作為承載媒體播放器(如MediaPlayer、ExoPlayer等)和MediaSession的容器

  • MediaSession
    媒體會(huì)話,即受控端,通過(guò)設(shè)置MediaSessionCompat.Callback回調(diào)來(lái)接收媒體控制器MediaController發(fā)送的指令,當(dāng)收到指令時(shí)會(huì)觸發(fā)Callback中各個(gè)指令對(duì)應(yīng)的回調(diào)方法(回調(diào)方法中會(huì)執(zhí)行播放器相應(yīng)的操作,如播放、暫停等)。Session一般在Service.onCreate方法中創(chuàng)建,最后需調(diào)用setSessionToken方法設(shè)置用于和控制器配對(duì)的令牌并通知瀏覽器連接服務(wù)成功

  • MediaController
    媒體控制器,在客戶端中開(kāi)發(fā)者不僅可以使用控制器向Service中的受控端發(fā)送指令,還可以通過(guò)設(shè)置MediaControllerCompat.Callback回調(diào)方法接收受控端的狀態(tài),從而根據(jù)相應(yīng)的狀態(tài)刷新界面UI。MediaController的創(chuàng)建需要受控端的配對(duì)令牌,因此需在瀏覽器成功連接服務(wù)的回調(diào)執(zhí)行創(chuàng)建的操作

通過(guò)上述的簡(jiǎn)介中我們不難看出這四個(gè)成員之間有著非常明確的分工和作用范圍,使得整個(gè)代碼結(jié)構(gòu)變得清晰易讀??梢酝ㄟ^(guò)下面這張圖來(lái)簡(jiǎn)單歸納它們之間的關(guān)系

除此之外,MediaSession框架中還有一些同樣重要的類需要拿出來(lái)講,例如封裝了各種播放狀態(tài)PlaybackState,和Map相似通過(guò)鍵值對(duì)保存媒體信息MediaMetadata,以及用于MediaBrowserMediaBrowserService之間進(jìn)行數(shù)據(jù)交互的MediaItem等等,下面我們通過(guò)實(shí)現(xiàn)一個(gè)簡(jiǎn)單的demo來(lái)具體分析這套框架的工作流程

使用MediaSession框架構(gòu)建簡(jiǎn)單的音樂(lè)播放器

例如我們的demo是這樣的(見(jiàn)下圖),只提供簡(jiǎn)單的播放暫停操作,音樂(lè)數(shù)據(jù)源從raw資源文件夾中獲取

按照工作流程,我們就從獲取音樂(lè)庫(kù)數(shù)據(jù)開(kāi)始吧。首先界面上方添加一個(gè)RecyclerView來(lái)展示獲取的音樂(lè)列表,我們?cè)?strong>DemoActivity中完成一些RecyclerView的初始化操作

public class DemoActivity extends AppCompatActivity {
    private RecyclerView recyclerView;
    private List<MediaBrowserCompat.MediaItem> list;
    private DemoAdapter demoAdapter;
    private LinearLayoutManager layoutManager;

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_demo);

        list = new ArrayList<>();
        layoutManager = new LinearLayoutManager(this);
        layoutManager.setOrientation(LinearLayoutManager.VERTICAL);
        demoAdapter = new DemoAdapter(this,list);

        recyclerView = (RecyclerView) findViewById(R.id.recyclerView);
        recyclerView.setLayoutManager(layoutManager);
        recyclerView.setAdapter(demoAdapter);
    }
}

注意List元素的類型為MediaBrowserCompat.MediaItem,因?yàn)?strong>MediaBrowser從服務(wù)中獲取的每一首音樂(lè)都會(huì)封裝成MediaItem對(duì)象。接下來(lái)我們創(chuàng)建MediaBrowser,并執(zhí)行連接服務(wù)端和訂閱數(shù)據(jù)的操作

public class DemoActivity extends AppCompatActivity {
    ...
    private MediaBrowserCompat mBrowser;

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        ...
        mBrowser = new MediaBrowserCompat(
                this,
                new ComponentName(this, MusicService.class),//綁定瀏覽器服務(wù)
                BrowserConnectionCallback,//設(shè)置連接回調(diào)
                null
        );
    }

    @Override
    protected void onStart() {
        super.onStart();
        //Browser發(fā)送連接請(qǐng)求
        mBrowser.connect();
    }

    @Override
    protected void onStop() {
        super.onStop();
        mBrowser.disconnect();
    }

    /**
     * 連接狀態(tài)的回調(diào)接口,連接成功時(shí)會(huì)調(diào)用onConnected()方法
     */
    private MediaBrowserCompat.ConnectionCallback BrowserConnectionCallback =
            new MediaBrowserCompat.ConnectionCallback(){
                @Override
                public void onConnected() {
                    Log.e(TAG,"onConnected------");
                    //必須在確保連接成功的前提下執(zhí)行訂閱的操作
                    if (mBrowser.isConnected()) {
                        //mediaId即為MediaBrowserService.onGetRoot的返回值
                        //若Service允許客戶端連接,則返回結(jié)果不為null,其值為數(shù)據(jù)內(nèi)容層次結(jié)構(gòu)的根ID
                        //若拒絕連接,則返回null
                        String mediaId = mBrowser.getRoot();

                        //Browser通過(guò)訂閱的方式向Service請(qǐng)求數(shù)據(jù),發(fā)起訂閱請(qǐng)求需要兩個(gè)參數(shù),其一為mediaId
                        //而如果該mediaId已經(jīng)被其他Browser實(shí)例訂閱,則需要在訂閱之前取消mediaId的訂閱者
                        //雖然訂閱一個(gè) 已被訂閱的mediaId 時(shí)會(huì)取代原Browser的訂閱回調(diào),但卻無(wú)法觸發(fā)onChildrenLoaded回調(diào)

                        //ps:雖然基本的概念是這樣的,但是Google在官方demo中有這么一段注釋...
                        // This is temporary: A bug is being fixed that will make subscribe
                        // consistently call onChildrenLoaded initially, no matter if it is replacing an existing
                        // subscriber or not. Currently this only happens if the mediaID has no previous
                        // subscriber or if the media content changes on the service side, so we need to
                        // unsubscribe first.
                        //大概的意思就是現(xiàn)在這里還有BUG,即只要發(fā)送訂閱請(qǐng)求就會(huì)觸發(fā)onChildrenLoaded回調(diào)
                        //所以無(wú)論怎樣我們發(fā)起訂閱請(qǐng)求之前都需要先取消訂閱
                        mBrowser.unsubscribe(mediaId);
                        //之前說(shuō)到訂閱的方法還需要一個(gè)參數(shù),即設(shè)置訂閱回調(diào)SubscriptionCallback
                        //當(dāng)Service獲取數(shù)據(jù)后會(huì)將數(shù)據(jù)發(fā)送回來(lái),此時(shí)會(huì)觸發(fā)SubscriptionCallback.onChildrenLoaded回調(diào)
                        mBrowser.subscribe(mediaId, BrowserSubscriptionCallback);
                    }
                }

                @Override
                public void onConnectionFailed() {
                    Log.e(TAG,"連接失??!");
                }
            };
    /**
     * 向媒體瀏覽器服務(wù)(MediaBrowserService)發(fā)起數(shù)據(jù)訂閱請(qǐng)求的回調(diào)接口
     */
    private final MediaBrowserCompat.SubscriptionCallback BrowserSubscriptionCallback =
            new MediaBrowserCompat.SubscriptionCallback(){
                @Override
                public void onChildrenLoaded(@NonNull String parentId,
                                             @NonNull List<MediaBrowserCompat.MediaItem> children) {
                    Log.e(TAG,"onChildrenLoaded------");
                    //children 即為Service發(fā)送回來(lái)的媒體數(shù)據(jù)集合
                    for (MediaBrowserCompat.MediaItem item:children){
                        Log.e(TAG,item.getDescription().getTitle().toString());
                        list.add(item);
                    }
                    //在onChildrenLoaded可以執(zhí)行刷新列表UI的操作
                    demoAdapter.notifyDataSetChanged();
                }
            };
}

通過(guò)上述的代碼和注釋大家應(yīng)該清楚MediaBrowser連接服務(wù)到向其訂閱數(shù)據(jù)的流程了,簡(jiǎn)單總結(jié)一下就是

connect → onConnected → subscribe → onChildrenLoaded

那么Service端那邊在這段流程中又做了什么呢?首先我們得繼承MediaBrowserService(這里使用了support-v4包的類)創(chuàng)建MusicService類。MediaBrowserService繼承自Service,所以記得在AndroidManifest.xml中完成配置

<service
    android:name=".demo.MusicService">
    <intent-filter>
        <action android:name="android.media.browse.MediaBrowserService" />
    </intent-filter>
</service>

我們需要在Service初始化的時(shí)候就完成MediaSession的構(gòu)建,并為它設(shè)置相應(yīng)的標(biāo)志、狀態(tài)等,具體的代碼如下

public class MusicService extends MediaBrowserServiceCompat {
    private MediaSessionCompat mSession;
    private PlaybackStateCompat mPlaybackState;

    @Override
    public void onCreate() {
        super.onCreate();
        mPlaybackState = new PlaybackStateCompat.Builder()
                .setState(PlaybackStateCompat.STATE_NONE,0,1.0f)
                .build();

        mSession = new MediaSessionCompat(this,"MusicService");
        mSession.setCallback(SessionCallback);//設(shè)置回調(diào)
        mSession.setFlags(MediaSessionCompat.FLAG_HANDLES_TRANSPORT_CONTROLS);
        mSession.setPlaybackState(mPlaybackState);

        //設(shè)置token后會(huì)觸發(fā)MediaBrowserCompat.ConnectionCallback的回調(diào)方法
        //表示MediaBrowser與MediaBrowserService連接成功
        setSessionToken(mSession.getSessionToken());
    }
}

這里解釋下其中的一些細(xì)節(jié),首先是調(diào)用MediaSession.setFlagSession設(shè)置標(biāo)志位,以便Session接收控制器的指令。然后是播放狀態(tài)的設(shè)置,需調(diào)用MediaSession.setPlaybackState,那么PlaybackState又是什么呢?之前我們簡(jiǎn)單介紹過(guò)它是封裝了各種播放狀態(tài)的類,我們可以通過(guò)判斷當(dāng)前播放狀態(tài)來(lái)控制各個(gè)成員的行為,而PlaybackState類為我們定義了各種狀態(tài)的規(guī)范。此外我們還需要設(shè)置SessionCallback回調(diào),當(dāng)客戶端使用控制器發(fā)送指令時(shí),就會(huì)觸發(fā)這些回調(diào)方法,從而達(dá)到控制播放器的目的

public class MusicService extends MediaBrowserServiceCompat {
    ...
    private MediaPlayer mMediaPlayer;

    @Override
    public void onCreate() {
        ...
        mMediaPlayer = new MediaPlayer();
        mMediaPlayer.setOnPreparedListener(PreparedListener);
        mMediaPlayer.setOnCompletionListener(CompletionListener);
    }

    /**
     * 響應(yīng)控制器指令的回調(diào)
     */
    private android.support.v4.media.session.MediaSessionCompat.Callback SessionCallback = new MediaSessionCompat.Callback(){
        /**
         * 響應(yīng)MediaController.getTransportControls().play
         */
        @Override
        public void onPlay() {
            Log.e(TAG,"onPlay");
            if(mPlaybackState.getState() == PlaybackStateCompat.STATE_PAUSED){
                mMediaPlayer.start();
                mPlaybackState = new PlaybackStateCompat.Builder()
                        .setState(PlaybackStateCompat.STATE_PLAYING,0,1.0f)
                        .build();
                mSession.setPlaybackState(mPlaybackState);
            }
        }

        /**
         * 響應(yīng)MediaController.getTransportControls().onPause
         */
        @Override
        public void onPause() {
            Log.e(TAG,"onPause");
            if(mPlaybackState.getState() == PlaybackStateCompat.STATE_PLAYING){
                mMediaPlayer.pause();
                mPlaybackState = new PlaybackStateCompat.Builder()
                        .setState(PlaybackStateCompat.STATE_PAUSED,0,1.0f)
                        .build();
                mSession.setPlaybackState(mPlaybackState);
            }
        }

        /**
         * 響應(yīng)MediaController.getTransportControls().playFromUri
         * @param uri
         * @param extras
         */
        @Override
        public void onPlayFromUri(Uri uri, Bundle extras) {
            Log.e(TAG,"onPlayFromUri");
            try {
                switch (mPlaybackState.getState()){
                    case PlaybackStateCompat.STATE_PLAYING:
                    case PlaybackStateCompat.STATE_PAUSED:
                    case PlaybackStateCompat.STATE_NONE:
                        mMediaPlayer.reset();
                        mMediaPlayer.setDataSource(MusicService.this,uri);
                        mMediaPlayer.prepare();//準(zhǔn)備同步
                        mPlaybackState = new PlaybackStateCompat.Builder()
                                .setState(PlaybackStateCompat.STATE_CONNECTING,0,1.0f)
                                .build();
                        mSession.setPlaybackState(mPlaybackState);
                        //我們可以保存當(dāng)前播放音樂(lè)的信息,以便客戶端刷新UI
                        mSession.setMetadata(new MediaMetadataCompat.Builder()
                                .putString(MediaMetadataCompat.METADATA_KEY_TITLE,extras.getString("title"))
                                .build()
                        );
                        break;
                }
            }catch (IOException e){
                e.printStackTrace();
            }
        }

        @Override
        public void onPlayFromSearch(String query, Bundle extras) {
        }
    };

    /**
     * 監(jiān)聽(tīng)MediaPlayer.prepare()
     */
    private MediaPlayer.OnPreparedListener PreparedListener = new MediaPlayer.OnPreparedListener() {
        @Override
        public void onPrepared(MediaPlayer mediaPlayer) {
            mMediaPlayer.start();
            mPlaybackState = new PlaybackStateCompat.Builder()
                    .setState(PlaybackStateCompat.STATE_PLAYING,0,1.0f)
                    .build();
            mSession.setPlaybackState(mPlaybackState);
        }
    } ;

    /**
     * 監(jiān)聽(tīng)播放結(jié)束的事件
     */
    private MediaPlayer.OnCompletionListener CompletionListener = new MediaPlayer.OnCompletionListener() {
        @Override
        public void onCompletion(MediaPlayer mediaPlayer) {
            mPlaybackState = new PlaybackStateCompat.Builder()
                    .setState(PlaybackStateCompat.STATE_NONE,0,1.0f)
                    .build();
            mSession.setPlaybackState(mPlaybackState);
            mMediaPlayer.reset();
        }
    };
}

MediaSessionCompat.Callback中還有許多回調(diào)方法,大家可以按需覆蓋重寫即可

構(gòu)建好MediaSession后記得調(diào)用setSessionToken保存Session的配對(duì)令牌,同時(shí)調(diào)用此方法也會(huì)回調(diào)MediaBrowser.ConnectionCallbackonConnected方法,告知客戶端BrowserBrowserService連接成功了,我們也就完成了MediaSession的創(chuàng)建和初始化

之前我們還講到BrowserBrowserService訂閱關(guān)系,在MediaBrowserService中我們需要重寫onGetRootonLoadChildren方法,其作用之前已經(jīng)講過(guò)就不多贅述了

public class MusicService extends MediaBrowserServiceCompat {
    @Nullable
    @Override
    public BrowserRoot onGetRoot(@NonNull String clientPackageName, int clientUid, @Nullable Bundle rootHints) {
        Log.e(TAG,"onGetRoot-----------");
        return new BrowserRoot(MEDIA_ID_ROOT, null);
    }

    @Override
    public void onLoadChildren(@NonNull String parentId, @NonNull final Result<List<MediaBrowserCompat.MediaItem>> result) {
        Log.e(TAG,"onLoadChildren--------");
        //將信息從當(dāng)前線程中移除,允許后續(xù)調(diào)用sendResult方法
        result.detach();

        //我們模擬獲取數(shù)據(jù)的過(guò)程,真實(shí)情況應(yīng)該是異步從網(wǎng)絡(luò)或本地讀取數(shù)據(jù)
        MediaMetadataCompat metadata = new MediaMetadataCompat.Builder()
                .putString(MediaMetadataCompat.METADATA_KEY_MEDIA_ID, ""+R.raw.jinglebells)
                .putString(MediaMetadataCompat.METADATA_KEY_TITLE, "圣誕歌")
                .build();
        ArrayList<MediaBrowserCompat.MediaItem> mediaItems = new ArrayList<>();
        mediaItems.add(createMediaItem(metadata));

        //向Browser發(fā)送數(shù)據(jù)
        result.sendResult(mediaItems);
    }

    private MediaBrowserCompat.MediaItem createMediaItem(MediaMetadataCompat metadata){
        return new MediaBrowserCompat.MediaItem(
                metadata.getDescription(),
                MediaBrowserCompat.MediaItem.FLAG_PLAYABLE
        );
    }
}

最后我們回到客戶端這邊,四大成員還剩下控制器MediaController沒(méi)講。MediaController的創(chuàng)建依賴于Session配對(duì)令牌,當(dāng)BrowserBrowserService連接成功我們就可以通過(guò)Browser拿到這個(gè)令牌了。控制器創(chuàng)建后,我們就可以通過(guò)MediaController.getTransportControls的方法發(fā)送播放指令,同時(shí)也可以注冊(cè)MediaControllerCompat.Callback回調(diào)接收播放狀態(tài),用以刷新界面UI

public class DemoActivity extends AppCompatActivity {
    ...
    private Button btnPlay;
    private TextView textTitle;

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        ...
        btnPlay = (Button) findViewById(R.id.btn_play);
        textTitle = (TextView) findViewById(R.id.text_title);
    }

    public void clickEvent(View view) {
        switch (view.getId()) {
            case R.id.btn_play:
                if(mController!=null){
                    handlerPlayEvent();
                }
                break;
        }
    }

    /**
     * 處理播放按鈕事件
     */
    private void handlerPlayEvent(){
        switch (mController.getPlaybackState().getState()){
            case PlaybackStateCompat.STATE_PLAYING:
                mController.getTransportControls().pause();
                break;
            case PlaybackStateCompat.STATE_PAUSED:
                mController.getTransportControls().play();
                break;
            default:
                mController.getTransportControls().playFromSearch("", null);
                break;
        }
    }

    /**
     * 連接狀態(tài)的回調(diào)接口,連接成功時(shí)會(huì)調(diào)用onConnected()方法
     */
    private MediaBrowserCompat.ConnectionCallback BrowserConnectionCallback =
            new MediaBrowserCompat.ConnectionCallback(){
                @Override
                public void onConnected() {
                    Log.e(TAG,"onConnected------");
                    if (mBrowser.isConnected()) {
                        ...
                        try{
                            mController = new MediaControllerCompat(DemoActivity.this,mBrowser.getSessionToken());
                            //注冊(cè)回調(diào)
                            mController.registerCallback(ControllerCallback);
                        }catch (RemoteException e){
                            e.printStackTrace();
                        }
                    }
                }

                @Override
                public void onConnectionFailed() {
                    Log.e(TAG,"連接失?。?);
                }
            };

    /**
     * 媒體控制器控制播放過(guò)程中的回調(diào)接口,可以用來(lái)根據(jù)播放狀態(tài)更新UI
     */
    private final MediaControllerCompat.Callback ControllerCallback =
            new MediaControllerCompat.Callback() {
                /***
                 * 音樂(lè)播放狀態(tài)改變的回調(diào)
                 * @param state
                 */
                @Override
                public void onPlaybackStateChanged(PlaybackStateCompat state) {
                    switch (state.getState()){
                        case PlaybackStateCompat.STATE_NONE://無(wú)任何狀態(tài)
                            textTitle.setText("");
                            btnPlay.setText("開(kāi)始");
                            break;
                        case PlaybackStateCompat.STATE_PAUSED:
                            btnPlay.setText("開(kāi)始");
                            break;
                        case PlaybackStateCompat.STATE_PLAYING:
                            btnPlay.setText("暫停");
                            break;
                    }
                }

                /**
                 * 播放音樂(lè)改變的回調(diào)
                 * @param metadata
                 */
                @Override
                public void onMetadataChanged(MediaMetadataCompat metadata) {
                    textTitle.setText(metadata.getDescription().getTitle());
                }
            };

    private Uri rawToUri(int id){
        String uriStr = "android.resource://" + getPackageName() + "/" + id;
        return Uri.parse(uriStr);
    }
}

MediaSession框架的基本用法我們已經(jīng)分析完了,后續(xù)將會(huì)分析Google官方demo UniversalMusicPlayer 的源碼,看看播放進(jìn)度條、播放隊(duì)列控制、通知欄上的快捷操作等等這些功能是如何結(jié)合MediaSession框架實(shí)現(xiàn)的

?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請(qǐng)聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時(shí)請(qǐng)結(jié)合常識(shí)與多方信息審慎甄別。
平臺(tái)聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點(diǎn),簡(jiǎn)書系信息發(fā)布平臺(tái),僅提供信息存儲(chǔ)服務(wù)。

相關(guān)閱讀更多精彩內(nèi)容

友情鏈接更多精彩內(nèi)容