Android車載應用開發與分析(8)- 車載多媒體(三)- 原生音樂播放器(上)

1. LocalMedia

LocalMedia 是 CarAndroid 中自帶的本地音樂播放器,它可以識別出系統中的音樂,并進行播放。本質上屬于一個功能比較完善的Demo,官方的目的可能是為了演示如何使用 MediaSession 框架寫一個音樂播放器。關于MediaSession框架之前已經介紹過了,本篇就簡單解析一下這個Demo。

1.1 LocalMedia 拆解

LocalMedia 運行時分為兩個APP:

  • com.android.car.media.localmediaplayer
    該app是一個Service,主要作用是檢索出本地的音樂多媒體,并封裝成元數據。
  • com.android.car.media
    主要用于展示HMI和用戶交互,源碼量非常龐大。

除了上面兩個APP,其實還有還有一個進程android.car.media,官方給出的注釋是這么介紹它的:
CarMediaService 管理汽車應用程序當前活動的媒體源。 這與 MediaSessionManager 的活動會話不同,因為汽車中只能有一個活動源,通過瀏覽和播放。在汽車中,活動媒體源不一定有活動的 MediaSession,例如 如果它只是被瀏覽。 但是,該來源仍被視為活動來源,并且應該是任何與媒體相關的 UI(媒體中心、主屏幕等)中顯示的來源。

這里就不介紹CarMediaService,在源碼中被分類在com.android.car目錄下,已經不屬于應用的范疇,本質上屬于Framework。

我們先來看看com.android.car.media.localmediaplayer 是如何實現。

2. localmediaplayer 核心源碼分析

應用的源碼分析討論都是一樣的,先從AndroidManifest開始。

<manifest xmlns:android="http://schemas.android.com/apk/res/android"
    package="com.android.car.media.localmediaplayer"
    android:sharedUserId="com.android.car.media">

    <uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
    <!-- 省略不重要的代碼 -->
    <application android:theme="@style/LocalMediaPlayerAppTheme">

        <service
            android:name=".LocalMediaBrowserService"
            android:exported="true"
            android:label="@string/app_name">
            <intent-filter>
                <action android:name="android.media.browse.MediaBrowserService" />
            </intent-filter>
        </service>

        <activity
            android:name=".PermissionsActivity"
            android:theme="@android:style/Theme.Translucent.NoTitleBar.Fullscreen">
            <meta-data
                android:name="distractionOptimized"
                android:value="true" />
        </activity>

    </application>
</manifest>

可以看出Service的結構很簡單,LocalMediaBrowserService是MediaSession的容器,PermissionsActivity則是負責權限檢查和申請。

2.1 LocalMediaBrowserService

LocalMediaBrowserService繼承自MediaBrowserService,作為一個容器,主要就是用來初始化其它組件。

@Override
public void onCreate() {
    super.onCreate();
    // 創建 DataModel。
    mDataModel = new DataModel(this);
    // 初始化 RootItem
    addRootItems();
    // 創建 MediaSession
    mSession = new MediaSession(this, MEDIA_SESSION_TAG);
    setSessionToken(mSession.getSessionToken());
    // 媒體播放器,同時也是 MediaSession.Callback
    mPlayer = new Player(this, mSession, mDataModel);
    mSession.setCallback(mPlayer);
    mSession.setFlags(MediaSession.FLAG_HANDLES_MEDIA_BUTTONS
            | MediaSession.FLAG_HANDLES_TRANSPORT_CONTROLS);
    mPlayer.maybeRestoreState();
    // 廣播,用于監聽Notification的控制動作
    IntentFilter filter = new IntentFilter();
    filter.addAction(ACTION_PLAY);
    filter.addAction(ACTION_PAUSE);
    filter.addAction(ACTION_NEXT);
    filter.addAction(ACTION_PREV);
    registerReceiver(mNotificationReceiver, filter);
}

  • 創建 DataModel
    用來檢索設備本地的多媒體數據。其內部主要封裝的都是如何在設備上查詢各種多媒體數據。

  • 初始化 RootItem

List<MediaBrowser.MediaItem> mRootItems = new ArrayList<>();

