Android и звук: как делать правильно

в 12:47, , рубрики: android, android audio, Android Auto, Разработка под android

В статье рассматривается архитектура и API для создания приложений, воспроизводящих музыку. Мы напишем простое приложение, которое будет проигрывать небольшой заранее заданный плейлист, но «по-взрослому» — с использованием официально рекомендуемых практик. Мы применим MediaSession и MediaController для организации единой точки доступа к медиаплееру, и MediaBrowserService для поддержки Android Auto. А также оговорим ряд шагов, которые обязательны, если мы не хотим вызвать ненависти пользователя.

В первом приближении задача выглядит просто: в activity создаем MediaPlayer, при нажатии кнопки Play начинаем воспроизведение, а Stop — останавливаем. Все прекрасно работает ровно до тех пор, пока пользователь не выйдет из activity. Очевидным решением будет перенос MediaPlayer в сервис. Однако теперь у нас встают вопросы организации доступа к плееру из UI. Нам придется реализовать binded-сервис, придумать для него API, который позволил бы управлять плеером и получать от него события. Но это только половина дела: никто, кроме нас, не знает API сервиса, соответственно, наша activity будет единственным средством управления. Пользователю придется зайти в приложение и нажать Pause, если он хочет позвонить. В идеале нам нужен унифицированный способ сообщить Android, что наше приложение является плеером, им можно управлять и что в настоящий момент мы играем такой-то трек из такого-то альбома. Чтобы система со своей стороны подсобила нам с UI. В Lollipop (API 21) был представлен такой механизм в виде классов MediaSession и MediaController. Немногим позже в support library появились их близнецы MediaSessionCompat и MediaControllerCompat.

Следует сразу отметить, что MediaSession не имеет отношения к воспроизведению звука, он только об управлении плеером и его метаданными.

MediaSession

Итак, мы создаем экземпляр MediaSession в сервисе, заполняем его сведениями о нашем плеере, его состоянии и отдаем MediaSession.Callback, в котором определены методы onPlay, onPause, onStop, onSkipToNext и прочие. В эти методы мы помещаем код управления MediaPlayer (в примере воспользуемся ExoPlayer). Наша цель, чтобы события и от аппаратных кнопок, и из окна блокировки, и с часов под Android Wear вызывали эти методы.

Полностью рабочий код доступен на GitHub (ветка master). В статьи приводятся только переработанные выдержки из него.

// Закешируем билдеры

// ...метаданных трека
final MediaMetadataCompat.Builder metadataBuilder = new MediaMetadataCompat.Builder();

// ...состояния плеера
// Здесь мы указываем действия, которые собираемся обрабатывать в коллбэках. 
// Например, если мы не укажем ACTION_PAUSE,
// то нажатие на паузу не вызовет onPause.
// ACTION_PLAY_PAUSE обязателен, иначе не будет работать
// управление с Android Wear!
final PlaybackStateCompat.Builder stateBuilder = new PlaybackStateCompat.Builder()
    .setActions(
            PlaybackStateCompat.ACTION_PLAY
                | PlaybackStateCompat.ACTION_STOP
                | PlaybackStateCompat.ACTION_PAUSE
                | PlaybackStateCompat.ACTION_PLAY_PAUSE
                | PlaybackStateCompat.ACTION_SKIP_TO_NEXT
                | PlaybackStateCompat.ACTION_SKIP_TO_PREVIOUS);

MediaSessionCompat mediaSession;

@Override
public void onCreate() {
    super.onCreate();

    // "PlayerService" - просто tag для отладки
    mediaSession = new MediaSessionCompat(this, "PlayerService");

    // FLAG_HANDLES_MEDIA_BUTTONS - хотим получать события от аппаратных кнопок
    // (например, гарнитуры)
    // FLAG_HANDLES_TRANSPORT_CONTROLS - хотим получать события от кнопок 
    // на окне блокировки
    mediaSession.setFlags(
        MediaSessionCompat.FLAG_HANDLES_MEDIA_BUTTONS
            | MediaSessionCompat.FLAG_HANDLES_TRANSPORT_CONTROLS);

    // Отдаем наши коллбэки
    mediaSession.setCallback(mediaSessionCallback);

    Context appContext = getApplicationContext()

    // Укажем activity, которую запустит система, если пользователь
    // заинтересуется подробностями данной сессии
    Intent activityIntent = new Intent(appContext, MainActivity.class);
    mediaSession.setSessionActivity(
        PendingIntent.getActivity(appContext, 0, activityIntent, 0));
}

