
Многие наверняка уже имели опыт реализации прямой трансляции в мобильных приложениях, и я в том числе был уверен, что сделать фичу не займет много времени с помощью таких библиотек как: video_player / media_kit / vlc.
Практически все плееры предлагают одинаковую модель использования: “открыть по ссылке”:
videoController.network(dataSource)
videoController.networkUrl(dataSource)
При этом на вебе трансляция уже была реализована и работала в двух режимах: live-просмотр и перемотка назад. Поэтому первым шагом стало изучение того, как именно этот функционал устроен в браузере.
Через DevTools было определено, что с одной стороны, присутствовал HLS-плейлист .m3u8, а с другой — активное WebSocket соединение, по которому в реальном времени летели бинарные данные.
HLS — коммуникационный протокол для потоковой передачи медиа на основе HTTP, в основе работы которого лежит принцип разбиения цельного потока на небольшие фрагменты, последовательно скачиваемые по HTTP. Поток непрерывен и теоретически может быть бесконечным.

Попытка использовать HLS напрямую в мобильных плеерах действительно позволяла воспроизвести трансляцию, однако задержка стабильно составляла порядка 15–22 секунд. Для live-режима такие значения неприемлемы.
Исходя из этого, было установлено - HLS в данном случае используется не как основной механизм прямой трансляции, а скорее как вспомогательный инструмент для просмотра записи с возможностью перемотки.
Основной же live-поток на вебе реализован через WebCodecs — API для работы с аудио- и видеокодеками напрямую, доступного в браузере “из коробки”. С этого момента началось изучение того, как реализовать трансляцию на мобильном клиенте.
Что приходит с бэкенда
Первым делом, необходимо собрать вводные. Погрузившись более детально было установлено, что сервер по веб-сокету присылает потоково чанки трансляции (в рамках статьи под чанком будет подразумеваться один закодированный видеокадр), структура которых показана ниже:
class WebCodecChunk { final String channelId; final String data; // base64 final int pts; final int duration; final FrameType frameType; // video / audio final bool key; // is key frame final DateTime timestamp; }
- где data оказалось строкой base64. Каждый 30-й кадр являлся ключевым (IDR).

По веб-сокету от сервера также отдельно получены параметры трансляции:
Видео:
codecString: avc1.641028
width: 1920, height: 1080
где avc1.641028 означает, что это H.264/AVC High Profile, Level 4.1.
Аудио:
codecString: mp4a.40.2
sampleRate: 48000
channelsCount: 2
Далее, начался поиск возможных готовых решений для воспроизведения прямой трансляции на веб-кодеках...
Готовые решения?
Скрытый текст
(спойлер: нет)
Есть прямая трансляция, кадры которой приходят потоково через WebSocket.
Каждое сообщение содержит сырой H.264, закодированный в base64.
Это не файл, не HLS, не RTMP, а последовательность NAL-юнитов, где:
● с определенной периодичностью приходит I-кадр (ключевой кадр, IDR)
● между I-кадром идут P/B-кадры
● P/B-кадры невозможно декодировать без предыдущего состояния декодера
● важна минимальная задержка

, где GOP - это логическая группа видеокадров, внутри которой кадры зависят друг от друга (Group of Pictures)
Логичный первый шаг в такой ситуации — проверить, существуют ли уже готовые решения.
Я рассмотрел несколько пакетов:
— представляюет собой обёртку над FFmpeg / FFprobe, позволяющую запускать FFmpeg-команды из Flutter.
В рамках типового использования пакет:
- запускает FFmpeg-команду
- выполняет её
- завершает сессиюВ моём случае данные потоково приходили по WebSocket в виде base64-строк, содержащих отдельные фрагменты видеопотока.
Это означало, что каждый такой чанк необходимо было передавать в FFmpeg как отдельную операцию декодирования через ffmpeg_kit_flutter_new.
В результате для каждого кадра фактически запускалась новая FFmpeg-сессия, что делало такой подход слишком затратным для realtime-трансляции.Сразу было установлено, что в такой модели контекст декодера не удерживается между вызовами, и стабильно удаётся получать изображение только из ключевых кадров.
Фактически это превращало “прямую трансляцию” в слайдшоу с частотой примерно 1 кадр на GOP.
Вывод:Пакет отлично подходит, если нужно:
- перекодировать файл
- нарезать видео
- вытащить кадры из готового источника
- выполнить “тяжёлую” медиа-операцию одной ко��андойНо не для сценария:
- “декодировать H.264 NAL-ы, приходящие по WebSocket, с минимальной задержкой”.Дополнительно стоит учитывать, что оригинальный FFmpegKit был официально переведён в статус retired, а ffmpeg_kit_flutter_new является форком. Это не приговор, но фактор риска при долгосрочной поддержке.
Согласно описанию, это плагин для Flutter, который декодирует необработанные кадры (H264/H265 IDR) в растровые изображения.
Судя по описанию и примерам использования, пакет ориентирован на декодирование отдельных ключевых кадров и не позиционируется как потоковый декодер, способный обрабатывать последовательность зависимых кадров.
В примерах входные данные передаются через файл (asset), а результатом работы является сформированное изображение. Такой подход хорошо подходит для оффлайн-декодирования отдельных кадров, однако плохо применим для live-трансляций, где требуется непрерывная обработка видеопотока с сохранением состояния декодера.
Несмотря на название, FlutterQuickVideoEncoder решает обратную задачу.
Библиотека предназначена для кодирования видео: она принимает последовательность raw RGB(A)-кадров (и опционально PCM-аудио) и собирает из них видеоролик в формате MP4.
Итог
С точки зрения задачи “прямая трансляция H.264 через WebSocket с минимальной задержкой”:
ffmpeg_kit_flutter_new — FFmpeg как командный инструмент, но не живой декодер
h264 — декодирование одиночных IDR-кадров, а не потока
FlutterQuickVideoEncoder — энкодер, а не декодер
Готового решения для вывода прямой трансляции на WEB-codec-ах “из коробки” найти не удалось. Это и стало отправной точкой для изобретения велосипеда.
Отрицание, торг, принятие
Прошло пару недель, и стадия отрицания сменилась принятием. Стало ясно, что готового решения под этот формат входных данных нет, и задачу придётся решать самостоятельно.
Веб-клиент уже использовал WebCodecs для декодирования видеопотока, поэтому первым шагом стало изучение того, как в веб-версии устроена обработка входящих данных.
Было рекомендовано перейти на MessagePackHubProtocol, чтобы получать данные сразу в бинарном виде, без base64-декодирования на клиенте. Ранее сервер отправлял кадры в формате base64-строк, что требовало дополнительного декодирования на клиенте. Переход на бинарный протокол позволил исключить этот шаг и сэкономить до 3-5 миллисекунд на обработке каждого чанка.
Далее рассматривались два варианта реализации:
нативная реализация на iOS/Android через platform channels
реализация C++ библиотеки для транскодинг потока с FFmpeg
Первой платформой был выбран iOS. Процесс изучения и реализации функционала занял несколько дней, где на выходе получилась более-менее рабочая версия вывода только видео-кадров.
Далее, было принято решение добиться примерно того же для андроида. Больше двух недель попыток не привели к результату.
По совету тимлида я сделал шаг назад и перешел ко второму варианту, который даёт:
единый код
одинаковое поведение на iOS и Android
Имея опыт разработки на плюсах, а также с ffi я принялся за работу и спустя несколько недель добился вывода прямой трансляции в приложении с задержкой менее 1 сек. на обоих платформах.
Дальше будет представлена схема данных и фрагменты реализации фичи:

Для оптимизации производительности был создан отдельный изолят, в котором устанавливалось соединение с сервером через SignalR:
_signalRConnection = HubConnectionBuilder() .withUrl(connectionUrl, options: connectionOptions) .withHubProtocol(MessagePackHubProtocol()) .build();
После получения параметров трансляции от сервера запускалось прослушивание стрима данных и выполнялась инициализация нативной библиотеки. Для работы с библиотекой на стороне Flutter был реализован FFI-слой - класс FfiDecoder, который инкапсулирует вызовы нативных функций и управляет жизненным циклом декодеров.
final DynamicLibrary _videoDecoderLib = () { if (Platform.isIOS) { final lib = DynamicLibrary.process(); return lib; } if (Platform.isAndroid) { final lib = DynamicLibrary.open('libh264_stream_decoder.so'); return lib; } throw UnsupportedError('Unsupported platform'); }(); class FfiDecoder { static FfiDecoder? _instance; factory FfiDecoder() { return _instance ??= FfiDecoder._internal(); } late final Pointer<Void> _videoDecoderPtr; late final Pointer<Void> _audioDecoderPtr; FfiDecoder._internal() { _setLogCallback(_logCallbackPointer); setNativeLogLevel(FfiDecoderLogLevel.warning); _videoDecoderPtr = _createDecoder(); _audioDecoderPtr = _createAudioDecoder(); } ... }
Для работы с CPP-типами данных было необходимо реализовать binding:
typedef _CreateDecoderC = Pointer<Void> Function(); typedef _CreateAudioDecoderC = Pointer<Void> Function(); typedef _ReleaseDecoderC = Void Function(Pointer<Void>); typedef _ReleaseAudioDecoderC = Void Function(Pointer<Void>); typedef _PushPacketToDecoderC = Void Function( Pointer<Void>, // decoder handle Pointer<Uint8>, // input data Int32, // input size Int64, // pts in microseconds Bool, // isKeyFrame ); final _createDecoder = _videoDecoderLib .lookup<NativeFunction<_CreateDecoderC>>('create_decoder') .asFunction<Pointer<Void> Function()>(); final _createAudioDecoder = _videoDecoderLib .lookup<NativeFunction<_CreateAudioDecoderC>>('create_audio_decoder') .asFunction<Pointer<Void> Function()>(); final _releaseDecoder = _videoDecoderLib .lookup<NativeFunction<_ReleaseDecoderC>>('release_decoder') .asFunction<void Function(Pointer<Void>)>(); final _releaseAudioDecoder = _videoDecoderLib .lookup<NativeFunction<_ReleaseAudioDecoderC>>('release_audio_decoder') .asFunction<void Function(Pointer<Void>)>(); final _pushPacketToDecoder = _videoDecoderLib .lookup<NativeFunction<_PushPacketToDecoderC>>('push_packet_to_decoder') .asFunction< void Function(Pointer<Void>, Pointer<Uint8>, int, int, bool) >();
И, на стороне плюсовой библиотеки также было необходимо обозначить видимость вызываемых методов:
// ffi_interface.cpp #include "ffi_interface.h" #include "decoder_h264.h" #include "decoder_aac.h" #include "logger.h" #include <cstdlib> #include <cstring> extern "C" { __attribute__((visibility("default"))) __attribute__((used)) DecoderHandle create_decoder() { return new DecoderH264(); } __attribute__((visibility("default"))) __attribute__((used)) AudioDecoderHandle create_audio_decoder() { return new DecoderAAC(); } __attribute__((visibility("default"))) __attribute__((used)) void release_decoder(DecoderHandle handle) { if (!handle) return; delete static_cast<DecoderH264 *>(handle); } __attribute__((visibility("default"))) __attribute__((used)) void release_audio_decoder(AudioDecoderHandle handle) { if (!handle) return; delete static_cast<DecoderAAC *>(handle); } __attribute__((visibility("default"))) __attribute__((used)) void push_packet_to_decoder( DecoderHandle handle, const uint8_t *data, int size, int64_t pts, bool is_key_frame) { ... }
Без наличия ключевого кадра FFmpeg не может корректно декодировать P-кадры. Так как мы можем присоединиться к трансляции в рандомный момент времени, был реализован механизм отбрасывания первых не ключевых кадров.
При поступление IDR-кадра последующие видео и аудио-чанки буферизировались и по очереди отправлялись на декодирование в нативную библиотеку.
Видео и аудио обрабатывались похожим образом: чанки по очереди отправлялись в декодер, а на выходе возвращались уже в “сырых” форматах (RGBA для видео и PCM для аудио). Аудио-чанки приходили от сервера как raw AAC без ADTS-заголовков, потому, для успешного преобразования в PCM-байты нужно было добавить их вручную:
Uint8List _aacWithAdtsHeader( Uint8List rawAac, int sampleRate, int channelsCount, ) { final frameLength = rawAac.length + 7; final adts = Uint8List(frameLength); // ADTS fixed header adts[0] = 0xFF; // Sync byte 1 adts[1] = 0xF1; // Sync byte 2 + protection absent // ADTS variable header // Profile: AAC LC (2-1=1), Sample Rate Index, Channel Config final sampleRateIndex = _getSampleRateIndex(sampleRate); adts[2] = (0x01 << 6) | (sampleRateIndex << 2) | (channelsCount >> 2); adts[3] = ((channelsCount & 0x3) << 6) | ((frameLength >> 11) & 0x03); adts[4] = (frameLength >> 3) & 0xFF; adts[5] = ((frameLength & 0x7) << 5) | 0x1F; adts[6] = 0xFC; adts.setRange(7, frameLength, rawAac); return adts; }
При успешном преобразовании данные возвращались в основной изолят, в котором происходила последующая обработка и вывод видео/аудио.
Чтобы минимизировать копирование крупных буферов при передаче данных между изолятами начальные данные из Uint8List упаковывались в TransferableTypedData. В основном изоляте, при обработке сообщений выполнялось обратное преобразование bytes.materialize().asUint8List().
class RgbaFrame { final int width; final int height; final Uint8List bytes; const RgbaFrame({ required this.width, required this.height, required this.bytes, }); Map<String, Object?> toMap() => { 'width': width, 'height': height, 'bytes': TransferableTypedData.fromList([bytes]), }; factory RgbaFrame.fromMap(Map<String, Object?> map) => RgbaFrame( width: map['width'] as int, height: map['height'] as int, bytes: (map['bytes'] as TransferableTypedData).materialize().asUint8List(), ); }
На финальном этапе происходило последнее преобразование из rgba-буфера в Image:
Future<Image> _decodeRgbaToImage(RgbaFrame rgbaFrame) async { final buffer = await ImmutableBuffer.fromUint8List(rgbaFrame.bytes); final descriptor = ImageDescriptor.raw( buffer, width: rgbaFrame.width, height: rgbaFrame.height, pixelFormat: PixelFormat.rgba8888, ); final codec = await descriptor.instantiateCodec(); final frame = await codec.getNextFrame(); return frame.image; }
На базе полученного Image выполнялся вывод кадра трансляции:
class WebCodecVideoWidget extends StatelessWidget { final WebCodecController webCodecController; const WebCodecVideo({super.key, required this.webCodecController}); @override Widget build(BuildContext context) { return ValueListenableBuilder( valueListenable: webCodecController.frameValueNotifier, builder: (context, value, child) { return RawImage(image: value, fit: BoxFit.fill); }, ); } }
На выходе аудиопоток уже находится в формате PCM и напрямую передавался в аудиосервис для воспроизведения:
Future<void> play(Uint8List aacData) async { if (!_isInitialized) return; final value = aacData.buffer.asUint16List(); await FlutterPcmSound.feed(PcmArrayInt16.fromList(value)); }
Итог
В статье приведены ключевые фрагменты, так как полная реализация завязана на внутреннюю бизнес-логику проекта.
Репозиторий с нативной частью проекта доступен по ссылке.
Основная особенность заключается в том, что декодер реализован в виде нативной C++ библиотеки, которая удерживает контекст FFmpeg на протяжении всей трансляции. Каждый новый пакет передаётся в уже инициализированный декодер, который хранит SPS/PPS, reference frames и внутренние буферы. Это позволяет корректно декодировать P-кадры и обеспечивает минимальную задержку для вывода трансляции.
Ограничения текущего решения:
декодирование выполняется программно на CPU (без использования аппаратных декодеров)
при обработке 4K-трансляций высокая нагрузка на CPU может приводить к нагреву устройства и увеличению отставания от live-трансляции
Это решение не является единственным возможным, однако выбранный подход позволил добиться задержки менее одной секунды и обеспечить предсказуемую работу на обеих платформах — iOS и Android.
Если у вас есть вопросы или интерес к теме — можете написать мне лично. Буду рад обратной связи и обсуждению альтернативных подходов!
