Добрый день, я Android Team Lead в компании по разработке мобильных приложений Trinity Digital. Наша компания существует на рынке три года и в 2015-м мы вошли в топ-10 лучших разработчиков Москвы. Наш второй офис находится в Петрозаводске, там я и руковожу командой Android-разработчиков. В этой статье хочу рассказать о том, как быстро добавить в приложение возможность взаимодействовать с устройством Google Chromecast, а именно — отправлять один видео-файл на воспроизведение и управлять просмотром. Получить устройство удалось благодаря конкурсу Device Lab от Google.

Если вы не знакомы с устройством Chromecast, то можете почитать обзорную статью вот тут. Несмотря на то, что эта статья про первую версию Chromecast, она даст общее представление о всем семействе устройств и принципе их работы.

Приложение, на примере которого я расскажу о технологии — «Рецепты Юлии Высоцкой».



Статья автора Андрея Хитрого, в рамках конкурса «Device Lab от Google».

Это один из самых успешных наших проектов, имеющий около полумиллиона пользователей. Приложение представляет собой сборник более чем 1500 рецептов, в том числе в видео-формате, что и позволило мне интегрировать в него Google Chromecast. Итак, начнём.

Первые шаги


Приступим к интеграции Chromecast в наше Android приложение. Мы рассмотрим простейший случай, когда в приложении имеется Activity, содержащая некоторый видео контент (один видео файл). Для этого воспользуемся библиотекой CastCompanionLibrary-android, которая упрощает интеграцию до нескольких шагов.

Для начала создадим пустой проект в Android Studio и добавим в файл app/build.gradle зависимость.

dependencies {
    compile 'com.google.android.libraries.cast.companionlibrary:ccl:2.8.4'
}

Библиотека использует синглтон VideoCastManager для организации взаимодействия. В первую очередь, мы должны инициализировать этот синглтон при помощи объекта конфигурации. Большинство опций прокомментировано в коде.

// Core.java
public class Core extends Application {

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

        CastConfiguration options = new CastConfiguration.Builder("CC1AD845")
                .enableAutoReconnect() // Восстановление соединения после разрыва
                .enableDebug() // Разрешаем отладку, чтобы логи были подробными
                .enableLockScreen() // Возможность управления на экране блокировки
                .enableNotification() // Возможность управления через оповещение + возможные действия
                .addNotificationAction(CastConfiguration.NOTIFICATION_ACTION_REWIND, false)
                .addNotificationAction(CastConfiguration.NOTIFICATION_ACTION_PLAY_PAUSE, true)
                .addNotificationAction(CastConfiguration.NOTIFICATION_ACTION_DISCONNECT, true)
                .enableWifiReconnection() // Восстановление, после смены wifi сети
                .setForwardStep(10) // Шаг перемотки в секундах
                .build();
        VideoCastManager.initialize(this, options);
    }
}

В конструктор CastConfiguration мы передаем идентификатор Media Receiver. Этот идентификатор определяет стилизацию плеера Chromecast. Мы не будем останавливаться на нем, более подробно можно почитать на официальной странице. Информацию о других опциях VideoCastManager можно найти в github.

Изменение манифеста приложения


Стоит отметить, что для корректной работы управления через оповещения и на заблокированном экране. необходимо добавить в манифест приложения объявления необходимых Activities, Services и Receivers.

В примере, который предлагают разработчики, я не нашел этих объявлений, что довольно странно, т.к. без них управление из области оповещений и на заблокированном экране не будет работать или будет работать не корректно.

<receiver android:name="com.google.android.libraries.cast.companionlibrary.remotecontrol.VideoIntentReceiver" >
    <intent-filter>
        <action android:name="android.media.AUDIO_BECOMING_NOISY" />
        <action android:name="android.intent.action.MEDIA_BUTTON" />
        <action android:name="com.google.android.libraries.cast.companionlibrary.action.toggleplayback" />
        <action android:name="com.google.android.libraries.cast.companionlibrary.action.stop" />
    </intent-filter>
</receiver>

<service
  android:name="com.google.android.libraries.cast.companionlibrary.notification.VideoCastNotificationService"
    android:exported="false" >
    <intent-filter>
        <action android:name="com.google.android.libraries.cast.companionlibrary.action.toggleplayback" />
        <action android:name="com.google.android.libraries.cast.companionlibrary.action.stop" />
        <action android:name="com.google.android.libraries.cast.companionlibrary.action.notificationvisibility" />
    </intent-filter>