@Override
public void onDestroy() {
    super.onDestroy();
    // Ресурсы освобождать обязательно
    mediaSession.release();
}

MediaSessionCompat.Callback mediaSessionCallback = new MediaSessionCompat.Callback() {
    @Override
    public void onPlay() {
        MusicRepository.Track track = musicRepository.getCurrent();

        // Заполняем данные о треке
        MediaMetadataCompat metadata = metadataBuilder
            .putBitmap(MediaMetadataCompat.METADATA_KEY_ART,
                BitmapFactory.decodeResource(getResources(), track.getBitmapResId()));
            .putString(MediaMetadataCompat.METADATA_KEY_TITLE, track.getTitle());
            .putString(MediaMetadataCompat.METADATA_KEY_ALBUM, track.getArtist());
            .putString(MediaMetadataCompat.METADATA_KEY_ARTIST, track.getArtist());
            .putLong(MediaMetadataCompat.METADATA_KEY_DURATION, track.getDuration())
            .build();
        mediaSession.setMetadata(metadata);

        // Указываем, что наше приложение теперь активный плеер и кнопки 
        // на окне блокировки должны управлять именно нами
        mediaSession.setActive(true);

        // Сообщаем новое состояние
        mediaSession.setPlaybackState(
            stateBuilder.setState(PlaybackStateCompat.STATE_PLAYING, 
                PlaybackStateCompat.PLAYBACK_POSITION_UNKNOWN, 1).build());

        // Загружаем URL аудио-файла в ExoPlayer
        prepareToPlay(track.getUri());

        // Запускаем воспроизведение
        exoPlayer.setPlayWhenReady(true);
    }

    @Override
    public void onPause() {
        // Останавливаем воспроизведение
        exoPlayer.setPlayWhenReady(false);

        // Сообщаем новое состояние
        mediaSession.setPlaybackState(
            stateBuilder.setState(PlaybackStateCompat.STATE_PAUSED, 
                PlaybackStateCompat.PLAYBACK_POSITION_UNKNOWN, 1).build());
    }

    @Override
    public void onStop() {
        // Останавливаем воспроизведение
        exoPlayer.setPlayWhenReady(false);

        // Все, больше мы не "главный" плеер, уходим со сцены
        mediaSession.setActive(false);

        // Сообщаем новое состояние
        mediaSession.setPlaybackState(
            stateBuilder.setState(PlaybackStateCompat.STATE_STOPPED,
                PlaybackStateCompat.PLAYBACK_POSITION_UNKNOWN, 1).build());
    }
}

Для доступа извне к MediaSession требуется токен. Для этого научим сервис его отдавать

@Override
public IBinder onBind(Intent intent) {
    return new PlayerServiceBinder();
}

public class PlayerServiceBinder extends Binder {
    public MediaSessionCompat.Token getMediaSessionToken() {
        return mediaSession.getSessionToken();
    }
}

и пропишем в манифест

<service
    android:name=".service.PlayerService"
    android:exported="false">
</service>

MediaController

Теперь реализуем activity с кнопками управления. Создаем экземпляр MediaController и передаем в конструктор полученный из сервиса токен.

MediaController предоставляет как методы управления плеером play, pause, stop, так и коллбэки onPlaybackStateChanged(PlaybackState state) и onMetadataChanged(MediaMetadata metadata). К одному MediaSession могут подключиться несколько MediaController, таким образом можно легко обеспечить консистентность состояний кнопок во всех окнах.

