company_banner

Быстрая интеграция Google Chromecast в Android приложение

  • Tutorial
Добрый день, я 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.
Метки:
  • +10
  • 4,4k
  • 2
Google 156,06
Филин Лаки
Поделиться публикацией
Похожие публикации
Комментарии 2
  • 0
    Привет,
    на сколько я понимаю теперь все равно придется мигрироваться с Cast Companion Library на Cast sdk3,
    который они сделали на базе CCL
    https://developers.google.com/cast/v2/ccl_migrate_sender
    • 0
      Вы правы, сейчас рекомендуется Cast SDK v3, но обратная совместимость сохранена (маппинг классов практически один в один). По сути, с v3 разработка упростила, часть вещей берет на себя SDK, но знания из v2 не пропадут даром для понимания общей механики, на мой взгляд.

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

    Самое читаемое