private void addRootItems() {
    MediaDescription folders = new MediaDescription.Builder()
            .setMediaId(FOLDERS_ID)
            .setTitle(getString(R.string.folders_title))
            .setIconUri(Utils.getUriForResource(this, R.drawable.ic_folder))
            .build();
    mRootItems.add(new MediaBrowser.MediaItem(folders, MediaBrowser.MediaItem.FLAG_BROWSABLE));

    MediaDescription albums = new MediaDescription.Builder()
            .setMediaId(ALBUMS_ID)
            .setTitle(getString(R.string.albums_title))
            .setIconUri(Utils.getUriForResource(this, R.drawable.ic_album))
            .build();
    mRootItems.add(new MediaBrowser.MediaItem(albums, MediaBrowser.MediaItem.FLAG_BROWSABLE));

    MediaDescription artists = new MediaDescription.Builder()
            .setMediaId(ARTISTS_ID)
            .setTitle(getString(R.string.artists_title))
            .setIconUri(Utils.getUriForResource(this, R.drawable.ic_artist))
            .build();
    mRootItems.add(new MediaBrowser.MediaItem(artists, MediaBrowser.MediaItem.FLAG_BROWSABLE));

    MediaDescription genres = new MediaDescription.Builder()
            .setMediaId(GENRES_ID)
            .setTitle(getString(R.string.genres_title))
            .setIconUri(Utils.getUriForResource(this, R.drawable.ic_genre))
            .build();
    mRootItems.add(new MediaBrowser.MediaItem(genres, MediaBrowser.MediaItem.FLAG_BROWSABLE));
}

RootItems是在HMI查詢ROOT_ID時返回的一個列表,列表中包含四個默認的MediaItem,而且Flag都是FLAG_BROWSABLE表示MediaItem是可瀏覽的(文件夾)。四個MediaItem對應HMI上顯示的四個大類。

mRootItems會在onLoadChildren()方法中傳給HMI端。HMI端需要調用MediaBrowser.subscribe才能觸發onLoadChildren()。

@Override
public void onLoadChildren(String parentId, Result<List<MediaBrowser.MediaItem>> result) {
    if (Log.isLoggable(TAG, Log.DEBUG)) {
        Log.d(TAG, "onLoadChildren parentId=" + parentId);
    }
    switch (parentId) {
        case ROOT_ID:
            result.sendResult(mRootItems);
            mLastCategory = parentId;
            break;
            //.....
    }
}

  • 創建 Player
    創建本地播放器。內部主要基于MediaPlayer實現。

  • 添加廣播監聽
    用來響應Notification中的動作。支持4個動作。

2.2 檢索/封裝Audio - DataModel

DataModel主要用于檢索設備本地的多媒體數據,提供了以下四種從ContentProvider檢索方式。

2.2.1 文件夾(Folder)檢索

private static final Uri[] ALL_AUDIO_URI = new Uri[] {
        MediaStore.Audio.Media.INTERNAL_CONTENT_URI,
        MediaStore.Audio.Media.EXTERNAL_CONTENT_URI
};

public void onQueryByFolder(String parentId, Result<List<MediaItem>> result) {
    FilesystemListTask query = new FilesystemListTask(result, ALL_AUDIO_URI, mResolver);
    queryInBackground(result, query);
}

2.2.2 專輯(Album)檢索

private static final Uri[] ALBUMS_URI = new Uri[] {
        MediaStore.Audio.Albums.INTERNAL_CONTENT_URI,
        MediaStore.Audio.Albums.EXTERNAL_CONTENT_URI
};

public void onQueryByAlbum(String parentId, Result<List<MediaItem>> result) {
    QueryTask query = new QueryTask.Builder()
            .setResolver(mResolver)
            .setResult(result)
            .setUri(ALBUMS_URI)
            .setKeyColumn(AudioColumns.ALBUM_KEY)
            .setTitleColumn(AudioColumns.ALBUM)
            .setFlags(MediaItem.FLAG_BROWSABLE)
            .build();
    queryInBackground(result, query);
}

2.2.3 藝術家(Artist)檢索

private static final Uri[] ARTISTS_URI = new Uri[] {
        MediaStore.Audio.Artists.INTERNAL_CONTENT_URI,
        MediaStore.Audio.Artists.EXTERNAL_CONTENT_URI
};

public void onQueryByArtist(String parentId, Result<List<MediaItem>> result) {
    QueryTask query = new QueryTask.Builder()
            .setResolver(mResolver)
            .setResult(result)
            .setUri(ARTISTS_URI)
            .setKeyColumn(AudioColumns.ARTIST_KEY)
            .setTitleColumn(AudioColumns.ARTIST)
            .setFlags(MediaItem.FLAG_BROWSABLE)
            .build();
    queryInBackground(result, query);
}

2.2.4 流派(Genre)檢索

private static final Uri[] GENRES_URI = new Uri[] {
        MediaStore.Audio.Genres.INTERNAL_CONTENT_URI,
        MediaStore.Audio.Genres.EXTERNAL_CONTENT_URI
};

public void onQueryByGenre(String parentId, Result<List<MediaItem>> result) {
    QueryTask query = new QueryTask.Builder()
            .setResolver(mResolver)
            .setResult(result)
            .setUri(GENRES_URI)
            .setKeyColumn(MediaStore.Audio.Genres._ID)
            .setTitleColumn(MediaStore.Audio.Genres.NAME)
            .setFlags(MediaItem.FLAG_BROWSABLE)
            .build();
    queryInBackground(result, query);
}

2.2.5 模糊檢索

該方法主要就是檢索出設備中所有的Audio數據。