PlayerService.PlayerServiceBinder playerServiceBinder;
MediaControllerCompat mediaController;

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

    final Button playButton = (Button) findViewById(R.id.play);
    final Button pauseButton = (Button) findViewById(R.id.pause);
    final Button stopButton = (Button) findViewById(R.id.stop);

    bindService(new Intent(this, PlayerService.class), new ServiceConnection() {
        @Override
        public void onServiceConnected(ComponentName name, IBinder service) {
            playerServiceBinder = (PlayerService.PlayerServiceBinder) service;
            try {
                mediaController = new MediaControllerCompat(
                    MainActivity.this, playerServiceBinder.getMediaSessionToken());
                mediaController.registerCallback(
                    new MediaControllerCompat.Callback() {
                        @Override
                        public void onPlaybackStateChanged(PlaybackStateCompat state) {
                            if (state == null)
                                return;
                            boolean playing = 
                                state.getState() == PlaybackStateCompat.STATE_PLAYING;
                            playButton.setEnabled(!playing);
                            pauseButton.setEnabled(playing);
                            stopButton.setEnabled(playing);
                        }
                    }
                );
            }
            catch (RemoteException e) {
                mediaController = null;
            }
        }

        @Override
        public void onServiceDisconnected(ComponentName name) {
            playerServiceBinder = null;
            mediaController = null;
        }
    }, BIND_AUTO_CREATE);

    playButton.setOnClickListener(new View.OnClickListener() {
        @Override
        public void onClick(View v) {
            if (mediaController != null)
                mediaController.getTransportControls().play();
        }
    });

    pauseButton.setOnClickListener(new View.OnClickListener() {
        @Override
        public void onClick(View v) {
            if (mediaController != null)
                mediaController.getTransportControls().pause();
        }
    });

    stopButton.setOnClickListener(new View.OnClickListener() {
        @Override
        public void onClick(View v) {
            if (mediaController != null)
                mediaController.getTransportControls().stop();
        }
    });
}

Наша activity работает, но ведь идея исходно была, чтобы из окна блокировки тоже можно было управлять. И тут мы приходим к важному моменту: в API 21 полностью переделали окно блокировки, теперь там отображаются уведомления и кнопки управления плеером надо делать через уведомления. К этому мы вернемся позже, давайте пока рассмотрим старое окно блокировки.

Как только мы вызываем mediaSession.setActive(true), система магическим образом присоединяется без всяких токенов к MediaSession и показывает кнопки управления на фоне картинки из метаданных.

Однако в силу исторических причин события о нажатии кнопок приходят не напрямую в MediaSession, а в виде бродкастов. Соответственно, нам надо еще подписаться на эти бродкасты и перебросить их в MediaSession.

MediaButtonReceiver

Для этого разработчики Android любезно предлагают нам воспользоваться готовым ресивером MediaButtonReceiver.

Добавим его в манифест

<receiver android:name="android.support.v4.media.session.MediaButtonReceiver">
    <intent-filter>
        <action android:name="android.intent.action.MEDIA_BUTTON" />
    </intent-filter>
</receiver>

MediaButtonReceiver при получении события ищет в приложении сервис, который также принимает "android.intent.action.MEDIA_BUTTON" и перенаправляет его туда. Поэтому добавим аналогичный интент-фильтр в сервис

<service
    android:name=".service.PlayerService"
    android:exported="false">
    <intent-filter>
        <action android:name="android.intent.action.MEDIA_BUTTON" />
    </intent-filter>
</service>

Если подходящий сервис не найден или их несколько, будет выброшен IllegalStateException.

Теперь в сервис добавим

@Override
public int onStartCommand(Intent intent, int flags, int startId) {
    MediaButtonReceiver.handleIntent(mediaSession, intent);
    return super.onStartCommand(intent, flags, startId);
}

Метод handleIntent анализирует коды кнопок из intent и вызывает соответствующие коллбэки в mediaSession. Получилось немного плясок с бубном, но зато почти без написания кода.

