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.
168 lines
8.8 KiB
Markdown
168 lines
8.8 KiB
Markdown
# 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/...` and `darwin/...` (shared
|
|
iOS+macOS sources) or `ios/` / `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/](../lib/src/keyboard/) | [android/src/main/jni/keyboard_bridge.c](../android/src/main/jni/keyboard_bridge.c) | [darwin/Keyboard/](../darwin/Keyboard/) |
|
|
| `XSensors` — gyro / accelerometer over JNI/FFI | [lib/src/sensors/](../lib/src/sensors/) | [android/src/main/jni/sensor_bridge.c](../android/src/main/jni/sensor_bridge.c) | [darwin/Sensors/](../darwin/Sensors/) |
|
|
| `XCamera` — CameraX (Android) / AVCaptureSession (Apple) | [lib/src/camera/](../lib/src/camera/) | [android/src/main/kotlin/io/swipelab/ux/camera/](../android/src/main/kotlin/io/swipelab/ux/camera/) | [darwin/Camera/](../darwin/Camera/) |
|
|
| `XScanner` — ZXing QR scanner (Android) / VNDetect (Apple) | [lib/src/scanner/](../lib/src/scanner/) | [android/src/main/kotlin/io/swipelab/ux/scanner/](../android/src/main/kotlin/io/swipelab/ux/scanner/) | [darwin/Scanner/](../darwin/Scanner/) |
|
|
| `XVideoPlayer` — ExoPlayer (Android) / AVPlayer (Apple) | [lib/src/video/](../lib/src/video/) | [android/src/main/kotlin/io/swipelab/ux/video/](../android/src/main/kotlin/io/swipelab/ux/video/) | [darwin/Video/](../darwin/Video/) |
|
|
| `XFile`, `XNotifications`, `XWindow`, navi — see source | [lib/src/](../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](../lib/src/video/x_video_player.dart) |
|
|
| MethodChannel binding | [lib/src/video/x_video_player_channel.dart](../lib/src/video/x_video_player_channel.dart) |
|
|
| Texture widget | [lib/src/video/x_video_player_view.dart](../lib/src/video/x_video_player_view.dart) |
|
|
| Per-controller native player | [android/src/main/kotlin/io/swipelab/ux/video/VideoPlayerInstance.kt](../android/src/main/kotlin/io/swipelab/ux/video/VideoPlayerInstance.kt) |
|
|
| ExoPlayer renderer factory | [android/src/main/kotlin/io/swipelab/ux/video/Renderers.kt](../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](../android/src/main/kotlin/io/swipelab/ux/video/VideoCompositor.kt) |
|
|
| FFmpeg renderer (Java) | [android/src/main/java/io/swipelab/ux/video/ffmpeg/](../android/src/main/java/io/swipelab/ux/video/ffmpeg/) |
|
|
| FFmpeg JNI source | [android/ffmpeg/ffmpeg_jni.cc](../android/ffmpeg/ffmpeg_jni.cc) |
|
|
| FFmpeg build orchestrator | [android/ffmpeg/build_ffmpeg.sh](../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:
|
|
|
|
1. **`FfmpegVideoRenderer`** — picked when `FfmpegLibrary.isAvailable()`
|
|
returns true (i.e. `libffmpegJNI.so` is loaded for the device's ABI)
|
|
*and* the format is H.264. Software decode via libavcodec, no DPB
|
|
cap, handles iOS `yuvj420p` (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 inside
|
|
`FfmpegOutputSurface`. Y plane uses `GL_NEAREST` filtering (1:1
|
|
sized to the EGL surface, sampling at exact texel centres preserves
|
|
luma detail); chroma uses `GL_LINEAR` for clean 4:2:0 upsampling.
|
|
2. **`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 `MediaCodecSelector` deprioritises every HiSilicon
|
|
decoder (`OMX.hisi.*` and `c2.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](../android/build.gradle)) handles everything:
|
|
|
|
1. Clones Media3 (`1.9.2`) and FFmpeg (`release/6.0`) into the
|
|
consumer's `build/ffmpeg-work/` on first run.
|
|
2. Drops the vendored `ffmpeg_jni.cc` + `CMakeLists.txt` over the
|
|
upstream copies (so the produced JNI exposes the video entry points
|
|
`FfmpegVideoDecoder.java` calls).
|
|
3. Cross-builds FFmpeg static libs (H.264 only) for all 4 Android ABIs.
|
|
4. Links `libffmpegJNI.so` per ABI and writes them into
|
|
`<ux-build>/jniLibs/<abi>/`, which AGP picks up via
|
|
`sourceSets.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](../android/ffmpeg/LICENSE-FFMPEG.txt)
|
|
and [android/ffmpeg/README.md](../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](../darwin/Video/VideoPlayerInstance.swift).
|