private static final String QUERY_BY_KEY_WHERE_CLAUSE =
        AudioColumns.ALBUM_KEY + "= ? or "
                + AudioColumns.ARTIST_KEY + " = ? or "
                + AudioColumns.TITLE_KEY + " = ? or "
                + AudioColumns.DATA + " like ?";

/**
 * 注意:這會清除隊列。 在調用此方法之前,應該擁有隊列的本地備份。
 */
public void onQueryByKey(String lastCategory, String parentId, Result<List<MediaItem>> result) {
    mQueue.clear();
    QueryTask.Builder query = new QueryTask.Builder()
            .setResolver(mResolver)
            .setResult(result);

    if (LocalMediaBrowserService.GENRES_ID.equals(lastCategory)) {
        // Genre來自不同的表,并且不使用通常媒體表中的 where 子句,因此我們需要有這個條件。
        try {
            long id = Long.parseLong(parentId);
            query.setUri(new Uri[] {
                MediaStore.Audio.Genres.Members.getContentUri(EXTERNAL, id),
                MediaStore.Audio.Genres.Members.getContentUri(INTERNAL, id) });
        } catch (NumberFormatException e) {
            // 這不應該發生。
            Log.e(TAG, "Incorrect key type: " + parentId + ", sending empty result");
            result.sendResult(new ArrayList<MediaItem>());
            return;
        }
    } else {
        query.setUri(ALL_AUDIO_URI)
                .setWhereClause(QUERY_BY_KEY_WHERE_CLAUSE)
                .setWhereArgs(new String[] { parentId, parentId, parentId, parentId });
    }

    query.setKeyColumn(AudioColumns.TITLE_KEY)
            .setTitleColumn(AudioColumns.TITLE)
            .setSubtitleColumn(AudioColumns.ALBUM)
            .setFlags(MediaItem.FLAG_PLAYABLE)
            .setQueue(mQueue);
    queryInBackground(result, query.build());
}

2.2.6 QueryTask

由于ContentProvider#query是一個耗時方法,所以需要放在子線程中執行,于是就有了QueryTask

@Override
protected Void doInBackground(Void... voids) {
    List<MediaItem> results = new ArrayList<>();
    long idx = 0;
    Cursor cursor = null;
    for (Uri uri : mUris) {
        try {
            cursor = mResolver.query(uri, mColumns, mWhereClause, mWhereArgs, null);
            if (cursor != null) {
                int keyColumn = cursor.getColumnIndex(mKeyColumn);
                int titleColumn = cursor.getColumnIndex(mTitleColumn);
                int pathColumn = cursor.getColumnIndex(AudioColumns.DATA);
                int subtitleColumn = -1;
                if (mSubtitleColumn != null) {
                    subtitleColumn = cursor.getColumnIndex(mSubtitleColumn);
                }

                while (cursor.moveToNext()) {
                    Bundle path = new Bundle();
                    if (pathColumn != -1) {
                        path.putString(PATH_KEY, cursor.getString(pathColumn));
                    }

                    MediaDescription.Builder builder = new MediaDescription.Builder()
                            .setMediaId(cursor.getString(keyColumn))
                            .setTitle(cursor.getString(titleColumn))
                            .setExtras(path);

                    if (subtitleColumn != -1) {
                        builder.setSubtitle(cursor.getString(subtitleColumn));
                    }

                    MediaDescription description = builder.build();
                    results.add(new MediaItem(description, mFlags));

                    // 我們在這里重建隊列,所以如果用戶選擇項目,那么我們可以立即使用這個隊列。
                    if (mQueue != null) {
                        mQueue.add(new QueueItem(description, idx));
                    }
                    idx++;
                }
            }
        } catch (SQLiteException e) {
            // 有時,如果媒體掃描尚未看到該類型的數據,則表不存在。
            // 例如,在第一次遇到具有流派的歌曲之前,流派表似乎根本不存在。
            // 如果我們遇到異常,則永遠不會發送結果導致另一端掛斷,這是一件壞事。
            // 相反,我們可以保持彈性并返回一個空列表。
            Log.i(TAG, "Failed to execute query " + e);
        } finally {
            if (cursor != null) {
                cursor.close();
            }
        }
    }
    mResult.sendResult(results);
    return null;  // 忽略.
}

QueryTask從名字上就能猜出來,是一個AsyncTask,而且實際只用到了doInBackground(),在后臺執行完查詢結果后,執行mResult.sendResult(results),結果就會從Service傳遞給HMI。QueryTask封裝了多個可配置參數,還用到一個簡單建造者模式,不過我們自己改寫的話,用比AsyncTask更輕量的一些的線程池協程即可。

2.2.7 FilesystemListTask

FilesystemListTask 與 QueryTask 一樣都是 AsyncTask,FilesystemListTask 主要用于文件夾檢索,為了便于理解代碼,沒有和QueryTask封裝在一起。如果強行寫在一起,看起來非常奇怪并且過度參數化,有可能變得更加冗長。