На системах с API >= 21 система не использует бродкасты для отправки событий нажатия на кнопки и вместо этого напрямую обращается в MediaSession. Однако, если наш MediaSession неактивен (setActive(false)), его пробудят бродкастом. И для того, чтобы этот механизм работал, надо сообщить MediaSession, в какой ресивер отправлять бродкасты.
Добавим в onCreate сервиса

Intent mediaButtonIntent = new Intent(
    Intent.ACTION_MEDIA_BUTTON, null, appContext, MediaButtonReceiver.class);
mediaSession.setMediaButtonReceiver(
    PendingIntent.getBroadcast(appContext, 0, mediaButtonIntent, 0));

На системах с API < 21 метод setMediaButtonReceiver ничего не делает.

Ок, хорошо. Запускаем, переходим в окно блокировки и… ничего нет. Потому что мы забыли важный момент, без которого ничего не работает, — получение аудиофокуса.

Аудиофокус

Всегда существует вероятность, что несколько приложений захотят одновременно воспроизвести звук. Или поступил входящий звонок и надо срочно остановить музыку. Для решения этих проблем в системный сервис AudioManager включили возможность запроса аудиофокуса. Аудиофокус является правом воспроизводить звук и выдается только одному приложению в каждый момент времени. Если приложению отказали в предоставлении аудиофокуса или забрали его позже, воспроизведение звука необходимо остановить. Как правило фокус всегда предоставляется, то есть когда у приложения нажимают play, все остальные приложения замолкают. Исключение бывает только при активном телефонном разговоре. Технически нас никто не заставляет получать фокус, но мы же не хотим раздражать пользователя? Ну и плюс окно блокировки игнорирует приложения без аудиофокуса.
Фокус необходимо запрашивать в onPlay() и освобождать в onStop().

Получаем AudioManager в onCreate

@Override
public void onCreate() {
    super.onCreate();
    audioManager = (AudioManager) getSystemService(Context.AUDIO_SERVICE);
    ...
}

Запрашиваем фокус в onPlay

@Override
public void onPlay() {
    ...

    int audioFocusResult = audioManager.requestAudioFocus(
        audioFocusChangeListener, 
        AudioManager.STREAM_MUSIC, 
        AudioManager.AUDIOFOCUS_GAIN);
    if (audioFocusResult != AudioManager.AUDIOFOCUS_REQUEST_GRANTED)
        return;

    // Аудиофокус надо получить строго до вызова setActive!
    mediaSession.setActive(true);

    ...
}

И освобождаем в onStop

@Override
public void onStop() {
    ...
    audioManager.abandonAudioFocus(audioFocusChangeListener);
    ...
}

При запросе фокуса мы отдали коллбэк

private AudioManager.OnAudioFocusChangeListener audioFocusChangeListener = 
    new AudioManager.OnAudioFocusChangeListener() {
        @Override
        public void onAudioFocusChange(int focusChange) {
            switch (focusChange) {
                case AudioManager.AUDIOFOCUS_GAIN:
                    // Фокус предоставлен.
                    // Например, был входящий звонок и фокус у нас отняли.
                    // Звонок закончился, фокус выдали опять
                    // и мы продолжили воспроизведение.
                    mediaSessionCallback.onPlay();
                    break;
                case AudioManager.AUDIOFOCUS_LOSS_TRANSIENT_CAN_DUCK:
                    // Фокус отняли, потому что какому-то приложению надо
                    // коротко "крякнуть".
                    // Например, проиграть звук уведомления или навигатору сказать
                    // "Через 50 метров поворот направо".
                    // В этой ситуации нам разрешено не останавливать вопроизведение, 
                    // но надо снизить громкость.
                    // Приложение не обязано именно снижать громкость,
                    // можно встать на паузу, что мы здесь и делаем.
                    mediaSessionCallback.onPause();
                    break;
                default:
                    // Фокус совсем отняли.
                    mediaSessionCallback.onPause();
                    break;
            }
        }
    };

Все, теперь окно блокировки на системах с API < 21 работает.

Так это выглядит

Android 4.4
Android 4.4

MIUI 8 (базируется на Android 6, то есть теоретически окно блокировки не должно отображать наш трек, но здесь уже сказывается кастомизация MIUI).
MIUI 8

