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 и onStop


    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 года, но вполне актуален.


    Ура!

    Поделиться публикацией
    Реклама помогает поддерживать и развивать наши сервисы

    Подробнее
    Реклама
    Комментарии 0

    Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.