@Override
protected Void doInBackground(Void... voids) {
    Set<String> paths = new HashSet<String>();
    Cursor cursor = null;
    for (Uri uri : mUris) {
        try {
            cursor = mResolver.query(uri, COLUMNS, null , null, null);
            if (cursor != null) {
                int pathColumn = cursor.getColumnIndex(AudioColumns.DATA);
                while (cursor.moveToNext()) {
                    // 我們想要對每首歌曲的路徑進行重復數據刪除,因此我們只得到一個包含目錄的列表。
                    String fullPath = cursor.getString(pathColumn);
                    int fileNameStart = fullPath.lastIndexOf(File.separator);
                    if (fileNameStart < 0) {
                        continue;
                    }
                    String dirPath = fullPath.substring(0, fileNameStart);
                    paths.add(dirPath);
                }
            }
        } catch (SQLiteException e) {
            Log.e(TAG, "Failed to execute query " + e);
        } finally {
            if (cursor != null) {
                cursor.close();
            }
        }
    }

    // 取出去重目錄列表,并將它們放入結果列表中,以完整目錄路徑為鍵,以便我們稍后進行匹配。
    List<MediaItem> results = new ArrayList<>();
    for (String path : paths) {
        int dirNameStart = path.lastIndexOf(File.separator) + 1;
        String dirName = path.substring(dirNameStart, path.length());
        //在封裝為 MediaItem
        MediaDescription description = new MediaDescription.Builder()
                .setMediaId(path + "%")  // 在類似查詢中使用。
                .setTitle(dirName)
                .setSubtitle(path)
                .build();
        results.add(new MediaItem(description, MediaItem.FLAG_BROWSABLE));
    }
    mResult.sendResult(results);
    return null;
}

3.3 媒體播放器 - Player

Player繼承MediaSession.Callback,所以需要處理HMI端調用MediaController.transportControls.xxx的對應方法。在內部邏輯主要就是記錄播放狀態和處理音頻焦點。

官方代碼中Player沒有實現上一曲、下一曲之間的無縫播放,無縫播放至少需要初始化兩個MediaPlayer。
完整的源碼位置 : Github - Player.java

3.3.1 初始化媒體播放器

public Player(Context context, MediaSession session, DataModel dataModel) {
    mContext = context;
    mDataModel = dataModel;
    // 創建AudioManager
    mAudioManager = (AudioManager) context.getSystemService(Context.AUDIO_SERVICE);

    mSession = session;
    // 創建SharedPreferences用于記錄播放狀態
    mSharedPrefs = context.getSharedPreferences(SHARED_PREFS_NAME, Context.MODE_PRIVATE);

    mShuffle = new CustomAction.Builder(SHUFFLE, context.getString(R.string.shuffle),
            R.drawable.shuffle).build();

    mMediaPlayer = new MediaPlayer();
    mMediaPlayer.reset();
    mMediaPlayer.setOnCompletionListener(mOnCompletionListener);

    // 初始化播放器狀態,這里設定為error狀態
    mErrorState = new PlaybackState.Builder()
            .setState(PlaybackState.STATE_ERROR, 0, 0)
            .setErrorMessage(context.getString(R.string.playback_error))
            .build();

    // 初始化Notification
    mNotificationManager =
            (NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE);
    // 媒體通知有兩種形式,播放時需要顯示暫停和跳過的控件,暫停時需要顯示播放和跳過的控件。
    // 預先為這兩個設置預先填充的構建器。
    Notification.Action prevAction = makeNotificationAction(
            LocalMediaBrowserService.ACTION_PREV, R.drawable.ic_prev, R.string.prev);
    Notification.Action nextAction = makeNotificationAction(
            LocalMediaBrowserService.ACTION_NEXT, R.drawable.ic_next, R.string.next);
    Notification.Action playAction = makeNotificationAction(
            LocalMediaBrowserService.ACTION_PLAY, R.drawable.ic_play, R.string.play);
    Notification.Action pauseAction = makeNotificationAction(
            LocalMediaBrowserService.ACTION_PAUSE, R.drawable.ic_pause, R.string.pause);

    // 播放時,需要上一個,暫停,下一個。
    mPlayingNotificationBuilder = new Notification.Builder(context)
            .setVisibility(Notification.VISIBILITY_PUBLIC)
            .setSmallIcon(R.drawable.ic_sd_storage_black)
            .addAction(prevAction)
            .addAction(pauseAction)
            .addAction(nextAction);

    // 暫停時,需要上一個,播放,下一個。
    mPausedNotificationBuilder = new Notification.Builder(context)
            .setVisibility(Notification.VISIBILITY_PUBLIC)
            .setSmallIcon(R.drawable.ic_sd_storage_black)
            .addAction(prevAction)
            .addAction(playAction)
            .addAction(nextAction);
}