Уведомления

Однако, как ранее упоминалось, начиная с API 21 окно блокировки научилось отображать уведомления. И по этому радостному поводу, вышеописанный механизм был выпилен. Так что теперь давайте еще формировать уведомления. Это не только требование современных систем, но и просто удобно, поскольку пользователю не придется выключать и включать экран, чтобы просто нажать паузу. Заодно применим это уведомление для перевода сервиса в foreground-режим.

Нам не придется рисовать кастомное уведомление, поскольку Android предоставляет специальный стиль для плееров — Notification.MediaStyle.

Добавим в сервис два метода

void refreshNotificationAndForegroundStatus(int playbackState) {
    switch (playbackState) {
        case PlaybackStateCompat.STATE_PLAYING: {
            startForeground(NOTIFICATION_ID, getNotification(playbackState));
            break;
        }
        case PlaybackStateCompat.STATE_PAUSED: {
            // На паузе мы перестаем быть foreground, однако оставляем уведомление,
            // чтобы пользователь мог play нажать
            NotificationManagerCompat.from(PlayerService.this)
                .notify(NOTIFICATION_ID, getNotification(playbackState));
            stopForeground(false);
            break;
        }
        default: {
            // Все, можно прятать уведомление
            stopForeground(true);
            break;
        }
    }
}

Notification getNotification(int playbackState) {
    // MediaStyleHelper заполняет уведомление метаданными трека.
    // Хелпер любезно написал Ian Lake / Android Framework Developer at Google
    // и выложил здесь: https://gist.github.com/ianhanniballake/47617ec3488e0257325c
    NotificationCompat.Builder builder = MediaStyleHelper.from(this, mediaSession);

    // Добавляем кнопки

    // ...на предыдущий трек
    builder.addAction(
        new NotificationCompat.Action(
            android.R.drawable.ic_media_previous, getString(R.string.previous), 
            MediaButtonReceiver.buildMediaButtonPendingIntent(
                this, 
                PlaybackStateCompat.ACTION_SKIP_TO_PREVIOUS)));

    // ...play/pause
    if (playbackState == PlaybackStateCompat.STATE_PLAYING)
        builder.addAction(
            new NotificationCompat.Action(
                android.R.drawable.ic_media_pause, getString(R.string.pause),
                MediaButtonReceiver.buildMediaButtonPendingIntent(
                    this, 
                    PlaybackStateCompat.ACTION_PLAY_PAUSE)));
    else
        builder.addAction(
            new NotificationCompat.Action(
                android.R.drawable.ic_media_play, getString(R.string.play), 
                MediaButtonReceiver.buildMediaButtonPendingIntent(
                    this, 
                    PlaybackStateCompat.ACTION_PLAY_PAUSE)));

    // ...на следующий трек
    builder.addAction(
        new NotificationCompat.Action(android.R.drawable.ic_media_next, getString(R.string.next), 
            MediaButtonReceiver.buildMediaButtonPendingIntent(
                this, 
                PlaybackStateCompat.ACTION_SKIP_TO_NEXT)));

    builder.setStyle(new NotificationCompat.MediaStyle()
            // В компактном варианте показывать Action с данным порядковым номером.
            // В нашем случае это play/pause.
            .setShowActionsInCompactView(1)
            // Отображать крестик в углу уведомления для его закрытия.
            // Это связано с тем, что для API < 21 из-за ошибки во фреймворке
            // пользователь не мог смахнуть уведомление foreground-сервиса
            // даже после вызова stopForeground(false).
            // Так что это костыль.
            // На API >= 21 крестик не отображается, там просто смахиваем уведомление.
            .setShowCancelButton(true)
            // Указываем, что делать при нажатии на крестик или смахивании
            .setCancelButtonIntent(
                MediaButtonReceiver.buildMediaButtonPendingIntent(
                    this, 
                    PlaybackStateCompat.ACTION_STOP))
            // Передаем токен. Это важно для Android Wear. Если токен не передать,
            // кнопка на Android Wear будет отображаться, но не будет ничего делать
            .setMediaSession(mediaSession.getSessionToken()));

    builder.setSmallIcon(R.mipmap.ic_launcher);
    builder.setColor(ContextCompat.getColor(this, R.color.colorPrimaryDark));

    // Не отображать время создания уведомления. В нашем случае это не имеет смысла
    builder.setShowWhen(false);

    // Это важно. Без этой строчки уведомления не отображаются на Android Wear 
    // и криво отображаются на самом телефоне.
    builder.setPriority(NotificationCompat.PRIORITY_HIGH);

    // Не надо каждый раз вываливать уведомление на пользователя
    builder.setOnlyAlertOnce(true);

    return builder.build();
}

