
Многие наверняка уже имели опыт реализации прямой трансляции в мобильных приложениях, и я в том числе был уверен, что сделать фичу не займет много времени с помощью таких библиотек как: 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.
Если у вас есть вопросы или интерес к теме — можете написать мне лично. Буду рад обратной связи и обсуждению альтернативных подходов!
Автор: aidar-aliullov