// 創建 Notification.Action 
private Notification.Action makeNotificationAction(String action, int iconId, int stringId) {
    PendingIntent intent = PendingIntent.getBroadcast(mContext, REQUEST_CODE,
            new Intent(action), PendingIntent.FLAG_UPDATE_CURRENT);
    Notification.Action notificationAction = new Notification.Action.Builder(iconId,
            mContext.getString(stringId), intent)
            .build();
    return notificationAction;
}

private OnCompletionListener mOnCompletionListener = new OnCompletionListener() {
    @Override
    public void onCompletion(MediaPlayer mediaPlayer) {
        if (Log.isLoggable(TAG, Log.DEBUG)) {
            Log.d(TAG, "onCompletion()");
        }
        safeAdvance();
    }
};

3.3.2 處理音頻焦點

音頻焦點的相關內容,在之前的博客Android車載應用開發與分析(6)- 車載多媒體(一)- 音視頻基礎知識與MediaPlayer中已經介紹過了,這里不再贅述。

// 申請音頻焦點
private boolean requestAudioFocus(Runnable onSuccess) {
    int result = mAudioManager.requestAudioFocus(mAudioFocusListener, AudioManager.STREAM_MUSIC,
            AudioManager.AUDIOFOCUS_GAIN);
    if (result == AudioManager.AUDIOFOCUS_REQUEST_GRANTED) {
        onSuccess.run();
        return true;
    }
    Log.e(TAG, "Failed to acquire audio focus");
    return false;
}

// 監聽音頻焦點變化
private OnAudioFocusChangeListener mAudioFocusListener = new OnAudioFocusChangeListener() {
    @Override
    public void onAudioFocusChange(int focus) {
        switch (focus) {
            case AudioManager.AUDIOFOCUS_GAIN:
                resumePlayback();
                break;
            case AudioManager.AUDIOFOCUS_LOSS:
            case AudioManager.AUDIOFOCUS_LOSS_TRANSIENT:
            case AudioManager.AUDIOFOCUS_LOSS_TRANSIENT_CAN_DUCK:
                pausePlayback();
                break;
            default:
                Log.e(TAG, "Unhandled audio focus type: " + focus);
        }
    }
};

3.3.3 播放指定的媒體

在HMI端調用 MediaController.transportControls.playFromMediaId()時觸發。

@Override
public void onPlayFromMediaId(String mediaId, Bundle extras) {
    super.onPlayFromMediaId(mediaId, extras);
    if (Log.isLoggable(TAG, Log.DEBUG)) {
        Log.d(TAG, "onPlayFromMediaId mediaId" + mediaId + " extras=" + extras);
    }
    // 嘗試申請音頻焦點,申請成功則執行 startPlayback
    requestAudioFocus(() -> startPlayback(mediaId));
}

private void startPlayback(String key) {
    if (Log.isLoggable(TAG, Log.DEBUG)) {
        Log.d(TAG, "startPlayback()");
    }
    List<QueueItem> queue = mDataModel.getQueue();
    int idx = 0;
    int foundIdx = -1;
    for (QueueItem item : queue) {
        if (item.getDescription().getMediaId().equals(key)) {
            foundIdx = idx;
            break;
        }
        idx++;
    }
    if (foundIdx == -1) {
        mSession.setPlaybackState(mErrorState);
        return;
    }
    mQueue = new ArrayList<>(queue);
    mCurrentQueueIdx = foundIdx;
    QueueItem current = mQueue.get(mCurrentQueueIdx);
    String path = current.getDescription().getExtras().getString(DataModel.PATH_KEY);
    MediaMetadata metadata = mDataModel.getMetadata(current.getDescription().getMediaId());
    updateSessionQueueState();
    try {
        play(path, metadata);
    } catch (IOException e) {
        Log.e(TAG, "Playback failed.", e);
        mSession.setPlaybackState(mErrorState);
    }
}

private void play(String path, MediaMetadata metadata) throws IOException {
    if (Log.isLoggable(TAG, Log.DEBUG)) {
        Log.d(TAG, "play path=" + path + " metadata=" + metadata);
    }
    mMediaPlayer.reset();
    mMediaPlayer.setDataSource(path);
    mMediaPlayer.prepare();
    if (metadata != null) {
        mSession.setMetadata(metadata);
    }
    // 判斷此時是否獲取到音頻焦點
    boolean wasGrantedAudio = requestAudioFocus(() -> {
        mMediaPlayer.start();
        updatePlaybackStatePlaying();
    });
    // 沒有獲取到音頻焦點,則暫停播放
    if (!wasGrantedAudio) {
        pausePlayback();
    }
}

getMetadata()DataModel中的方法,主要就是將從ContentProvider中查詢到的原始數據,封裝成元數據。下面的代碼,演示了如何封裝。