И добавим вызов refreshNotificationAndForegroundStatus(int playbackState) во все коллбэки MediaSession.

Так это выглядит

Android 4.4
Android 4.4

Android 7.1.1
Android 7.1.1

Android Wear
Android Wear

Started service

В принципе у нас уже все работает, но есть засада: наша activity запускает сервис через binding. Соответственно, после того, как activity отцепится от сервиса, он будет уничтожен и музыка остановится. Поэтому нам надо в onPlay добавить

startService(new Intent(getApplicationContext(), PlayerService.class));

Никакой обработки в onStartCommand не надо, наша цель не дать системе убить сервис после onUnbind.

А в onStop добавить

stopSelf();

В случае, если к сервису привязаны клиенты, stopSelf ничего не делает, только взводит флаг, что после onUnbind сервис можно уничтожить. Так что это вполне безопасно.

ACTION_AUDIO_BECOMING_NOISY

Продолжаем полировать сервис. Допустим пользователь слушает музыку в наушниках и выдергивает их. Если эту ситуацию специально не обработать, звук переключится на динамик телефона и его услышат все окружающие. Было бы хорошо в этом случае встать на паузу.
Для этого в Android есть специальный бродкаст AudioManager.ACTION_AUDIO_BECOMING_NOISY.
Добавим в onPlay

registerReceiver(
    becomingNoisyReceiver, 
    new IntentFilter(AudioManager.ACTION_AUDIO_BECOMING_NOISY));

В onPause

unregisterReceiver(becomingNoisyReceiver);

И по факту события встаем на паузу

final BroadcastReceiver becomingNoisyReceiver = new BroadcastReceiver() {
    @Override
    public void onReceive(Context context, Intent intent) {
        if (AudioManager.ACTION_AUDIO_BECOMING_NOISY.equals(intent.getAction())) {
            mediaSessionCallback.onPause();
        }
    }
};

Android Auto

Начиная с API 21 появилась возможность интегрировать телефон с экраном в автомобиле. Для этого необходимо поставить приложение Android Auto и подключить телефон к совместимому автомобилю. На экран автомобиля будет выведены крупные контролы для управления навигацией, сообщениями и музыкой. Давайте предложим Android Auto наше приложение в качестве поставщика музыки.

Если у вас под рукой нет совместимого автомобиля, что, согласитесь, иногда бывает, можно просто запустить приложение и экран самого телефона будет работать в качестве автомобильного.

Исходный код выложен на GitHub (ветка MediaBrowserService).

Прежде всего надо указать в манифесте, что наше приложение совместимо с Android Auto.
Добавим в манифест

<meta-data android:name="com.google.android.gms.car.application"
            android:resource="@xml/automotive_app_desc"/>

Здесь automotive_app_desc — это ссылка на файл automotive_app_desc.xml, который надо создать в папке xml

<automotiveApp>
    <uses name="media" />
</automotiveApp>

Преобразуем наш сервис в MediaBrowserService. Его задача, помимо всего ранее сделанного, отдавать токен в Android Auto и предоставлять плейлисты.

Поправим декларацию сервиса в манифесте

<service
    android:name=".service.PlayerService"
    android:exported="true"
    tools:ignore="ExportedService" >
    <intent-filter>
        <action android:name="android.media.browse.MediaBrowserService"/>
        <action android:name="android.intent.action.MEDIA_BUTTON" />
    </intent-filter>
