惯性聚合 高效追踪和阅读你感兴趣的博客、新闻、科技资讯
阅读原文 在惯性聚合中打开

推荐订阅源

L
LINUX DO - 最新话题
cs.CV updates on arXiv.org
cs.CV updates on arXiv.org
PCI Perspectives
PCI Perspectives
cs.AI updates on arXiv.org
cs.AI updates on arXiv.org
K
KPMG report finds enterprise disconnect between AI and its ROI | CIO
H
Heimdal Security Blog
S
Security @ Cisco Blogs
N
News | PayPal Newsroom
J
Java Code Geeks
罗磊的独立博客
Security Archives - TechRepublic
Security Archives - TechRepublic
N
News and Events Feed by Topic
V
V2EX
WordPress大学
WordPress大学
Google Online Security Blog
Google Online Security Blog
N
News and Events Feed by Topic
www.infosecurity-magazine.com
www.infosecurity-magazine.com
月光博客
月光博客
AI
AI
小众软件
小众软件
The GitHub Blog
The GitHub Blog
MongoDB | Blog
MongoDB | Blog
A
Arctic Wolf
CTFtime.org: upcoming CTF events
CTFtime.org: upcoming CTF events
美团技术团队
Threat Intelligence Blog | Flashpoint
Threat Intelligence Blog | Flashpoint
Exploit-DB.com RSS Feed
Exploit-DB.com RSS Feed
Hacker News - Newest:
Hacker News - Newest: "LLM"
T
Tailwind CSS Blog
S
Schneier on Security
博客园 - 三生石上(FineUI控件)
F
Full Disclosure
B
Blog RSS Feed
Forbes - Security
Forbes - Security
S
SegmentFault 最新的问题
钛媒体:引领未来商业与生活新知
钛媒体:引领未来商业与生活新知
人人都是产品经理
人人都是产品经理
云风的 BLOG
云风的 BLOG
Jina AI
Jina AI
Cisco Talos Blog
Cisco Talos Blog
U
Unit 42
Project Zero
Project Zero
H
Hacker News: Front Page
Y
Y Combinator Blog
Application and Cybersecurity Blog
Application and Cybersecurity Blog
The Cloudflare Blog
大猫的无限游戏
大猫的无限游戏
S
Secure Thoughts
The Hacker News
The Hacker News
Microsoft Azure Blog
Microsoft Azure Blog

Все публикации подряд на Хабре

