Привет! Меня зовут Владислав Фальзан, я работаю android-разработчиком в М2. Наша команда мобильной разработки развивает приложение – онлайн-платформу для решения вопросов с недвижимостью. Основные пользователи приложения – физические лица (B2C) и риелторы (B2B2C). Эта статья – технический гайд для android-разработчиков о том, как реализовать видео сторис у себя в приложении или как использовать нашу библиотеку для работы с ними.
Из статьи вы поймете: как запустить видео сторис в своем приложении, как работать с несколькими видео, как сделать из этого полноценный плагин, если вы хотите инкапсулировать логику в отдельном модуле и подключать ее только при необходимости, или как использовать нашу библиотеку для этих целей.
Для удобства изучения статьи я решил разбить ее на блоки:
Почему решил написать статью
Эта статья – описание нового модуля-плагина нашей библиотеки, следующая итерация фичи сторис в нашем приложении. Рекомендую к прочтению предыдущую часть, так как она описывает принципы работы библиотеки в целом. Если же вас интересует, как создать сторис «с нуля», то советую посмотреть наши первые статьи из цикла про сторис: эту и эту – там все подробно расписано. После прочтения этой статьи вы сможете создавать видео сторис в своем приложении либо воспользоваться нашей библиотекой, разобравшись, как это все работает.
Для тех, кто хочет сразу начать читать эту статью, не стоит переживать, вам не нужно читать предыдущие статьи для понимания работы видео-сторис.
Я периодически буду делать референсы на участки предыдущих частей, чтобы не перегружать эту статью.
Итак, сначала поговорим о том, как мы пришли к идее создания видео сторис. Далее мы детально разберемся, как видео сторис работают изнутри: особенности поведения, поговорим о деталях реализации, разберемся зачем и как оформить данную фичу в отдельный модуль. В конце поделимся планами на будущее.
Почему возникла необходимость в видео формате
Итак, видеоформат на сегодня – один из трендовых и эффективных инструментов для коммуникации с пользователями и работы с их вовлеченностью в продукты. Движущееся изображение и звук быстрее захватывают внимание пользователя, повышают его интерес к продукту и, тем самым, конверсию.
Неудивительно, что после релиза фичи с обычными сторис и ее эффективности, бизнес довольно быстро пришел с идеей насчет видеоформата. Небольшой спойлер – в результате внедрения фичи, на примере сторис «Дистанционные сделки», ее вы видели выше, за 1 месяц каждый 10-ый перешел из сторис на продукт, и уже из них каждый 2-ой – оформил заявку на продукт, что составляет 5% от всех посмотревших сторис! Как мы это реализовали – расскажем ниже. Ранее про эффективность сторис мы говорили здесь: 1 часть, 2 часть, 3 часть.
В качестве библиотеки для работы с видео долго думать не пришлось – мы остановились на гугловом ExoPlayer. Это официальная библиотека, наиболее распространенная среди Android устройств.
Среди ее возможностей:
Универсальность – поддержка разных форматов видео (как стандартных, так и для онлайн-стриминга)
Поддержка Compose для UI интеграции
Регулярные апдейты и официальная помощь от Google
С какими трудностями мы столкнулись:
Библиотека – это зависимость. Что, если пользователь библиотеки не планирует работать с видео сторис, а только с обычными? Вне зависимости от этого, ему придется «подтягивать» к себе в проект лишние зависимости, которые ему по факту не нужны. К тому же это еще и лишний вес к конечному apk.
Работа с одним видео – это одно, с несколькими – другое. Сам по себе плеер – это тяжелый, ресурсозатратный объект в приложении. А когда необходимо работать с несколькими видео, как в случае со сторис – одного такого объекта недостаточно. С одной стороны, при свайпе на следующем видео мы увидим кадр с предыдущего. С другой стороны, каждый такой созданный объект – дополнительная нагрузка на ресурсы устройства, в том числе батарею, и чрезмерное их количество негативно на этом скажется.
Функционал видео сторис должен работать из коробки и не должен требовать дополнительных настроек от пользователя, быть простым в обращении, в противном случае это может отпугнуть его пользоваться нашей библиотекой.
Что мы в итоге сделали:
Вынесли библиотеки в отдельный модуль – плагин. Таким образом, если пользователю понадобятся видео сторис – достаточно просто его подключить, как и обычный модуль по сторис. При этом без него обычные сторис все так же продолжат работать, но без лишних зависимостей и веса.
Сделали так называемый «пул» из 3 плееров – это позволило одновременно добиться плавного интерфейса (при свайпе мы видим 2 активных видео + 1 на предзагрузку/отрисовку pager берет к себе заранее) и лимитировать количество данных объектов, чтобы не засорять память.
Незначительно переработали контракт наружу для взаимодействия с пользователем. Ему все так же необходимо минимум усилий, чтобы создать видео сторис. Одновременно с этим понятно, что у пользователя могут быть свои пожелания по UI части, поэтому мы намеренно ничего не отрисовываем внутри библиотеки – это необходимо для гибкости в использовании.
Как работает библиотека
Поведение превью и сторис сделано по аналогии с известной запрещенной соцсетью, принципы описаны здесь и здесь. Тестовый пример вы можете найти здесь. Как реализовано у нас в приложении, можете найти здесь, видео и обычные сторис вместе.
Вам не нужно ничего описывать на своей стороне, управление сторис уже реализовано в рамках библиотеки. Вы можете создавать как сторис с изображениями, так и с видео, в любом порядке.
Подключение
Чтобы подключить библиотеку, добавьте к себе следующий код:
в settings.gradle вашего проекта:
dependencyResolutionManagement { repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) repositories { google() mavenCentral() maven { url = uri("https://jitpack.io") } } }
в build.gradle вашего модуля, где вы хотите ее использовать:
dependencies { implementation("com.github.m2-oss.StoriesCompose:stories:Tag") implementation("com.github.m2-oss.StoriesCompose:stories-video:Tag") }
, где Tag – это номер версии. Актуальные версии можно посмотреть здесь.
Обратите внимание, что видео здесь – это отдельная библиотека, которая является опциональной для подключения. Если вас не интересуют видео сторис – данную библиотеку можно не подключать, ограничившись первой.
Создание видео
Что изменилось
Для начала рекомендую посмотреть, как создавать превью и обычные сторис.
Давайте посмотрим, что изменилось в библиотеке для поддержки видеосторис.
Создание превью никак не поменялось. А вот, чтобы создать данные для компонента видео, мы видоизменили модель UiSlidesData:
data class UiStoriesData( val storiesId: String, val stories: Map<String, List<UiSlidesData>> ) sealed class UiSlidesData(open val duration: Long) { data class Image(override val duration: Long) : UiSlidesData(duration) data class Video(val url: String) : UiSlidesData(0L) }
Это публичная модель, здесь мы даем понять, что хотим видеть в качестве слайда – картинки или видео. Обратите внимание, именно слайд, а не сторис, ведь 1 сторис может содержать слайды и с картинками, и с видео. Также вы могли заметить, что у любой сторис есть параметр длительность, для видео он = 0. Так сделано неспроста, ведь изначально, когда мы загружаем видео по урлу, мы не знаем заранее ее длительность. Позже мы увидим, как обновить длительность.
Тестовый пример
Создадим простой тестовый пример. Вот список наших превью:
val STORIES_PREVIEW_LIST = listOf( UiStoriesPreviewData( id = "video1", imageData = R.drawable.ic_launcher_foreground, title = "video1", ) )
Здесь мы даем понять, что хотим создать видео:
@Composable private fun createData( storiesId: String, previews: List<UiStoriesPreviewData> ): UiStoriesData = UiStoriesData( storiesId = storiesId, stories = buildMap { val ids = previews.map { it.id } ids.forEach { put( it, buildList { add( UiSlidesData.Video(url = "https://cdn.m2.ru/assets/file-upload-server/59d1bf8dd1ba8cee2d5df824ea01871d.mp4") ) } ) } } )
Данная функция позволяет добавлять как видео, так и фото сторис.
Если вам нужны еще сторис – положите еще 1 ключ-значение в функцию put – ключом здесь является id вашей сторис, а значением – список из слайдов типа UiSlidesData.
Теперь создадим контейнер для хранения сторис:
Код контейнера для хранения сторис
@Composable fun Container(previews: List<UiStoriesPreviewData>, storiesId: String, onFinished: () -> Unit) { val data = createData(storiesId, previews) StoriesContainer( data = data, onFinished = onFinished ) { stories, slide, progressBar, playerHolder -> val player = (playerHolder as? ExoPlayerHolder)?.player Column(modifier = Modifier.fillMaxSize()) { val video = data.stories[stories]?.get(slide) is UiSlidesData.Video Box( modifier = Modifier .fillMaxWidth() .weight(1f) .background(Color.Gray) ) { if (video) { VideoContent(player) } else { // реализация обычных сторис } } } } }
Обратите внимание на 2 переменные:
player – получение плеера. нам передается контейнер с плеером, где мы уже спрашиваем, есть ли доступ к нему, по сути, подключена ли библиотека – плагин. Если библиотека не подключена – нам вернется null, поэтому важно уметь обрабатывать данный сценарий. Почему контейнер, а не сам плеер? Дело в том, что интерфейс ExoPlayer является unstable – компилятор не может гарантировать целостность внутренних параметров со временем. Это приводит к бесконечной рекомпозиции, что затратно по ресурсам. Как выход, мы использовали класс-обертку, которую пометили как stable. Подробнее можно почитать здесь.
video – мы определяем, является ли текущий слайд сторис видео или обычным. мы смотрим на данные о сторис, созданных нами ранее. Мы получаем текущий слайд и сторис в зависимости от переданных нам соответствующих индексов.
Давайте теперь создадим непосредственно вью для плеера:
@Composable private fun VideoContent(player: ExoPlayer?) { if (player == null) return Box(modifier = Modifier.fillMaxSize()) { ContentFrame( player = player, modifier = Modifier.fillMaxSize(), surfaceType = SURFACE_TYPE_TEXTURE_VIEW, contentScale = ContentScale.Fit ) } }
Как вы можете видеть – мы не делаем никакой реализации UI для видеоплеера за пользователя – мы лишь передаем ссылку на плеер для него, оставляя свободу действий за ним. Вы можете заменить ContentFrame на PlayerSurface или AndroidView.
Результат:
Но есть нюанс – необходимо в качестве параметра surfaceType передавать значение SURFACE_TYPE_TEXTURE_VIEW, так как именно оно предназначено для 3d анимации. Если же вам нужен именно SURFACE_TYPE_SURFACE_VIEW и 2d анимация, рекомендую включить ее через параметры сторис контейнера:
StoriesContainer( ... storiesParams = UiStoriesParams().copy(graphicsTransition = false) ...
Управление видео
Данная секция опциональна. Само по себе управление видео происходит на стороне пользователя. Ниже мы кратко разберем несколько простых кейсов по манипуляции с видео в рамках нашей библиотеки.
Загрузка
Давайте посмотрим, как управлять состоянием загрузки видео.
Требование: показывать лоадер каждый раз, пока видео грузится, буферизуется, или произошла ошибка, бесконечный лоадер.
Создадим функцию, которая будет это отслеживать:
@Composable private fun IsVideoLoadingDisposableEffect( player: ExoPlayer?, loading: MutableState<Boolean> ) { if (player == null) return DisposableEffect(player) { val listener = object : Player.Listener { override fun onPlaybackStateChanged(playbackState: Int) { loading.value = playbackState == Player.STATE_BUFFERING || playbackState == Player.STATE_IDLE } } player.addListener(listener) onDispose { player.removeListener(listener) } } }
Все просто – подписываемся на изменение состояние воспроизведения. Если видео буферизируется или «простаивает», будь то первоначальная загрузка или ошибка, говорим об этом переменной loading. Для каждого ключа, плеера в нашем случае, слушатель будет уникальный – при смене сторис или уходе с экрана слушатель удаляется и заменяется другим.
Теперь применим эту функцию:
Код контейнера с загрузкой
StoriesContainer( data = data, onFinished = onFinished ) { stories, slide, progressBar, playerHolder -> val player = (playerHolder as? ExoPlayerHolder)?.player Column(modifier = Modifier.fillMaxSize()) { val loading = remember { mutableStateOf( player?.playbackState == Player.STATE_BUFFERING || player?.playbackState == Player.STATE_IDLE ) } IsVideoLoadingDisposableEffect(player = player, loading = loading) val video = data.stories[stories]?.get(slide) is UiSlidesData.Video Box( modifier = Modifier .fillMaxWidth() .weight(1f) .background(Color.Gray) ) { if (video) { VideoContent(player, loading) } else { // реализация обычных сторис } } } }
Здесь мы инициализируем переменную loading с теми же условиями, что и в функции. Обратите внимание, что мы инициализируем переменную именно внутри функции StoriesContainer, а если точнее – нашей лямбды content. Это нужно, чтобы значение сбрасывалось при смене сторис. Если мы передвинем инициализацию, например, наверх, то при смене сторис можем получить состояние предыдущей.
Создадим сам лоадер:
@Composable private fun Loader() { Box( modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center ) { CircularProgressIndicator() } }
Добавим его:
@Composable private fun VideoContent(player: ExoPlayer?, loading: MutableState<Boolean>) { if (player == null) return Box(modifier = Modifier.fillMaxSize()) { ContentFrame( player = player, modifier = Modifier.fillMaxSize(), surfaceType = SURFACE_TYPE_TEXTURE_VIEW, contentScale = ContentScale.Fit ) if (loading.value) { Loader() } } }
Результат:
Кстати, в библиотеке уже зашита проверка на наличие интернета – если его нет/пропадет, то в случае его появления видео продолжит воспроизведение.
Звук
Теперь давайте поработаем со звуком.
Для начала давайте просто создадим кнопку звука:
Код кнопки звука
@Composable private fun MuteButton( player: ExoPlayer, mute: MutableState<Boolean> ) { IconButton( modifier = Modifier.size(56.dp), onClick = { mute.value = !mute.value player.volume = if (mute.value) 0f else 1f } ) { Image( painter = painterResource( if (mute.value) { R.drawable.ic_launcher_background } else { R.drawable.ic_launcher_foreground } ), contentDescription = null ) } }
Здесь mute – переменная, отвечающая за наличие/отсутствие звука. В зависимости от ее значения, иконка кнопки будет меняться. На его состояние будут влиять не только нажатие на кнопку, но и другие эффекты, которые будут ниже.
Добавим кнопку в контейнер:
Код контейнера с кнопкой звука
@Composable fun Container(previews: List<UiStoriesPreviewData>, storiesId: String, onFinished: () -> Unit) { val data = createData(storiesId, previews) StoriesContainer( data = data, onFinished = onFinished ) { stories, slide, progressBar, playerHolder -> val player = (playerHolder as? ExoPlayerHolder)?.player Column(modifier = Modifier.fillMaxSize()) { val loading = remember { mutableStateOf( player?.playbackState == Player.STATE_BUFFERING || player?.playbackState == Player.STATE_IDLE ) } IsVideoLoadingDisposableEffect(player = player, loading = loading) val mute = remember { mutableStateOf(player?.volume == 0f) } val video = data.stories[stories]?.get(slide) is UiSlidesData.Video Box( modifier = Modifier .fillMaxSize() .background(Color.Gray) ) { if (video) { VideoContent(player, loading) } else { // реализация обычных сторис } if (video && player != null) { Row(modifier = Modifier.align(Alignment.BottomStart)) { MuteButton( player = player, mute = mute ) Text( text = "device volume = ${player.deviceVolume}, player volume = ${player.volume}", modifier = Modifier.background(Color.Green).align(Alignment.CenterVertically) ) } } } } } }
Здесь можем видеть, что переменную mute мы создаем со значением, есть ли звук на плеере. Также добавлен поясняющий текст со значением звука аппаратном и плеера. Обратите внимание, что это 2 разные громкости. Первая отвечает за громкость устройства в целом, на уровне всех приложений. Вторая отвечает за текущий экземпляр плеера внутри нашего приложения, то есть следующий созданный плеер будет иметь свое собственное дефолтное значение.
Результат:
Перейдем к режиму «без звука».
Вот функции, которые его отслеживают:
Код отслеживания режима "без звука"
@Composable private fun SilentModeDisposableEffect( player: ExoPlayer?, mute: MutableState<Boolean> ) { val context = LocalContext.current val audioManager = context.getSystemService(Context.AUDIO_SERVICE) as AudioManager DisposableEffect(player) { // Ловим смену звукового режима val receiver = object : BroadcastReceiver() { override fun onReceive(context: Context?, intent: Intent?) { if (intent?.action == AudioManager.RINGER_MODE_CHANGED_ACTION) { setPlayerState(player, mute, audioManager.ringerMode) } } } val filter = IntentFilter(AudioManager.RINGER_MODE_CHANGED_ACTION) context.registerReceiver(receiver, filter) // Проверка при инициализации setPlayerState(player, mute, audioManager.ringerMode) onDispose { context.unregisterReceiver(receiver) } } } private fun setPlayerState( player: ExoPlayer?, mute: MutableState<Boolean>, mode: Int ) { if (player == null) return mute.value = mode == AudioManager.RINGER_MODE_SILENT player.volume = if (mute.value) 0f else 1f }
Обратите внимание на то, что установкой громкости мы занимаемся 2 раза: при первом заходе, при инициализации, когда ресивер еще не запущен, и при смене режима внутри уже запущенного ресивера.
Теперь добавим ее в наш контейнер:
StoriesContainer( data = data, onFinished = onFinished ) { stories, slide, progressBar, playerHolder -> val player = (playerHolder as? ExoPlayerHolder)?.player Column(modifier = Modifier.fillMaxSize()) { ... val mute = remember { mutableStateOf(player?.volume == 0f) } SilentModeDisposableEffect( player = player, mute = mute ) val video = data.stories[stories]?.get(slide) is UiSlidesData.Video ... }
Инициализируем переменную мьют – звука в нет, если плеера нет или его громкость = 0.
Результат:
Теперь глянем на то, как управлять аппаратной громкость через качель громкости.
Функция по отслеживанию:
Код отслеживания аппаратной громокости
@Composable private fun DeviceVolumeDisposableEffect( player: ExoPlayer?, mute: MutableState<Boolean> ) { if (player == null) return DisposableEffect(player) { val listener = object : Player.Listener { // Слушаем изменения аппаратной громкости устройства override fun onDeviceVolumeChanged( volume: Int, muted: Boolean ) { mute.value = muted || (volume == 0) player.volume = if (mute.value) 0f else 1f } } player.addListener(listener) onDispose { player.removeListener(listener) } } }
Все просто – если устройство аппаратно поставлено на режим «без звука» или громкость = 0, то ставим громкости плеера 0, нет громкости, иначе 1, полная громкость плеера, не звука устройства.
Добавим его в контейнер:
StoriesContainer( data = data, onFinished = onFinished ) { stories, slide, progressBar, playerHolder -> ... DeviceVolumeDisposableEffect( player = player, mute = mute ) val video = data.stories[stories]?.get(slide) is UiSlidesData.Video ... }
Результат:
Теперь проверим видео на наличие звуковой дорожки, бывают видео без звука изначально:
Код проверки звуковой дорожки
@Composable private fun CheckSoundDisposableEffect( player: ExoPlayer?, muteVisible: MutableState<Boolean>, stories: String, slide: Int ) { if (player == null) return DisposableEffect(player, stories, slide) { val listener = object : Player.Listener { override fun onTracksChanged(tracks: Tracks) { val hasAudio = tracks.groups.any { it.type == C.TRACK_TYPE_AUDIO } muteVisible.value = hasAudio } } player.addListener(listener) onDispose { player.removeListener(listener) } } }
Из интересного – в качестве ключей нам необходимо указать идентификаторы сторис и слайда – в каждом из них может быть видео, и неизвестно, будут ли у них звуковые дорожки или нет.
Также мы ввели переменную muteVisible – она понадобится нам для показа/скрытия кнопки звука, которую мы добавим сейчас:
Добавим функцию в контейнер:
Код контейнера со звуковой дорожкой
StoriesContainer( data = data, onFinished = onFinished ) { stories, slide, progressBar, playerHolder -> val player = (playerHolder as? ExoPlayerHolder)?.player Column(modifier = Modifier.fillMaxSize()) { ... val muteVisible = remember { mutableStateOf( player?.currentTracks?.groups?.any { trackGroup -> trackGroup.type == C.TRACK_TYPE_AUDIO } ?: false ) } CheckSoundDisposableEffect( player = player, muteVisible = muteVisible, stories = stories, slide = slide ) val video = data.stories[stories]?.get(slide) is UiSlidesData.Video Box( modifier = Modifier .fillMaxSize() .background(Color.Gray) ) { ... if (video && muteVisible.value && player != null) { Row(modifier = Modifier.align(Alignment.BottomStart)) { MuteButton( player = player, mute = mute ) ... } } } } }
Добавили объявление переменной muteVisible с проверкой, аналогичной в функции.
Результат:
Обратите внимание, что иконка звука с текстом появились не сразу, а после того, как загрузилась информация по видео.
Что в итоге
Проведен очередной этап эволюции фичи сторис. Была проведена большая работа с тем, чтобы внедрить видео в библиотеку:
Сначала добавили 1 видео – это уже был вызов, ведь добавляли его уже в существующую большую кодовую базу по сторис. Поняли, что прогресс видео, например, считается по-другому, нежели обычная сторис, поэтому нужно было разделить их расчет по типу сторис. Длительность получается не сразу, а без нее не запустить видео. Нужно подождать, пока она подтянется, и уже после запускать видео.
Дальше добавили несколько видео. Пришлось повозиться с тяжеловесными объектами видеоплеера: оптимизировать их количество, синхронизировать их работу между собой. Также пришлось научить видео и обычные сторис работать вместе, вперемешку.
Дальше – вынос этого функционала в отдельный модуль, так как пользователю библиотеки может понадобиться только обычные сторис, и лишние зависимости ему ни к чему. Отдельный модуль позволил изолировать код видео от обычных сторис, теперь и читаемость стала лучше, и появился выбор – подключать или нет дополнительный функционал – здорово.
Ну и, конечно, багофикс – куда без него
В чем преимущества нашей библиотеки:
Она проста понятна в использовании. Подключается легко, без лишнего геморроя. Сторис создаются быстро, множество аргументов дефолтные, можно быстро получить результат
Она функциональна – есть как превью сторис, обычные сторис, теперь есть и видео сторис. Более того, можно написать свою реализацию видео и подключить к нашей библиотеке, если есть такое желание.
Она постоянно дорабатывается. Мы не стоим на месте. Есть спрос на нее как со стороны бизнеса, и постоянные бизнес-задачи ее улучшают, так и технические – оптимизация текущей кодовой базы.

Что по поводу планов на будущее? Из бизнес задач у нас в планах добавить через тот же ExoPlayer возможность стриминга видео онлайн – это позволит смотреть трансляции наших вебинаров прямо из приложения. Из технических задач – ослабить связь с БД через многомодульность, создание api/impl модулей, для того, чтобы пользователь мог сам решать, какой способ хранения ему использовать. Также планирую заняться оптимизацией кода.
Надеюсь, вам была полезна данная статья. Буду рад любому вашему фидбэку, так что не стесняйтесь оставлять комментарии здесь или issues на странице библиотеки в github. Успехов вам и спасибо за внимание!