</service>

Во-первых, теперь наш сервис экспортируется, поскольку к нему будут подсоединяться снаружи.

И, во-вторых, добавлен интент-фильтр android.media.browse.MediaBrowserService.

Меняем родительский класс на MediaBrowserServiceCompat.

Поскольку теперь сервис должен отдавать разные IBinder в зависимости от интента, поправим onBind

@Override
public IBinder onBind(Intent intent) {
    if (SERVICE_INTERFACE.equals(intent.getAction())) {
        return super.onBind(intent);
    }
    return new PlayerServiceBinder();
}

Имплементируем два абстрактных метода, возвращающие плейлисты

@Override
public BrowserRoot onGetRoot(@NonNull String clientPackageName, 
    int clientUid, @Nullable Bundle rootHints) 
{
    // Здесь мы возвращаем rootId - в нашем случае "Root".
    // Значение RootId непринципиально, оно будет просто передано
    // в onLoadChildren как parentId.
    // Идея здесь в том, что мы можем проверить clientPackageName и
    // в зависимости от того, что это за приложение, вернуть ему
    // разные плейлисты.
    // Если с неким приложением мы не хотим работать вообще,
    // можно написать return null;
    return new BrowserRoot("Root", null);
}

@Override
public void onLoadChildren(@NonNull String parentId, 
    @NonNull Result<List<MediaBrowserCompat.MediaItem>> result) 
{
    // Возвращаем плейлист. Элементы могут быть FLAG_PLAYABLE 
    // или FLAG_BROWSABLE.
    // Элемент FLAG_PLAYABLE нас могут попросить проиграть,
    // а FLAG_BROWSABLE отобразится как папка и, если пользователь
    // в нее попробует войти, то вызовется onLoadChildren с parentId
    // данного browsable-элемента.
    // То есть мы можем построить виртуальную древовидную структуру, 
    // а не просто список треков.

    ArrayList<MediaBrowserCompat.MediaItem> data = 
        new ArrayList<>(musicRepository.getTrackCount());

    MediaDescriptionCompat.Builder descriptionBuilder = 
        new MediaDescriptionCompat.Builder();
    for (int i = 0; i < musicRepository.getTrackCount() - 1; i++) {
        MusicRepository.Track track = musicRepository.getTrackByIndex(i);
        MediaDescriptionCompat description = descriptionBuilder
            .setDescription(track.getArtist())
            .setTitle(track.getTitle())
            .setSubtitle(track.getArtist())
            // Картинки отдавать только как Uri
            //.setIconBitmap(...)
            .setIconUri(new Uri.Builder()
                .scheme(ContentResolver.SCHEME_ANDROID_RESOURCE)
                .authority(getResources()
                    .getResourcePackageName(track.getBitmapResId()))
                .appendPath(getResources()
                    .getResourceTypeName(track.getBitmapResId()))
                .appendPath(getResources()
                    .getResourceEntryName(track.getBitmapResId()))
                .build())
            .setMediaId(Integer.toString(i))
            .build();
        data.add(new MediaBrowserCompat.MediaItem(description, FLAG_PLAYABLE));
    }
    result.sendResult(data);
}

И, наконец, имплементируем новый коллбэк MediaSession

@Override
public void onPlayFromMediaId(String mediaId, Bundle extras) {
    playTrack(musicRepository.getTrackByIndex(Integer.parseInt(mediaId)));
}

Здесь mediaId — это тот, который мы отдали в setMediaId в onLoadChildren.

Так это выглядит

Плейлист
Плейлист

Трек
Трек

Вот мы и добрались до конца. В целом тема эта довольно запутанная. Плюс отличия реализаций на разных API level и у разных производителей. Очень надеюсь, что я ничего не упустил. Но если у вас есть, что исправить и добавить, с удовольствием внесу изменения в статью.

Еще очень рекомендую к просмотру доклад Ian Lake. Доклад от 2015 года, но вполне актуален.

Ура!

Автор: SergeyVin

Источник

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js