public MediaMetadata getMetadata(String key) {
    Cursor cursor = null;
    MediaMetadata.Builder metadata = new MediaMetadata.Builder();
    try {
        for (Uri uri : ALL_AUDIO_URI) {
            cursor = mResolver.query(uri, null, AudioColumns.TITLE_KEY + " = ?",
                    new String[]{ key }, null);
            if (cursor != null) {
                int title = cursor.getColumnIndex(AudioColumns.TITLE);
                int artist = cursor.getColumnIndex(AudioColumns.ARTIST);
                int album = cursor.getColumnIndex(AudioColumns.ALBUM);
                int albumId = cursor.getColumnIndex(AudioColumns.ALBUM_ID);
                int duration = cursor.getColumnIndex(AudioColumns.DURATION);

                while (cursor.moveToNext()) {
                    metadata.putString(MediaMetadata.METADATA_KEY_TITLE,
                            cursor.getString(title));
                    metadata.putString(MediaMetadata.METADATA_KEY_ARTIST,
                            cursor.getString(artist));
                    metadata.putString(MediaMetadata.METADATA_KEY_ALBUM,
                            cursor.getString(album));
                    metadata.putLong(MediaMetadata.METADATA_KEY_DURATION,
                            cursor.getLong(duration));

                    String albumArt = null;
                    Uri albumArtUri = ContentUris.withAppendedId(ART_BASE_URI,
                            cursor.getLong(albumId));
                    try {
                        InputStream dummy = mResolver.openInputStream(albumArtUri);
                        albumArt = albumArtUri.toString();
                        dummy.close();
                    } catch (IOException e) {
                        // Ignored because the albumArt is intialized correctly anyway.
                    }
                    metadata.putString(MediaMetadata.METADATA_KEY_ALBUM_ART_URI, albumArt);
                    break;
                }
            }
        }
    } finally {
        if (cursor != null) {
            cursor.close();
        }
    }

    return metadata.build();
}

3.3.4 恢復播放

@Override
public void onPlay() {
    super.onPlay();
    if (Log.isLoggable(TAG, Log.DEBUG)) {
        Log.d(TAG, "onPlay");
    }
    // 每次嘗試播放媒體時都要檢查權限
    if (!Utils.hasRequiredPermissions(mContext)) {
        setMissingPermissionError();
    } else {
        requestAudioFocus(() -> resumePlayback());
    }
}

// 權限檢查錯誤
private void setMissingPermissionError() {
    // 啟動權限申請用的Activity
    Intent prefsIntent = new Intent();
    prefsIntent.setClass(mContext, PermissionsActivity.class);
    prefsIntent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
    PendingIntent pendingIntent = PendingIntent.getActivity(mContext, 0, prefsIntent, 0);

    // 將播放狀態設定未ERROR
    Bundle extras = new Bundle();
    extras.putString(Utils.ERROR_RESOLUTION_ACTION_LABEL,
            mContext.getString(R.string.permission_error_resolve));
    extras.putParcelable(Utils.ERROR_RESOLUTION_ACTION_INTENT, pendingIntent);
    PlaybackState state = new PlaybackState.Builder()
            .setState(PlaybackState.STATE_ERROR, 0, 0)
            .setErrorMessage(mContext.getString(R.string.permission_error))
            .setExtras(extras)
            .build();
    mSession.setPlaybackState(state);
}

private void resumePlayback() {
    if (Log.isLoggable(TAG, Log.DEBUG)) {
        Log.d(TAG, "resumePlayback()");
    }
    // 更新播放狀態
    updatePlaybackStatePlaying();
    if (!mMediaPlayer.isPlaying()) {
        mMediaPlayer.start();
    }
}

播放時還要同步更新播放狀態,并通過MediaSession將狀態告知HMI端。

// 更新播放狀態
private void updatePlaybackStatePlaying() {
    if (!mSession.isActive()) {
        mSession.setActive(true);
    }
    // 更新媒體會話中的狀態。
    CustomAction action = new CustomAction
            .Builder("android.car.media.localmediaplayer.shuffle",
            mContext.getString(R.string.shuffle),
            R.drawable.shuffle)
            .build();
    PlaybackState state = new PlaybackState.Builder()
            .setState(PlaybackState.STATE_PLAYING,
                    mMediaPlayer.getCurrentPosition(), PLAYBACK_SPEED)
            .setActions(PLAYING_ACTIONS)
            .addCustomAction(action)
            .setActiveQueueItemId(mQueue.get(mCurrentQueueIdx).getQueueId())
            .build();
    mSession.setPlaybackState(state);
    // 更新媒體樣式的通知。
    postMediaNotification(mPlayingNotificationBuilder);
}

3.3.5 暫停

@Override
public void onPause() {
    super.onPause();
    if (Log.isLoggable(TAG, Log.DEBUG)) {
        Log.d(TAG, "onPause");
    }
    pausePlayback();
    // 放棄音頻焦點
    mAudioManager.abandonAudioFocus(mAudioFocusListener);
}