Ловим музу за клавиатуру: как айтишнику стать автором Что умеет Midjourney в 2026? Мой немного грустный разбор этого шикарного инструмента Никто не любит писать тесты, но ИИ может исправить это IPv8 выглядит как мечта. Поэтому почти наверняка не взлетит Производители вернули в продажу материнки с DDR3. Что происходит? Управление агентом с телефона через Telegram теперь в KodaCode От координации к лидерству: как меняется роль руководителя разработки Я сделала родителям бизнес вместо пенсии: зарабатываем 70 тысяч, мама не даёт продать В три раза быстрее приемка товара и оптимизация трудозатрат на 73%: как «РСТ-Инвент» помог Gulliver Group ИИ-шечный мир победил? О влиянии искусственного интеллекта на игропром Кремль снижает давление на Телеграмм пока Европа строит интернет по паспорту Как CEO, CTO и CIO за 8 часов собрали ИИ-директора, который умеет держать позицию под давлением Как (не) потерять домен за выходные Вместо 8 разных VPS: как я организовал практику студентам на одном сервере Почему твой Open Source проект не замечают? R&D: искусство управления неопределенностью в разработке AI-дефляция: вакансий для разработчиков больше, а рост зарплат — худший за 15 лет Мы отдали управление роботами OpenClaw. Что из этого вышло Галактический ID: система идентификации для всех форм разумной жизни Шесть основ бизнес-анализа: начинаем с вопроса «Кто в игре?» Код-ревью, в котором дело не в коде Данные переехали. Команда — нет Системной подход к сдаче OSWE в 2025 Почему комната управления реактором покрашена в цвет морской пены 4 YAML-файла вместо PySpark: как аналитикам строить пайплайны без разработчиков LLM-агент для поиска свободных доменов: автоматизируем подбор Когда, зачем и как правильно начинать новую сессию в Claude Code? Как я заставил нейросеть писать макросы для FreeCAD Анатомия ИИ‑агента для подбора персонала. От тысячи резюме к топ‑10 за минуты Опыт разработчика как экономика внимания Автономность как точка невозврата: кто будет субъектом в цифровом будущем Обучение ИИ в «диких» условиях: как рутинные действия превращаются в датасеты Как измерить LLM для задач кибербеза: обзор открытых бенчмарков Где хранить код? Сравнение GitHub, GitLab и Bitbucket Математика объясняет, почему нормальное распределение встречается повсюду Почему ваш FinOps не работает: 12 тезисов от практиков Как подписать проектную документацию УКЭП с использованием бесплатных лицензий Pilot Адаптивное администрирование Sigla Vision Я грузил уран в бочки, а потом 20 лет строил ИТ в атомной отрасли Чем позвонить с Эвереста? История и обзор спутниковой связи. Часть 2 Как языковая модель помогает контролировать качество инструктажей по охране труда в металлургии Как не передать на desktop свой IP в РКН Анатомия SAP Privileges: как устроено управление правами в macOS MoneyDev: Сказка про три главных слова Обновлённый токенизатор видео K-VAE 2.0 от Сбера Как сделать диспетчеризацию дома на 1284 квартиры почти бесплатно Как мы разогнали железную дорогу Мы дали агентам рутину. Теперь надо решить — что делать с освободившимся временем Токсичный контент, промпт-хакинг и защита ИИ — всё о Guardrails для LLM Умный город начинается с точного взгляда: как «Фалькон Тех» меняет пространство к лучшему Навайбкодил приложение для анализа графов Почему Дюну так интересно читать? Упрощаем работу с рутиной или как стать Гендальфом Белым Деконструкция Go: CPU, RAM и что там происходит. Go Assembler база. Часть 1.1 Какие профессии исчезнут из-за ИИ, а какие появятся? И что с этим делать Как мы построили IT-отдел, где хочется расти: архитектурные встречи, прозрачные метрики и книжные подарки Rufler: Делаем из Claude Code автономный рой через один YAML-конфиг Sing-box и белый список приложений Как построить надёжный обмен сообщениями в микросервисах: лучшие практики для enterprise OpenAI строит MLM-пирамиду, а McKinsey и Accenture помогают ей в этом Дом, который не построил Фишер (Часть 2) «Сверхзвуковой математик» против «Вдумчивого логиста»: битва алгоритмов 3D-упаковки Мультимодальные модели – грубый и дорогой инструмент Разговоры ничего не стоят. Код тоже Проверки физических лиц: с кого начнет ФНС Топ-10 бесплатных нейросетей для создания видео в 2026 году Первые слои кода: как наши решения сегодня определяют архитектуру ИИ на десятилетия Разработка нового статического анализатора: PVS-Studio JavaScript Поиск уязвимостей ПО: базовый минимум или роскошный максимум Почему оценка персонала не работает как инструмент управления Как мы разработали ИИ-ассистента и сократили рутину продуктовой команды на 50% Как я ушел из найма, нажарил косточек и продал на маркетплейсах на 168 млн в год Когда 1С:ERP уже внедрена, а нормального производственного плана всё ещё нет Как я сделал Claude мультимодальным, подключив к нему Qwen Omni Как приглашение на вакансию мечты превращается в атаку Infrastructure as Code: философия и лучшие практики IaC Тестируем Yandex Code Assistant на задаче, в которой нужно хранить секреты nxs-universal-chart v3.0: новое поколение универсального Helm-чарта Callback Injection: Техника, которая отправила Microsoft Defender в глухой нокаут «Все идеи на стол»: митап как способ вывести проект из тупика Сегодня я узнал нечто новое о GPU благодаря багу в своей игре Как заставить LLM ̶ ̶г̶а̶л̶л̶ю̶ ̶ эволюционировать Карта событий как фундамент аналитики: практический кейс для E-commerce Что выбрать для AI: x86, ARM или RISC-V? Дайджест железа за март Роль соматических мутаций в развитии аутоиммунных заболеваний: путь к избирательной терапии Mythos от Anthropic — тревожный сигнал для всех, а не только для банков Guardrails для LLM на Java: как приручить промпт‑инъекции и токсичные ответы Green-VLA: как мы собрали VLA-модель для реального антропоморфного робота и не потеряли обобщение Финансовая гонка вооружений: почему умные люди добровольно в ней участвуют Эра ИИ-агентов наступила: выбираем лучшего цифрового сотрудника # Практический опыт внедрения WinCC Redundancy на производственном предприятии Сделал MVP за 3 дня, а потом неделю прикручивал оплату. Оно того стоило? Физика против Маска: почему Starship V3 может оказаться ещё одной катастрофой Нефть Венесуэлы: крупнейшие запасы в мире, но не крупнейшая нефтяная держава JPA 4. Переосмысление Hibernate Почему зеркальная фотокамера Nikon D5 десятилетней давности идеально подошла для миссии «Артемида-2» Проект «Уровень-Спутник» или как мы сделали платформу для гидрологов «Замедлиться, чтобы ускориться»: почему ИИ повышает цену ошибок в требованиях и архитектуре Как с нуля поднять трафик IT-компании на 1657% при бюджете 55 тыс. и выжить Pixel-perfect Downsampling — идеальная отрисовка 50 миллионов точек без потерь
Технический гайд по сторис – часть 4: как мы добавили видео формат
vfalzan · 2026-05-22 · via Все публикации подряд на Хабре

Средний

14 мин

7.7K

Привет! Меня зовут Владислав Фальзан, я работаю android-разработчиком в М2. Наша команда мобильной разработки развивает приложение – онлайн-платформу для решения вопросов с недвижимостью. Основные пользователи приложения –  физические лица (B2C) и риелторы (B2B2C). Эта статья – технический гайд для android-разработчиков о том, как реализовать видео сторис у себя в приложении или как использовать нашу библиотеку для работы с ними.

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

Для удобства изучения статьи я решил разбить ее на блоки:

  1. Почему решил написать статью

  2. Почему возникла необходимость в видео формате

  3. Как работает библиотека

    1. Подключение

    2. Создание видео

  4. Управление видео

    1. Загрузка

    2. Звук

  5. Что в итоге

Почему решил написать статью

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

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

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

Почему возникла необходимость в видео формате

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

Неудивительно, что после релиза фичи с обычными сторис и ее эффективности, бизнес довольно быстро пришел с идеей насчет видеоформата. Небольшой спойлер – в результате внедрения фичи, на примере сторис «Дистанционные сделки», ее вы видели выше, за 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. Успехов вам и спасибо за внимание!