`ux/notifications/events` and `ux/window/events` only had macOS stream handlers, so on Android/iOS the unconditional Dart subscription threw MissingPluginException at startup (EventChannel reports activation failures straight to FlutterError.onError, bypassing the `onError:` callback). - Gate each Dart event-channel subscription to platforms that register a native handler (`defaultTargetPlatform`), silencing iOS. - `WindowPlugin`: report app foreground/background as host focus via `ProcessLifecycleOwner` ON_START/ON_STOP, so a backgrounded-but-alive process reports `focused = false`. - `NotificationsPlugin`: local notifications (show/cancel by thread/all), POST_NOTIFICATIONS request on 13+, and tap routing back over the event channel — a tap that cold-starts the process is buffered until Dart subscribes. - Regression tests for the subscription gate plus contract tests for the method/event payloads.
11 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
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.
Notifications + window focus
XNotifications and XWindow share a two-channel shape: a MethodChannel
for commands and an EventChannel for native-pushed events. Each Dart
constructor only subscribes to its EventChannel on platforms that
register a native handler (defaultTargetPlatform gate) — otherwise
activating the stream throws MissingPluginException, which Flutter
reports straight to FlutterError.onError.
Handler coverage:
| macOS | Android | iOS | |
|---|---|---|---|
XWindow (ux/window/events) |
NSApplication active/resign |
ProcessLifecycleOwner ON_START/ON_STOP |
none — focused stays true |
XNotifications (ux/notifications + …/events) |
UNUserNotificationCenter |
NotificationManagerCompat + POST_NOTIFICATIONS |
none |
On Android the two are coupled: XWindow.focused flipping to false
when the app backgrounds is what lets a consumer (Banlu's
MessageNotifier) post a local notification for a live socket message
while the process is alive but unfocused — the gap FCM intentionally
skips for socket-connected devices. The notification's tap PendingIntent
relaunches the app's launcher activity (resolved generically via
getLaunchIntentForPackage, no app class hard-coded) carrying the data
payload, which the plugin re-emits as a tap event; a tap that cold-starts
the process is buffered until Dart subscribes.