private void pausePlayback() {
    if (Log.isLoggable(TAG, Log.DEBUG)) {
        Log.d(TAG, "pausePlayback()");
    }
    long currentPosition = 0;
    if (mMediaPlayer.isPlaying()) {
        currentPosition = mMediaPlayer.getCurrentPosition();
        mMediaPlayer.pause();
    }
    // 更新播放狀態
    PlaybackState state = new PlaybackState.Builder()
            .setState(PlaybackState.STATE_PAUSED, currentPosition, PLAYBACK_SPEED_STOPPED)
            .setActions(PAUSED_ACTIONS)
            .addCustomAction(mShuffle)
            .setActiveQueueItemId(mQueue.get(mCurrentQueueIdx).getQueueId())
            .build();
    mSession.setPlaybackState(state);
    // 更新媒體的Notification狀態。
    postMediaNotification(mPausedNotificationBuilder);
}

3.3.6 終止播放

在Service被銷毀時需要終止播放,并銷毀播放器

// 在Service的onDestroy方法中調用
public void destroy() {
    stopPlayback();
    mNotificationManager.cancelAll();
    mAudioManager.abandonAudioFocus(mAudioFocusListener);
    mMediaPlayer.release();
}

private void stopPlayback() {
    if (Log.isLoggable(TAG, Log.DEBUG)) {
        Log.d(TAG, "stopPlayback()");
    }
    if (mMediaPlayer.isPlaying()) {
        mMediaPlayer.stop();
    }
    // 更新播放狀態
    PlaybackState state = new PlaybackState.Builder()
            .setState(PlaybackState.STATE_STOPPED, PLAYBACK_POSITION_STOPPED,
                    PLAYBACK_SPEED_STOPPED)
            .setActions(STOPPED_ACTIONS)
            .build();
    mSession.setPlaybackState(state);
}

3.3.7 切換下一曲

@Override
public void onSkipToNext() {
    if (Log.isLoggable(TAG, Log.DEBUG)) {
        Log.d(TAG, "onSkipToNext()");
    }
    safeAdvance();
}

private void safeAdvance() {
    try {
        advance();
    } catch (IOException e) {
        Log.e(TAG, "Failed to advance.", e);
        mSession.setPlaybackState(mErrorState);
    }
}

private void advance() throws IOException {
    if (Log.isLoggable(TAG, Log.DEBUG)) {
        Log.d(TAG, "advance()");
    }
    // 如果存在,請轉到下一首歌曲。
    // 請注意,如果您要支持無縫播放,則必須更改此代碼,
    // 以便擁有當前正在播放和正在加載的MediaPlayer,并在它們之間進行切換,同時還調用setNextMediaPlayer。
    if (mQueue != null && !mQueue.isEmpty()) {
        // 當我們跑出當前隊列的末尾時,繼續循環。
        mCurrentQueueIdx = (mCurrentQueueIdx + 1) % mQueue.size();
        playCurrentQueueIndex();
    } else {
        // 終止播放
        stopPlayback();
    }
}

3.3.8 切換下一曲

@Override
public void onSkipToPrevious() {
    if (Log.isLoggable(TAG, Log.DEBUG)) {
        Log.d(TAG, "onSkipToPrevious()");
    }
    safeRetreat();
}

private void safeRetreat() {
    try {
        retreat();
    } catch (IOException e) {
        Log.e(TAG, "Failed to advance.", e);
        mSession.setPlaybackState(mErrorState);
    }
}

private void retreat() throws IOException {
    if (Log.isLoggable(TAG, Log.DEBUG)) {
        Log.d(TAG, "retreat()");
    }
    // 如果有下一首歌,請轉到下一首。請注意,如果要支持無間隙播放,則必須更改此代碼,
    // 以便在調用setNextMediaPlayer的同時,擁有當前正在播放和正在加載的MediaPlayer,并在兩者之間進行切換。
    if (mQueue != null) {
        // 當我們跑完當前隊列的末尾時,繼續循環。
        mCurrentQueueIdx--;
        if (mCurrentQueueIdx < 0) {
            mCurrentQueueIdx = mQueue.size() - 1;
        }
        playCurrentQueueIndex();
    } else {
        // 終止播放
        stopPlayback();
    }
}

3.3.9 播放指定的媒體

@Override
public void onSkipToQueueItem(long id) {
    try {
        mCurrentQueueIdx = (int) id;
        playCurrentQueueIndex();
    } catch (IOException e) {
        Log.e(TAG, "Failed to play.", e);
        mSession.setPlaybackState(mErrorState);
    }
}

private void playCurrentQueueIndex() throws IOException {
    MediaDescription next = mQueue.get(mCurrentQueueIdx).getDescription();
    String path = next.getExtras().getString(DataModel.PATH_KEY);
    MediaMetadata metadata = mDataModel.getMetadata(next.getMediaId());
    play(path, metadata);
}

3.3.10 隨機播放

隨機播放MediaSession.Callback中并沒有定義,所以需要使用MediaSession.Callback中提供的onCustomAction進行拓展。

