m2_tech блог
Технический гайд по сторис — часть 4: как мы добавили видео формат

Привет! Меня зовут Владислав Фальзан, я работаю 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:
<div class="ql-code-block" data-language="plain">data class UiStoriesData(</div><div class="ql-code-block" data-language="plain">   val storiesId: String,</div><div class="ql-code-block" data-language="plain">   val stories: Map&lt;String, List&lt;UiSlidesData&gt;&gt;</div><div class="ql-code-block" data-language="plain">)</div><div class="ql-code-block" data-language="plain"><br></div><div class="ql-code-block" data-language="plain">sealed class UiSlidesData(open val duration: Long) {</div><div class="ql-code-block" data-language="plain"><br></div><div class="ql-code-block" data-language="plain">   data class Image(override val duration: Long) : UiSlidesData(duration)</div><div class="ql-code-block" data-language="plain"><br></div><div class="ql-code-block" data-language="plain">   data class Video(val url: String) : UiSlidesData(0L)</div><div class="ql-code-block" data-language="plain">}</div><div class="ql-code-block" data-language="plain">Объяснить с</div>

Это публичная модель, здесь мы даем понять, что хотим видеть в качестве слайда – картинки или видео. Обратите внимание, именно слайд, а не сторис, ведь 1 сторис может содержать слайды и с картинками, и с видео. Также вы могли заметить, что у любой сторис есть параметр длительность, для видео он = 0. Так сделано неспроста, ведь изначально, когда мы загружаем видео по урлу, мы не знаем заранее ее длительность. Позже мы увидим, как обновить длительность.
Тестовый пример
Создадим простой тестовый пример. Вот список наших превью:
<div class="ql-code-block" data-language="plain">val STORIES_PREVIEW_LIST = listOf(</div><div class="ql-code-block" data-language="plain">   UiStoriesPreviewData(</div><div class="ql-code-block" data-language="plain">       id = "video1",</div><div class="ql-code-block" data-language="plain">       imageData = R.drawable.ic_launcher_foreground,</div><div class="ql-code-block" data-language="plain">       title = "video1",</div><div class="ql-code-block" data-language="plain">   )</div><div class="ql-code-block" data-language="plain">)</div><div class="ql-code-block" data-language="plain">Объяснить с</div>

Здесь мы даем понять, что хотим создать видео:
<div class="ql-code-block" data-language="plain">@Composable</div><div class="ql-code-block" data-language="plain">private fun createData(</div><div class="ql-code-block" data-language="plain">   storiesId: String,</div><div class="ql-code-block" data-language="plain">   previews: List&lt;UiStoriesPreviewData&gt;</div><div class="ql-code-block" data-language="plain">): UiStoriesData = UiStoriesData(</div><div class="ql-code-block" data-language="plain">   storiesId = storiesId,</div><div class="ql-code-block" data-language="plain">   stories = buildMap {</div><div class="ql-code-block" data-language="plain">       val ids = previews.map { it.id }</div><div class="ql-code-block" data-language="plain">       ids.forEach {</div><div class="ql-code-block" data-language="plain">           put(</div><div class="ql-code-block" data-language="plain">               it,</div><div class="ql-code-block" data-language="plain">               buildList {</div><div class="ql-code-block" data-language="plain">                   add(</div><div class="ql-code-block" data-language="plain">                       UiSlidesData.Video(url = "https://cdn.m2.ru/assets/file-upload-server/59d1bf8dd1ba8cee2d5df824ea01871d.mp4")</div><div class="ql-code-block" data-language="plain">                   )</div><div class="ql-code-block" data-language="plain">               }</div><div class="ql-code-block" data-language="plain">           )</div><div class="ql-code-block" data-language="plain">       }</div><div class="ql-code-block" data-language="plain">   }</div><div class="ql-code-block" data-language="plain">)</div><div class="ql-code-block" data-language="plain">Объяснить с</div>
Данная функция позволяет добавлять как видео, так и фото сторис.
Если вам нужны еще сторис – положите еще 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 – мы определяем, является ли текущий слайд сторис видео или обычным. мы смотрим на данные о сторис, созданных нами ранее. Мы получаем текущий слайд и сторис в зависимости от переданных нам соответствующих индексов.
Давайте теперь создадим непосредственно вью для плеера:
<div class="ql-code-block" data-language="plain">@Composable</div><div class="ql-code-block" data-language="plain">private fun VideoContent(player: ExoPlayer?) {</div><div class="ql-code-block" data-language="plain">   if (player == null) return</div><div class="ql-code-block" data-language="plain"><br></div><div class="ql-code-block" data-language="plain">   Box(modifier = Modifier.fillMaxSize()) {</div><div class="ql-code-block" data-language="plain">       ContentFrame(</div><div class="ql-code-block" data-language="plain">           player = player,</div><div class="ql-code-block" data-language="plain">           modifier = Modifier.fillMaxSize(),</div><div class="ql-code-block" data-language="plain">           surfaceType = SURFACE_TYPE_TEXTURE_VIEW,</div><div class="ql-code-block" data-language="plain">           contentScale = ContentScale.Fit</div><div class="ql-code-block" data-language="plain">       )</div><div class="ql-code-block" data-language="plain">   }</div><div class="ql-code-block" data-language="plain">}</div><div class="ql-code-block" data-language="plain">Объяснить с</div>
Как вы можете видеть – мы не делаем никакой реализации UI для видеоплеера за пользователя – мы лишь передаем ссылку на плеер для него, оставляя свободу действий за ним. Вы можете заменить ContentFrame на PlayerSurface или AndroidView.Результат:
Результат:
Имя Фамилия
Должность
10.09.2026