Adds an LGPL FFmpeg-backed video renderer that slots ahead of Media3's
MediaCodecVideoRenderer via EXTENSION_RENDERER_MODE_PREFER. Resolves
playback failures on Huawei EMUI 11 (Mate 20, Kirin 980): the Codec2
HiSilicon AVC decoder initialises cleanly on iOS High@3.1 streams with
deep DPB + full-range yuvj420p, then errors on the first sample inside
MediaCodecVideoRenderer (init-failure fallback can't catch this).
Google's C2 SW AVC decoder hits its 8-frame output-delay cap on the
same shape and stalls on dequeueOutputBuffer.
Media3's own decoder-ffmpeg ships only an audio renderer;
ExperimentalFfmpegVideoRenderer has been a stub since 2020 (returns
FORMAT_UNSUPPORTED_TYPE, createDecoder returns null). NextLib is
GPL-3.0. So we vendor our own Apache-licensed JNI on top of LGPL
FFmpeg, dynamically linked at runtime.
Build flow:
- android/ffmpeg/ holds the JNI source + CMakeLists + orchestrator
script + LGPL notice. No native binaries in git.
- :ux:buildFfmpegJni Gradle task (wired to preBuild) clones
Media3 1.9.2 + FFmpeg release/6.0 into build/ffmpeg-work/ on
first run, builds h264-only static libs per ABI, links
libffmpegJNI.so per ABI into build/jniLibs/<abi>/. AGP picks
them up via sourceSets.main.jniLibs.srcDirs +=. Gradle
UP-TO-DATE skips the task when ffmpeg_jni.cc / CMakeLists /
build_ffmpeg.sh are unchanged.
Renderer:
- FfmpegVideoDecoder (SimpleDecoder) sends each packet with its
inputBuffer.timeUs as pkt->pts; the JNI overwrites
outputBuffer.timeUs with f->pts on receive so frames emitted in
display order carry their true display PTS (input PTS in decode
order scrambles ExoPlayer's drop logic and halves the render
rate on B-frame streams).
- FfmpegOutputSurface does YUV->RGB in one GLES2 pass against an
EGL window surface sized to display orientation. Y plane uses
GL_NEAREST (1:1 sized, sampling at exact texel centres
preserves luma detail); chroma uses GL_LINEAR. Pre-rotated quad
UVs (0/90/180/270) keep the YUV sampling correct when the
coded frame needs rotation for display.
- FfmpegVideoRenderer swaps the output buffer's width/height for
90/270 streams before super.renderOutputBuffer notifies size,
matching MediaCodecVideoRenderer's post-rotation reporting.
Decoder fallback:
- Renderers.kt selects FfmpegVideoRenderer first when
libffmpegJNI.so is loaded; falls through to the platform path
for formats FFmpeg doesn't handle or ABIs without the .so.
- MediaCodec selector deprioritises every HiSilicon decoder
(OMX.hisi.* and c2.hisi.*) so the platform path picks
c2.android.avc.decoder ahead of the C2 Hisi variant when FFmpeg
isn't available. Required because the C2 Hisi failure is
post-init, which Media3's setEnableDecoderFallback(true) can't
intercept.
Compositor:
- VideoCompositor.setInputSurfaceSize lets the renderer resize the
codec-input SurfaceTexture before eglCreateWindowSurface so the
EGL surface inherits matching buffer dimensions on creation
(MediaCodec sizes natively; EGL doesn't).
- VideoPlayerInstance wires Renderers.build with a sizer callback
that calls into compositor.setInputSurfaceSize from the FFmpeg
renderer thread.
Adds docs/architecture.md with the layered video pipeline diagram,
file map, renderer-selection rationale, build flow, and LGPL
boundary notes.
8.8 KiB
ux — architecture
This doc maps the load-bearing modules in the ux Flutter package: where
each lives, what it owns, and which native pieces back it on each
platform. Banlu is the primary consumer; everything here is meant to be
reusable across other Flutter apps that depend on ux.
Conventions:
- Source paths are anchored at the package root (e.g.
lib/src/...). - Native paths use
android/...anddarwin/...(shared iOS+macOS sources) orios//macos/(platform-specific). - "Banned" means: don't reintroduce. Lints and tests exist to catch regressions where applicable.
Module map
| Module | Dart | Android | Apple |
|---|---|---|---|
XKeyboard — frame-accurate keyboard tracking + interactive dismiss |
lib/src/keyboard/ | android/src/main/jni/keyboard_bridge.c | darwin/Keyboard/ |
XSensors — gyro / accelerometer over JNI/FFI |
lib/src/sensors/ | android/src/main/jni/sensor_bridge.c | darwin/Sensors/ |
XCamera — CameraX (Android) / AVCaptureSession (Apple) |
lib/src/camera/ | android/src/main/kotlin/io/swipelab/ux/camera/ | darwin/Camera/ |
XScanner — ZXing QR scanner (Android) / VNDetect (Apple) |
lib/src/scanner/ | android/src/main/kotlin/io/swipelab/ux/scanner/ | darwin/Scanner/ |
XVideoPlayer — ExoPlayer (Android) / AVPlayer (Apple) |
lib/src/video/ | android/src/main/kotlin/io/swipelab/ux/video/ | darwin/Video/ |
XFile, XNotifications, XWindow, navi — see source |
lib/src/ | mixed | mixed |
Video pipeline (Android)
The Android video stack is the most involved piece in ux because it
spans four cooperating layers (Dart controller → Kotlin player
instance → Media3 ExoPlayer → GL compositor → Flutter texture). It
also carries the most production-load-bearing fix: a vendored FFmpeg
video decoder that fronts the platform MediaCodec path for files
the OEM hardware decoder can't handle.
Layers
Dart XVideoPlayerController (lib/src/video/x_video_player.dart)
│ MethodChannel `ux/video`
▼
Kotlin VideoPlayerInstance (one per Dart controller)
│ owns:
│ ├── ExoPlayer (Media3 1.9.2; renderers wired via Renderers.kt)
│ ├── VideoCompositor (codec output → Flutter texture; GL blit)
│ └── Flutter SurfaceTextureEntry (the texture id Dart Texture() samples)
▼
Native Media3 video renderer
│ tier 1: FfmpegVideoRenderer (libavcodec, SW decode, GL YUV→RGB)
│ tier 2: MediaCodecVideoRenderer (HW path; HiSilicon C2 decoders
│ deprioritised so EMUI 10/11 falls through to c2.android.avc.decoder)
▼
GL SurfaceTexture (compositor input)
│ compositor reads + applies SurfaceTexture transform
│ blits to Flutter SurfaceTexture (output)
▼
Flutter Texture widget samples the SurfaceTexture, scales to widget size
Files
| What | Where |
|---|---|
| Dart controller | lib/src/video/x_video_player.dart |
| MethodChannel binding | lib/src/video/x_video_player_channel.dart |
| Texture widget | lib/src/video/x_video_player_view.dart |
| Per-controller native player | android/src/main/kotlin/io/swipelab/ux/video/VideoPlayerInstance.kt |
| ExoPlayer renderer factory | android/src/main/kotlin/io/swipelab/ux/video/Renderers.kt |
| GL blit between codec and Flutter texture | android/src/main/kotlin/io/swipelab/ux/video/VideoCompositor.kt |
| FFmpeg renderer (Java) | android/src/main/java/io/swipelab/ux/video/ffmpeg/ |
| FFmpeg JNI source | android/ffmpeg/ffmpeg_jni.cc |
| FFmpeg build orchestrator | android/ffmpeg/build_ffmpeg.sh |
Renderer selection (Renderers.kt)
Renderers.build(context, surfaceSizer) returns a DefaultRenderersFactory
subclass that builds the video renderer list with FFmpeg first and
MediaCodec second:
FfmpegVideoRenderer— picked whenFfmpegLibrary.isAvailable()returns true (i.e.libffmpegJNI.sois loaded for the device's ABI) and the format is H.264. Software decode via libavcodec, no DPB cap, handles iOSyuvj420p(full-range) and deep B-frame reorder structures that defeat platform decoders on older Huawei silicon. YUV→RGB happens in a one-pass GLES2 shader insideFfmpegOutputSurface. Y plane usesGL_NEARESTfiltering (1:1 sized to the EGL surface, sampling at exact texel centres preserves luma detail); chroma usesGL_LINEARfor clean 4:2:0 upsampling.MediaCodecVideoRenderer(Media3 default) — picked for any other format, or if FFmpeg is unavailable. Two non-default tweaks:setEnableDecoderFallback(true)so init-time failures bounce to the next decoder.- Custom
MediaCodecSelectordeprioritises every HiSilicon decoder (OMX.hisi.*andc2.hisi.*). The EMUI 10 OMX variant fails codec start (caught by fallback); the EMUI 11 C2 variant initialises cleanly but errors on the first sample, which fallback can't catch — name-based deprioritisation is the only way past it when FFmpeg is unavailable for that ABI.
The two-tier design is intentional: FFmpeg adds a slight battery cost vs. hardware decoding, but it's the only path that reliably plays the full range of producer-side content (iOS recordings in particular). Hardware stays as the fallback for the formats and devices where it works.
Why we vendor an FFmpeg video renderer
Media3's published decoder-ffmpeg is audio-only.
ExperimentalFfmpegVideoRenderer in the same library has been a stub
since 2020 (createDecoder returns null, supportsFormat returns
FORMAT_UNSUPPORTED_TYPE). The only well-maintained community
alternative (NextLib) is GPL-3.0 — would impose copyleft on every
consumer. So we ship our own Apache-licensed JNI on top of the LGPL
FFmpeg, vendored under android/ffmpeg/.
How the FFmpeg .so gets built
There are no native binaries in the repo. The :ux:buildFfmpegJni
Gradle task (wired to preBuild in
android/build.gradle) handles everything:
- Clones Media3 (
1.9.2) and FFmpeg (release/6.0) into the consumer'sbuild/ffmpeg-work/on first run. - Drops the vendored
ffmpeg_jni.cc+CMakeLists.txtover the upstream copies (so the produced JNI exposes the video entry pointsFfmpegVideoDecoder.javacalls). - Cross-builds FFmpeg static libs (H.264 only) for all 4 Android ABIs.
- Links
libffmpegJNI.soper ABI and writes them into<ux-build>/jniLibs/<abi>/, which AGP picks up viasourceSets.main.jniLibs.srcDirs +=.
Up-to-date checking on ffmpeg_jni.cc + CMakeLists.txt +
build_ffmpeg.sh means subsequent builds skip the task entirely. The
slow part (FFmpeg static-lib build, ~30 min on an x86 host, ~2 min on
Apple Silicon) only happens on the first build for a given checkout,
or after a gradle clean.
License: FFmpeg is LGPL v2.1, linked dynamically at runtime via
System.loadLibrary("ffmpegJNI") — the LGPL boundary stays intact.
The build config omits --enable-gpl and --enable-nonfree. See
android/ffmpeg/LICENSE-FFMPEG.txt
and android/ffmpeg/README.md.
Looping and end-of-stream
XVideoPlayerController.setLooping(true) maps to ExoPlayer's
REPEAT_MODE_ONE (the entire seek-to-0-and-resume cycle is handled
in-engine). Non-looping playback emits a completed event when
STATE_ENDED is reached without repeat — see
VideoPlayerInstance.kt. Consumers that need tap-play-at-end-restarts
behavior (Telegram parity) check position against duration on the
Dart side and call seekTo(Duration.zero) before play() when within
2 seconds of the end.
Video pipeline (Apple)
AVPlayer + AVPlayerItem + AVPlayerItemVideoOutput. No FFmpeg
fallback needed — AVFoundation handles iOS-produced H.264 (and HEVC)
directly without the DPB-cap / full-range quirks the Android platform
decoders trip over. See
darwin/Video/VideoPlayerInstance.swift.