</service>

<service android:name="com.google.android.libraries.cast.companionlibrary.cast.reconnection.ReconnectionService"/>
<activity android:name="com.google.android.libraries.cast.companionlibrary.cast.player.VideoCastControllerActivity"/>


Воспроизведение одного файла


Для организации взаимодействия между Chromecast и приложением Android библиотека использует класс VideoCastConsumerImpl. Изначально он рассчитан для работы с очередью видеофайлов, но, т.к. наше приложение не предполагает наличие очереди, мы несколько изменим этот класс.

// SingleVideoCastConsumer.java
public abstract class SingleVideoCastConsumer extends VideoCastConsumerImpl {
    private AppCompatActivity activity;
    private final String videoUrl;
    private final String title;
    private final String subtitle;
    private final String imageUrl;
    private final String contentType;

    public SingleVideoCastConsumer(AppCompatActivity activity, String videoUrl, String title, String subtitle, String imageUrl, String contentType) {
        this.activity = activity;
        this.videoUrl = videoUrl;
        this.title = title;
        this.subtitle = subtitle;
        this.imageUrl = imageUrl;
        this.contentType = contentType;
    }

    public abstract void onPlaybackFinished();
    public abstract void onQueueLoad(final MediaQueueItem[] items, final int startIndex, final int repeatMode,
                                     final JSONObject customData) throws TransientNetworkDisconnectionException, NoConnectionException;

    @Override
    public void onMediaQueueUpdated(List<MediaQueueItem>queueItems, MediaQueueItem item, int repeatMode, boolean shuffle) {
        // Если в очереди больше нет элементов, то оповещаем о завершении воспроизведения
        if(queueItems != null && queueItems.size() == 0) {
            onPlaybackFinished();
        }
    }

    @Override
    public void onApplicationConnected(ApplicationMetadata appMetadata, String sessionId,
                                       boolean wasLaunched) {
        // Изменить состояние кнопки Cast
        activity.invalidateOptionsMenu();
        // Создаем метаданные типа видеофайл
        MediaMetadata movieMetadata = new MediaMetadata(MediaMetadata.MEDIA_TYPE_MOVIE);

        // Заголовок
        movieMetadata.putString(MediaMetadata.KEY_TITLE, title);

        // Подзаголовок
        movieMetadata.putString(MediaMetadata.KEY_SUBTITLE, subtitle);

        // Картинка, которая будет показана при загрузке
        movieMetadata.addImage(new WebImage(Uri.parse(imageUrl)));

        // Создаем информацию о медиа контенте
        MediaInfo info = new MediaInfo.Builder(videoUrl)
                .setStreamType(MediaInfo.STREAM_TYPE_BUFFERED)
                .setContentType(contentType)
                .setMetadata(movieMetadata)
                .build();

        // Создаем элемент очереди медиафайлов
        MediaQueueItem item = new MediaQueueItem.Builder(info).build();
        try {
            // Обновляем очередь Chromecast, она всегда содержит 1 элемент, т.к. у нас всего 1 видеофайл
            onQueueLoad(new MediaQueueItem[]{item}, 0, MediaStatus.REPEAT_MODE_REPEAT_OFF, null);
        } catch (TransientNetworkDisconnectionException e) {
            e.printStackTrace();
        } catch (NoConnectionException e) {
            e.printStackTrace();
        }
    }

    @Override
    public void onDisconnected() {
        // Изменить состояние кнопки Cast
        activity.invalidateOptionsMenu();
    }
}

Основными методами, на которых стоит заострить внимание являются onApplicationConnected и onQueueLoad. Как в могли заметить, библиотека использует MediaInfo, MediaMetadata и MediaQueueItem для работы с медиа данными. в методе onApplicationConnected, который будет вызван как только приложение подключится к Chromecast, мы создадим объект очереди и вызовем абстрактный метод onQueueLoad, который позже реализуем в Activity. Описание работы методов можно найти в комментариях к коду.

Использование в Activity


Следующим (и последним) шагом будет реализация нашей Activity.

