Closes H1 from the pre-ship review (the known-limit doc note added inc0d55ba). The previous workaround was "first play-through truncates the last ~16 frames; replay is fine because flush_buffers clears libavcodec." That trade-off was OK for shipping but the proper fix is to drain the reorder buffer before propagating EOS to ExoPlayer. Media3's SimpleDecoder short-circuits the end-of-stream input buffer and never invokes the subclass's decode(), so there's no hook to send avcodec_send_packet(NULL). Every override worth overriding (decode loop, queue methods, flush) is final on SimpleDecoder. So we vendor a copy as FfmpegSimpleDecoder (Apache 2.0 attribution at the top of the file) with one structural change: an EOS-drain state. On EOS input, signalEndOfInput() flushes libavcodec's reorder queue, then drainAtEndOfStream() is called on successive output buffers until it reports DRAIN_DONE — at which point the loop attaches BUFFER_FLAG_END_OF_STREAM and resumes normal teardown. Everything else mirrors SimpleDecoder verbatim so upstream improvements are cheap to pull forward. - FfmpegSimpleDecoder.java: vendored base class. - ffmpegVideoSignalEos JNI: sends avcodec_send_packet(NULL). - FfmpegVideoDecoder: extends the new base; signalEndOfInput forwards to the JNI; drainAtEndOfStream re-uses the existing ffmpegVideoReceiveFrame so per-frame PTS recovery and the pending_frame path fromc0d55bacontinue to work during drain.
Vendored FFmpeg video decoder for ux
This directory contains the JNI source + Gradle build wiring for
libffmpegJNI.so, the LGPL-licensed FFmpeg shared library that backs
io.swipelab.ux.video.ffmpeg.FfmpegVideoRenderer. The renderer is
slotted ahead of MediaCodecVideoRenderer so iOS H.264 streams with
deep DPB (has_b_frames > 8) and full-range YUV play on devices where
the platform decoder fails (notably Huawei Mate 20 on EMUI 11).
How it builds
The native library is produced by the :ux:buildFfmpegJni Gradle task,
wired as a dependency of preBuild. On any consumer build
(flutter build apk, ./gradlew assembleRelease, IDE sync) the task
runs automatically; Gradle's UP-TO-DATE checking skips it when nothing
relevant changed.
What the task does:
- Clones upstream Media3 (
1.9.2) and FFmpeg (release/6.0) into<ux-android-build>/ffmpeg-work/if missing — once per checkout. - Drops the vendored
ffmpeg_jni.cc+CMakeLists.txtover the upstream Media3 copies so the build produces a video-capable JNI. - Builds FFmpeg static libs (
libavcodec,libavutil,libswresample) with H.264 decoder enabled forarmeabi-v7a,arm64-v8a,x86,x86_64. Slow part — first build only (~30 min on a typical x86 host, ~2 min on Apple Silicon). Static libs are cached inffmpeg-work/and reused on subsequent runs. - Cross-compiles
libffmpegJNI.soper ABI via CMake + Ninja and writes the result into<ux-android-build>/jniLibs/<abi>/. AGP picks them up via thejniLibs.srcDirs +=line in build.gradle and bundles them in the AAR.
What ships in git:
ffmpeg_jni.cc— JNI bridge exposing the five entry pointsFfmpegVideoDecoder.javacalls: initialize / sendPacket / receiveFrame / flush / release. Adapted from Media3's audio-only template; the audio path was dropped (MediaCodec AAC works everywhere we ship to).CMakeLists.txt— links the FFmpeg static libs intolibffmpegJNI.so.build_ffmpeg.sh— orchestrates the clone + static-lib build + JNI link. Env-driven, invoked only by the Gradle task — not intended to be run by hand.LICENSE-FFMPEG.txt— LGPL v2.1 text. Required attribution.
No .so files in git. Everything under <build>/jniLibs/ is
generated on demand; /build is already in .gitignore.
License
FFmpeg is LGPL v2.1. We link to it dynamically (consumer apps load
libffmpegJNI.so at runtime via System.loadLibrary), which keeps the
LGPL boundary intact and does not impose copyleft on the consuming
app. The build configuration in upstream Media3's build_ffmpeg.sh
intentionally omits --enable-gpl and --enable-nonfree to keep the
binaries LGPL-only. Do not add codecs that require GPL configuration
(e.g. x264) — that would taint the artifact.
Pinned versions
- Media3
1.9.2(matches the version used by the rest ofux). - FFmpeg
release/6.0(matches Media3's tested compatibility window). - Android NDK is resolved via
android.ndkDirectoryfrom AGP — the NDK version your Android project pins, normally r27 on a recent AGP. Older NDKs may fail the 16-KB page-size alignment check enforced for Android 15.
Bumping either version: edit the MEDIA3_TAG / FFMPEG_TAG
constants near the top of build_ffmpeg.sh, then ./gradlew :ux:buildFfmpegJni --rerun-tasks to force a rebuild.
Why we vendor this instead of using media3-decoder-ffmpeg
Media3's published FFmpeg extension is audio-only.
ExperimentalFfmpegVideoRenderer in the same library has been a stub
since 2020 — createDecoder() returns null, supportsFormat() returns
FORMAT_UNSUPPORTED_TYPE. The community alternative (NextLib) is
GPL-3.0, which would impose copyleft on consumers. So we built our own
on top of the same JNI skeleton.