@Override
public void onCustomAction(String action, Bundle extras) {
    switch (action) {
        case SHUFFLE:
            shuffle();
            break;
        default:
            Log.e(TAG, "Unhandled custom action: " + action);
    }
}

/**
 * 這是shuffle 的一個簡單實現,之前播放的歌曲可能會在shuffle操作后重復。只能從主線程調用此函數。
 * shuffle 可以理解為亂序播放。
 */
private void shuffle() {
    if (Log.isLoggable(TAG, Log.DEBUG)) {
        Log.d(TAG, "Shuffling");
    }
    // 以隨機的形式重建隊列。
    if (mQueue != null && mQueue.size() > 2) {
        QueueItem current = mQueue.remove(mCurrentQueueIdx);
        // 打亂隊列順序
        Collections.shuffle(mQueue);
        mQueue.add(0, current);
        // QueueItem 包含一個隊列 id,當用戶選擇當前播放列表時,該 id 用作鍵。
        // 這意味著必須重建 QueueItems 以設置其新 ID。
        for (int i = 0; i < mQueue.size(); i++) {
            mQueue.set(i, new QueueItem(mQueue.get(i).getDescription(), i));
        }
        mCurrentQueueIdx = 0;
        // 更新MediaSession隊列狀態
        updateSessionQueueState();
    }
}

private void updateSessionQueueState() {
    mSession.setQueueTitle(mContext.getString(R.string.playlist));
    mSession.setQueue(mQueue);
}

以上就是localMediaPlayer中比較重要的源碼,HMI部分的源碼解讀受限于篇幅,之后再單獨寫一篇。

最后編輯于
?著作權歸作者所有,轉載或內容合作請聯系作者
  • 序言:七十年代末,一起剝皮案震驚了整個濱河市,隨后出現的幾起案子,更是在濱河造成了極大的恐慌,老刑警劉巖,帶你破解...
    沈念sama閱讀 227,401評論 6 531
  • 序言:濱河連續發生了三起死亡事件,死亡現場離奇詭異,居然都是意外死亡,警方通過查閱死者的電腦和手機,發現死者居然都...
    沈念sama閱讀 98,011評論 3 413
  • 文/潘曉璐 我一進店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人,你說我怎么就攤上這事。” “怎么了?”我有些...
    開封第一講書人閱讀 175,263評論 0 373
  • 文/不壞的土叔 我叫張陵,是天一觀的道長。 經常有香客問我,道長,這世上最難降的妖魔是什么? 我笑而不...
    開封第一講書人閱讀 62,543評論 1 307
  • 正文 為了忘掉前任,我火速辦了婚禮,結果婚禮上,老公的妹妹穿的比我還像新娘。我一直安慰自己,他們只是感情好,可當我...
    茶點故事閱讀 71,323評論 6 404
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著,像睡著了一般。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發上,一...
    開封第一講書人閱讀 54,874評論 1 321
  • 那天,我揣著相機與錄音,去河邊找鬼。 笑死,一個胖子當著我的面吹牛,可吹牛的內容都是我干的。 我是一名探鬼主播,決...
    沈念sama閱讀 42,968評論 3 439
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場噩夢啊……” “哼!你這毒婦竟也來了?” 一聲冷哼從身側響起,我...
    開封第一講書人閱讀 42,095評論 0 286
  • 序言:老撾萬榮一對情侶失蹤,失蹤者是張志新(化名)和其女友劉穎,沒想到半個月后,有當地人在樹林里發現了一具尸體,經...
    沈念sama閱讀 48,605評論 1 331
  • 正文 獨居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內容為張勛視角 年9月15日...
    茶點故事閱讀 40,551評論 3 354
  • 正文 我和宋清朗相戀三年,在試婚紗的時候發現自己被綠了。 大學時的朋友給我發了我未婚夫和他白月光在一起吃飯的照片。...
    茶點故事閱讀 42,720評論 1 369
  • 序言:一個原本活蹦亂跳的男人離奇死亡,死狀恐怖,靈堂內的尸體忽然破棺而出,到底是詐尸還是另有隱情,我是刑警寧澤,帶...
    沈念sama閱讀 38,242評論 5 355
  • 正文 年R本政府宣布,位于F島的核電站,受9級特大地震影響,放射性物質發生泄漏。R本人自食惡果不足惜,卻給世界環境...
    茶點故事閱讀 43,961評論 3 345
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧,春花似錦、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 34,358評論 0 25
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至,卻和暖如春,著一層夾襖步出監牢的瞬間,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 35,612評論 1 280
  • 我被黑心中介騙來泰國打工, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留,地道東北人。 一個月前我還...
    沈念sama閱讀 51,330評論 3 390
  • 正文 我出身青樓,卻偏偏與公主長得像,于是被迫代替她去往敵國和親。 傳聞我的和親對象是個殘疾皇子,可洞房花燭夜當晚...
    茶點故事閱讀 47,690評論 2 370