ublic class MainActivity extends AppCompatActivity {

    private VideoCastManager castManager;
    private VideoCastConsumer castConsumer;
    private Toolbar toolbar;

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

        toolbar = (Toolbar) findViewById(R.id.toolbar);
        setSupportActionBar(toolbar);

        castManager = VideoCastManager.getInstance();
        castConsumer = new SingleVideoCastConsumer(this,
                http://example.com/somemkvfile.mkv", // ссылка на файл
                "Jet Packs Was Yes", "Periphery", // подзаголовок и заголовок
                "http://fugostudios.com/wp-content/uploads/2012/02/periphery720p-600x338.jpg", // картинка
                "video/mkv" // тип файла
                ) {
            @Override
            public void onPlaybackFinished() {
                // Отключаем устройство
                disconnectDevice();
            }

            @Override
            public void onQueueLoad(MediaQueueItem[] items, int startIndex,
                                    int repeatMode, JSONObject customData)
                    throws TransientNetworkDisconnectionException, NoConnectionException {
                // Простой проброс очереди из нашего SingleVideoCastConsumer в castManager
                castManager.queueLoad(items, startIndex, repeatMode, customData);
            }
        };
    }

    @Override
    public boolean onCreateOptionsMenu(Menu menu) {
        getMenuInflater().inflate(R.menu.main, menu);
        // Добавляем кнопку Cast в toolbar
        castManager.addMediaRouterButton(menu, R.id.media_route_menu_item);
        return true;
    }

    @Override
    public boolean dispatchKeyEvent(@NonNull KeyEvent event) {
        // Даем возможность управлять громкостью воспроизведения при помощи
        // физических кнопок
        return castManager.onDispatchVolumeKeyEvent(event, 0.05)
                || super.dispatchKeyEvent(event);
    }

    @Override
    protected void onResume() {
        // Подключаем castConsumer и увеличиваем счетчик подключений
        if (castManager != null) {
            castManager.addVideoCastConsumer(castConsumer);
            castManager.incrementUiCounter();
        }

        super.onResume();
    }

    @Override
    protected void onPause() {
        // Уменьшаем счетчик подключений и отключаем castConsumer
        castManager.decrementUiCounter();
        castManager.removeVideoCastConsumer(castConsumer);
        super.onPause();
    }

    // По непонятной мне причине отключение устройства без задержки
    // не работало, но если использовать 100-500 мс задержку, то устройство
    // отключается нормально.
    private void disconnectDevice() {
        new Handler().postDelayed(new Runnable() {
            @Override
            public void run() {
               castManager.disconnect();
            }
        },500);
    }
}

В нашей Activity нет ничего сложного, мы получаем VideoCastManager в методе onCreate. В методах onResume и onPause управляем жизненным циклом нашего подключения к Chromecast. А методы onCreateOptionsMenu и dispatchKeyEvent организуют UX часть нашей интеграции. К сожалению, я так и не понял, почему castManager.disconnect() выбрасывал ошибку, но какая программа обходится без костылей.

Design Checklist


Теперь обратимся к дизайну. Большинство из Design Guidelines за нас реализует выше описанная библиотка, но некоторые пункты нужно реализовать вручную.

  • Стилизация диалогов
  • Показ интро для пользователя

Мы выполняли интеграцию с Google Chromecast в приложении «Рецепты Юлии Высоцкой». В этом приложении присутствуют видео-рецепты и было бы неплохо добавить возможность показывать их через Chromecast.

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


После интеграции с Chromecast и при наличии в нашей сети настроенного Chromecast экран будет выглядеть так:


Показ интро пользователю


Теперь нам необходимо показать пользователю информацию о том, что Chromecast доступен для стриминга и он может просмотреть видео-рецепт через него. Для показа этой информации мы воспользуемся IntroductoryOverlay из той же библиотеки. Я не буду описывать параметры этого класса, т.к. они очевидны и нам нужно указать только сопровождающий текст. Выглядит это вот так:


Стилизация диалогов

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

Для этого мы будем использовать CastConfiguration.Builder и метод setMediaRouteDialogFactory.

options.setMediaRouteDialogFactory(new MediaRouteDialogFactory() {
                    @NonNull
                    @Override
                    public MediaRouteChooserDialogFragment onCreateChooserDialogFragment() {
                        return new MediaRouteChooserDialogFragment() {
                            @Override
                            public MediaRouteChooserDialog onCreateChooserDialog(Context context, Bundle savedInstanceState) {
                                return new MediaRouteChooserDialog(context, R.style.Theme_MediaRouter_Light);
                            }
                        };
                    }

                    @NonNull
                    @Override
                    public MediaRouteControllerDialogFragment onCreateControllerDialogFragment() {
                        return new MediaRouteControllerDialogFragment(){
                            @Override
                            public MediaRouteControllerDialog onCreateControllerDialog(Context context, Bundle savedInstanceState) {
                                return new MediaRouteControllerDialog(context, R.style.Theme_MediaRouter_Light);
                            }
                        };
                    }
                })
								

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

<style name="Theme.MediaRouter.Light" parent="Base.Theme.AppCompat.Light.Dialog.Alert">
        <item name="android:windowNoTitle">false</item>
        <item name="mediaRouteButtonStyle">@style/Widget.MediaRouter.Light.MediaRouteButton</item>
        <item name="MediaRouteControllerWindowBackground">@drawable/mr_dialog_material_background_light</item>
        <item name="mediaRouteOffDrawable">@drawable/ic_cast_off_light</item>
        <item name="mediaRouteConnectingDrawable">@drawable/mr_ic_media_route_connecting_mono_light</item>
        <item name="mediaRouteOnDrawable">@drawable/ic_cast_on_light</item>
        <item name="mediaRouteCloseDrawable">@drawable/mr_ic_close_light</item>
        <item name="mediaRoutePlayDrawable">@drawable/mr_ic_play_light</item>
        <item name="mediaRoutePauseDrawable">@drawable/mr_ic_pause_light</item>
        <item name="mediaRouteCastDrawable">@drawable/mr_ic_cast_light</item>
        <item name="mediaRouteAudioTrackDrawable">@drawable/mr_ic_audiotrack_light</item>
        <item name="mediaRouteDefaultIconDrawable">@drawable/ic_cast_grey</item>
        <item name="mediaRouteBluetoothIconDrawable">@drawable/ic_bluetooth_grey</item>
        <item name="mediaRouteTvIconDrawable">@drawable/ic_tv_light</item>
        <item name="mediaRouteSpeakerIconDrawable">@drawable/ic_speaker_light</item>
        <item name="mediaRouteSpeakerGroupIconDrawable">@drawable/ic_speaker_group_light</item>
        <item name="mediaRouteChooserPrimaryTextStyle">@style/Widget.MediaRouter.ChooserText.Primary.Light</item>
        <item name="mediaRouteChooserSecondaryTextStyle">@style/Widget.MediaRouter.ChooserText.Secondary.Light</item>
        <item name="mediaRouteControllerTitleTextStyle">@style/Widget.MediaRouter.ControllerText.Title.Dark</item>
        <item name="mediaRouteControllerPrimaryTextStyle">@style/Widget.MediaRouter.ControllerText.Primary.Light</item>
        <item name="mediaRouteControllerSecondaryTextStyle">@style/Widget.MediaRouter.ControllerText.Secondary.Light</item>
    </style> 
		

Параметры стиля имеют говорящие названия, вы можете экспериментировать с ними, чтобы добиться наилучшего результата для вашего приложения. У нас получилось вот так:



Вот еще несколько скриншотов из приложения, которые показывают управление из области оповещений и на заблокированном экране:


Надеюсь, что вы нашли статью полезной, полный исходных код демо проекта лежит на github. Задавайте вопросы в комментариях, в следующей статье я постараюсь собрать ответы на часто задаваемые вопросы и рассказать о Media Receivers, управлении очередью воспроизведения и стилизации MediaReceivers. Более полную информацию об интеграции с другими платформами, а также примеры вы можете найти на официальной странице.

Более подробные примеры кода, в том числе и для других платформ, можно найти здесь. Подробную информацию о принципах взаимодействия с пользователем можно найти в Design Checklist.
Поделиться с друзьями
-->

Комментарии (2)


  1. sapl
    04.10.2016 09:49

    Привет,
    на сколько я понимаю теперь все равно придется мигрироваться с Cast Companion Library на Cast sdk3,
    который они сделали на базе CCL
    https://developers.google.com/cast/v2/ccl_migrate_sender


    1. inatale
      04.10.2016 12:18

      Вы правы, сейчас рекомендуется Cast SDK v3, но обратная совместимость сохранена (маппинг классов практически один в один). По сути, с v3 разработка упростила, часть вещей берет на себя SDK, но знания из v2 не пропадут даром для понимания общей механики, на мой взгляд.