video_player + insets: native playback backend + animated viewPadding

- video_player: ExoPlayer (Android) / AVPlayer (iOS/macOS) backend with
  PixelBufferSink, method-channel adapter, Dart-side XVideoPlayer +
  testing fake.
- insets: XInsets singleton + XAnimatedInsets widget lerp the system
  viewPadding over 220ms so OS bar visibility toggles
  (immersiveSticky <-> edgeToEdge) slide bottom-/top-anchored UI into
  place instead of snapping by the nav-bar / status-bar height.
This commit is contained in:
agra
2026-05-23 15:57:15 +03:00
parent 96df891b9d
commit de4925adf9
28 changed files with 4243 additions and 14 deletions

View File

@@ -70,4 +70,10 @@ dependencies {
implementation 'androidx.lifecycle:lifecycle-process:2.7.0'
// Pure-Kotlin/Java QR decoder. ~470 KB jar, no Play Services dep.
implementation 'com.google.zxing:core:3.5.3'
// Media3 for ux.video_player. Same version line video_player_android
// 2.9.5 pulls in (1.9.x) so the spike fork can coexist during the
// Phase 2/3 migration without dragging in two ExoPlayer copies.
def media3Version = '1.9.2'
implementation "androidx.media3:media3-exoplayer:$media3Version"
implementation "androidx.media3:media3-common:$media3Version"
}

View File

@@ -4,7 +4,9 @@ import io.flutter.embedding.engine.plugins.FlutterPlugin
import io.flutter.embedding.engine.plugins.activity.ActivityAware
import io.flutter.embedding.engine.plugins.activity.ActivityPluginBinding
import io.swipelab.ux.camera.CameraPlugin
import io.swipelab.ux.video.VideoPlayerPlugin
@androidx.annotation.OptIn(androidx.media3.common.util.UnstableApi::class)
class XPlugin : FlutterPlugin, ActivityAware {
private val plugins: List<NativePlugin> = listOf(
KeyboardPlugin(),
@@ -14,6 +16,7 @@ class XPlugin : FlutterPlugin, ActivityAware {
ClipboardPlugin(),
GalleryPlugin(),
CameraPlugin(),
VideoPlayerPlugin(),
CrashPlugin(),
UrlPlugin(),
)

View File

@@ -0,0 +1,40 @@
package io.swipelab.ux.video
import android.content.Context
import androidx.media3.common.util.UnstableApi
import androidx.media3.exoplayer.DefaultRenderersFactory
import androidx.media3.exoplayer.RenderersFactory
import androidx.media3.exoplayer.mediacodec.MediaCodecInfo
import androidx.media3.exoplayer.mediacodec.MediaCodecSelector
/// Renderer factory configured for Banlu. Two non-default tweaks:
///
/// - `setEnableDecoderFallback(true)` — Media3's default refuses to
/// fall back if the primary decoder fails to start. On Huawei
/// EMUI (Mate 20 Pro / LYA-L29 / API 29) the hardware AVC decoder
/// `OMX.hisi.video.decoder.avc` fails codec start; without
/// fallback the surface stays black.
/// - `MediaCodecSelector` deprioritises `OMX.hisi.*` so Media3
/// picks the working software decoder first
/// (`c2.android.avc.decoder` on the affected device). The
/// hardware decoder stays as a last-resort option for devices
/// where it works correctly.
@UnstableApi
internal object Renderers {
fun build(context: Context): RenderersFactory {
return DefaultRenderersFactory(context)
.setEnableDecoderFallback(true)
.setMediaCodecSelector { mimeType, requiresSecure, requiresTunneling ->
val infos = MediaCodecSelector.DEFAULT.getDecoderInfos(
mimeType, requiresSecure, requiresTunneling,
)
val ok = ArrayList<MediaCodecInfo>(infos.size)
val broken = ArrayList<MediaCodecInfo>()
for (info in infos) {
if (info.name.startsWith("OMX.hisi.")) broken.add(info) else ok.add(info)
}
ok.addAll(broken)
ok
}
}
}

View File

@@ -0,0 +1,433 @@
package io.swipelab.ux.video
import android.graphics.SurfaceTexture
import android.opengl.EGL14
import android.opengl.EGLConfig
import android.opengl.EGLContext
import android.opengl.EGLDisplay
import android.opengl.EGLSurface
import android.opengl.GLES11Ext
import android.opengl.GLES20
import android.opengl.Matrix
import android.os.Handler
import android.os.HandlerThread
import android.util.Log
import android.view.Surface
import androidx.media3.common.util.UnstableApi
import java.nio.ByteBuffer
import java.nio.ByteOrder
import java.nio.FloatBuffer
/// GLES-based blit between an internal `SurfaceTexture` (codec input)
/// and a Flutter `SurfaceTextureEntry`-backed `Surface` (output).
///
/// Why this exists: on Huawei EMUI 10 the software AVC decoder
/// (`c2.android.avc.decoder`) writes YUV frames into a stride-padded
/// buffer and sets a SurfaceTexture transform matrix that doesn't
/// fully describe the crop — Flutter's `Texture` widget then samples
/// the padding bytes and shows a green strip on the right edge.
/// Platform-view paths sidestep this but lose smooth gallery
/// animations; Media3's `DefaultVideoFrameProcessor` won't render
/// into a SurfaceTexture-backed Surface (it drops every frame with
/// "FinalShaderWrapper: Output surface and size not set"). So we run
/// our own one-shader blit downstream of the codec.
///
/// Lifecycle:
/// 1. [start] — EGL display + context + a tiny pbuffer surface so
/// the context can be current before any output exists. Creates
/// the input `SurfaceTexture` + Surface and hands the Surface to
/// the caller via [onInputSurfaceReady].
/// 2. [setDisplaySize] — first call (typically from
/// `Player.Listener.onVideoSizeChanged`) sizes the Flutter
/// output `SurfaceTexture` to match the codec exactly and
/// creates the EGL window surface. Until then `drawFrame`
/// consumes input frames (so the codec doesn't stall on a full
/// queue) but does not paint — there is no output to paint to
/// and no dimensions to invent.
/// 3. [dispose] — tears down the render thread, GL, both EGL
/// surfaces, and the context.
@UnstableApi
internal class VideoCompositor(
private val outputSurface: Surface,
/// Called once the input `SurfaceTexture` is ready and wrapped in a
/// Surface. The caller (typically [VideoPlayerInstance]) hands this
/// Surface to ExoPlayer via `setVideoSurface`. Fires on the render
/// thread.
private val onInputSurfaceReady: (Surface) -> Unit,
/// Fires exactly once, on the render thread, after the first
/// `eglSwapBuffers` lands a real frame on [outputSurface]. The
/// player instance uses this to gate `initialize()` resolution so
/// the gallery's thumb→texture swap happens against a populated
/// texture, not an empty one (avoids the black flash on hero
/// landing).
private val onFirstFrame: (() -> Unit)? = null,
/// Called when the codec's display dimensions land, with the same
/// width / height [setDisplaySize] was called with. Lets the owning
/// instance resize the Flutter `SurfaceTexture`'s `defaultBufferSize`
/// in sync with the EGL window surface. Fires on the render thread.
private val onOutputSized: ((Int, Int) -> Unit)? = null,
) {
private val thread = HandlerThread("ux.video.compositor", Thread.NORM_PRIORITY)
private lateinit var handler: Handler
// GL state lives on the render thread.
private var eglDisplay: EGLDisplay = EGL14.EGL_NO_DISPLAY
private var eglConfig: EGLConfig? = null
private var eglContext: EGLContext = EGL14.EGL_NO_CONTEXT
private var eglPbufferSurface: EGLSurface = EGL14.EGL_NO_SURFACE
private var eglWindowSurface: EGLSurface = EGL14.EGL_NO_SURFACE
private var inputTextureId: Int = 0
private var inputSurfaceTexture: SurfaceTexture? = null
private var inputSurface: Surface? = null
private var program: Int = 0
private var aPositionLoc: Int = -1
private var aTexCoordLoc: Int = -1
private var uStMatrixLoc: Int = -1
private var uCropMatrixLoc: Int = -1
private val stMatrix = FloatArray(16).also { Matrix.setIdentityM(it, 0) }
private val cropMatrix = FloatArray(16).also { Matrix.setIdentityM(it, 0) }
/// Codec-reported display dimensions. Zero until [setDisplaySize]
/// has been called by the player listener.
private var displayWidth: Int = 0
private var displayHeight: Int = 0
private val quadVertices: FloatBuffer = ByteBuffer
.allocateDirect(QUAD_COORDS.size * 4)
.order(ByteOrder.nativeOrder())
.asFloatBuffer()
.apply { put(QUAD_COORDS).position(0) }
private val quadTexCoords: FloatBuffer = ByteBuffer
.allocateDirect(QUAD_TEXCOORDS.size * 4)
.order(ByteOrder.nativeOrder())
.asFloatBuffer()
.apply { put(QUAD_TEXCOORDS).position(0) }
private var disposed = false
private var firstFrameDelivered = false
fun start() {
thread.start()
handler = Handler(thread.looper)
handler.post {
try {
setupEgl()
Log.i(TAG, "egl ready ctx=$eglContext pbuf=$eglPbufferSurface")
setupProgram()
Log.i(TAG, "program ready id=$program")
setupInputSurfaceTexture()
Log.i(TAG, "input ready tex=$inputTextureId surface=$inputSurface")
onInputSurfaceReady(inputSurface!!)
} catch (t: Throwable) {
Log.e(TAG, "compositor.start failed", t)
teardown()
}
}
}
fun setDisplaySize(width: Int, height: Int) {
if (disposed) return
if (width <= 0 || height <= 0) return
handler.post {
val changed = width != displayWidth || height != displayHeight
if (!changed) return@post
displayWidth = width
displayHeight = height
onOutputSized?.invoke(width, height)
Log.i(TAG, "setDisplaySize ${width}x$height window=$eglWindowSurface")
if (eglWindowSurface == EGL14.EGL_NO_SURFACE) {
try {
createWindowSurface()
Log.i(TAG, "window surface created $eglWindowSurface")
// ExoPlayer in default `playWhenReady = false` decodes
// exactly one preview frame and then pauses waiting for
// `play()` — that frame is already sitting in the input
// SurfaceTexture (we called `updateTexImage` on the
// pre-window draw attempt). Without a follow-up draw the
// first-frame signal never fires, `initialize()` never
// resolves on the Dart side, the gallery never transitions
// to ready and autoplay never triggers. Paint that cached
// frame to the Flutter output now.
drawFrame()
} catch (t: Throwable) {
Log.e(TAG, "createWindowSurface failed", t)
}
}
}
}
fun dispose() {
if (disposed) return
disposed = true
handler.post {
teardown()
thread.quitSafely()
}
}
// ---- render thread ---------------------------------------------------
private fun setupEgl() {
eglDisplay = EGL14.eglGetDisplay(EGL14.EGL_DEFAULT_DISPLAY)
if (eglDisplay == EGL14.EGL_NO_DISPLAY) error("eglGetDisplay failed")
val version = IntArray(2)
if (!EGL14.eglInitialize(eglDisplay, version, 0, version, 1)) {
error("eglInitialize failed")
}
val configAttribs = intArrayOf(
EGL14.EGL_RED_SIZE, 8,
EGL14.EGL_GREEN_SIZE, 8,
EGL14.EGL_BLUE_SIZE, 8,
EGL14.EGL_ALPHA_SIZE, 8,
EGL14.EGL_RENDERABLE_TYPE, EGL14.EGL_OPENGL_ES2_BIT,
// Surface type covers both window (output) and pbuffer
// (bootstrap) — same config serves both.
EGL14.EGL_SURFACE_TYPE, EGL14.EGL_WINDOW_BIT or EGL14.EGL_PBUFFER_BIT,
EGL14.EGL_NONE,
)
val configs = arrayOfNulls<EGLConfig>(1)
val numConfigs = IntArray(1)
if (!EGL14.eglChooseConfig(
eglDisplay, configAttribs, 0, configs, 0, configs.size, numConfigs, 0,
) || numConfigs[0] == 0
) {
error("eglChooseConfig failed")
}
eglConfig = configs[0]
val contextAttribs = intArrayOf(
EGL14.EGL_CONTEXT_CLIENT_VERSION, 2,
EGL14.EGL_NONE,
)
eglContext = EGL14.eglCreateContext(
eglDisplay, eglConfig, EGL14.EGL_NO_CONTEXT, contextAttribs, 0,
)
if (eglContext == EGL14.EGL_NO_CONTEXT) error("eglCreateContext failed")
val pbufferAttribs = intArrayOf(
EGL14.EGL_WIDTH, 1,
EGL14.EGL_HEIGHT, 1,
EGL14.EGL_NONE,
)
eglPbufferSurface = EGL14.eglCreatePbufferSurface(
eglDisplay, eglConfig, pbufferAttribs, 0,
)
if (eglPbufferSurface == EGL14.EGL_NO_SURFACE) error("eglCreatePbufferSurface failed")
if (!EGL14.eglMakeCurrent(
eglDisplay, eglPbufferSurface, eglPbufferSurface, eglContext,
)
) {
error("eglMakeCurrent(pbuffer) failed")
}
}
private fun createWindowSurface() {
val config = eglConfig ?: error("createWindowSurface before setupEgl")
val attribs = intArrayOf(EGL14.EGL_NONE)
val surface = EGL14.eglCreateWindowSurface(
eglDisplay, config, outputSurface, attribs, 0,
)
if (surface == EGL14.EGL_NO_SURFACE) error("eglCreateWindowSurface failed")
eglWindowSurface = surface
if (!EGL14.eglMakeCurrent(eglDisplay, surface, surface, eglContext)) {
error("eglMakeCurrent(window) failed")
}
}
private fun setupProgram() {
val vsh = compileShader(GLES20.GL_VERTEX_SHADER, VERTEX_SHADER)
val fsh = compileShader(GLES20.GL_FRAGMENT_SHADER, FRAGMENT_SHADER)
program = GLES20.glCreateProgram()
GLES20.glAttachShader(program, vsh)
GLES20.glAttachShader(program, fsh)
GLES20.glLinkProgram(program)
val linked = IntArray(1)
GLES20.glGetProgramiv(program, GLES20.GL_LINK_STATUS, linked, 0)
if (linked[0] == 0) {
val info = GLES20.glGetProgramInfoLog(program)
GLES20.glDeleteProgram(program)
program = 0
error("Program link failed: $info")
}
GLES20.glDeleteShader(vsh)
GLES20.glDeleteShader(fsh)
aPositionLoc = GLES20.glGetAttribLocation(program, "aPosition")
aTexCoordLoc = GLES20.glGetAttribLocation(program, "aTexCoord")
uStMatrixLoc = GLES20.glGetUniformLocation(program, "uStMatrix")
uCropMatrixLoc = GLES20.glGetUniformLocation(program, "uCropMatrix")
}
private fun setupInputSurfaceTexture() {
val ids = IntArray(1)
GLES20.glGenTextures(1, ids, 0)
inputTextureId = ids[0]
GLES20.glBindTexture(GLES11Ext.GL_TEXTURE_EXTERNAL_OES, inputTextureId)
GLES20.glTexParameteri(
GLES11Ext.GL_TEXTURE_EXTERNAL_OES,
GLES20.GL_TEXTURE_MIN_FILTER, GLES20.GL_LINEAR,
)
GLES20.glTexParameteri(
GLES11Ext.GL_TEXTURE_EXTERNAL_OES,
GLES20.GL_TEXTURE_MAG_FILTER, GLES20.GL_LINEAR,
)
GLES20.glTexParameteri(
GLES11Ext.GL_TEXTURE_EXTERNAL_OES,
GLES20.GL_TEXTURE_WRAP_S, GLES20.GL_CLAMP_TO_EDGE,
)
GLES20.glTexParameteri(
GLES11Ext.GL_TEXTURE_EXTERNAL_OES,
GLES20.GL_TEXTURE_WRAP_T, GLES20.GL_CLAMP_TO_EDGE,
)
val st = SurfaceTexture(inputTextureId)
st.setOnFrameAvailableListener({ _ ->
if (disposed) return@setOnFrameAvailableListener
handler.post { drawFrame() }
}, handler)
inputSurfaceTexture = st
inputSurface = Surface(st)
}
private fun drawFrame() {
if (disposed) return
val st = inputSurfaceTexture ?: return
try {
// Always consume the input frame so the codec's surface queue
// doesn't fill up and stall, even before the EGL window
// surface exists.
st.updateTexImage()
st.getTransformMatrix(stMatrix)
} catch (t: Throwable) {
Log.e(TAG, "updateTexImage failed", t)
return
}
if (eglWindowSurface == EGL14.EGL_NO_SURFACE) {
Log.i(TAG, "drawFrame skipped: no window surface yet")
return
}
if (displayWidth <= 0 || displayHeight <= 0) return
GLES20.glViewport(0, 0, displayWidth, displayHeight)
GLES20.glClearColor(0f, 0f, 0f, 1f)
GLES20.glClear(GLES20.GL_COLOR_BUFFER_BIT)
GLES20.glUseProgram(program)
GLES20.glActiveTexture(GLES20.GL_TEXTURE0)
GLES20.glBindTexture(GLES11Ext.GL_TEXTURE_EXTERNAL_OES, inputTextureId)
GLES20.glUniformMatrix4fv(uStMatrixLoc, 1, false, stMatrix, 0)
GLES20.glUniformMatrix4fv(uCropMatrixLoc, 1, false, cropMatrix, 0)
quadVertices.position(0)
GLES20.glEnableVertexAttribArray(aPositionLoc)
GLES20.glVertexAttribPointer(aPositionLoc, 2, GLES20.GL_FLOAT, false, 0, quadVertices)
quadTexCoords.position(0)
GLES20.glEnableVertexAttribArray(aTexCoordLoc)
GLES20.glVertexAttribPointer(aTexCoordLoc, 2, GLES20.GL_FLOAT, false, 0, quadTexCoords)
GLES20.glDrawArrays(GLES20.GL_TRIANGLE_STRIP, 0, 4)
GLES20.glDisableVertexAttribArray(aPositionLoc)
GLES20.glDisableVertexAttribArray(aTexCoordLoc)
EGL14.eglSwapBuffers(eglDisplay, eglWindowSurface)
if (!firstFrameDelivered) {
firstFrameDelivered = true
Log.i(TAG, "first frame delivered to Flutter texture")
onFirstFrame?.invoke()
}
}
private fun teardown() {
inputSurface?.release()
inputSurface = null
inputSurfaceTexture?.release()
inputSurfaceTexture = null
if (inputTextureId != 0) {
GLES20.glDeleteTextures(1, intArrayOf(inputTextureId), 0)
inputTextureId = 0
}
if (program != 0) {
GLES20.glDeleteProgram(program)
program = 0
}
if (eglDisplay != EGL14.EGL_NO_DISPLAY) {
EGL14.eglMakeCurrent(
eglDisplay,
EGL14.EGL_NO_SURFACE, EGL14.EGL_NO_SURFACE,
EGL14.EGL_NO_CONTEXT,
)
if (eglWindowSurface != EGL14.EGL_NO_SURFACE) {
EGL14.eglDestroySurface(eglDisplay, eglWindowSurface)
eglWindowSurface = EGL14.EGL_NO_SURFACE
}
if (eglPbufferSurface != EGL14.EGL_NO_SURFACE) {
EGL14.eglDestroySurface(eglDisplay, eglPbufferSurface)
eglPbufferSurface = EGL14.EGL_NO_SURFACE
}
if (eglContext != EGL14.EGL_NO_CONTEXT) {
EGL14.eglDestroyContext(eglDisplay, eglContext)
eglContext = EGL14.EGL_NO_CONTEXT
}
EGL14.eglTerminate(eglDisplay)
eglDisplay = EGL14.EGL_NO_DISPLAY
}
}
private fun compileShader(type: Int, source: String): Int {
val shader = GLES20.glCreateShader(type)
GLES20.glShaderSource(shader, source)
GLES20.glCompileShader(shader)
val status = IntArray(1)
GLES20.glGetShaderiv(shader, GLES20.GL_COMPILE_STATUS, status, 0)
if (status[0] == 0) {
val info = GLES20.glGetShaderInfoLog(shader)
GLES20.glDeleteShader(shader)
error("Shader compile failed: $info")
}
return shader
}
companion object {
private const val TAG = "UxVideoCompositor"
/// Full-screen triangle strip in clip space (4 verts, 2 triangles).
private val QUAD_COORDS = floatArrayOf(
-1f, -1f,
1f, -1f,
-1f, 1f,
1f, 1f,
)
private val QUAD_TEXCOORDS = floatArrayOf(
0f, 0f,
1f, 0f,
0f, 1f,
1f, 1f,
)
private val VERTEX_SHADER = """
attribute vec4 aPosition;
attribute vec2 aTexCoord;
uniform mat4 uStMatrix;
uniform mat4 uCropMatrix;
varying vec2 vTexCoord;
void main() {
gl_Position = aPosition;
vec4 tc = uStMatrix * uCropMatrix * vec4(aTexCoord, 0.0, 1.0);
vTexCoord = tc.xy;
}
""".trimIndent()
private val FRAGMENT_SHADER = """
#extension GL_OES_EGL_image_external : require
precision mediump float;
uniform samplerExternalOES uTexture;
varying vec2 vTexCoord;
void main() {
gl_FragColor = texture2D(uTexture, vTexCoord);
}
""".trimIndent()
}
}

View File

@@ -0,0 +1,294 @@
package io.swipelab.ux.video
import android.content.Context
import android.net.Uri
import android.os.Handler
import android.os.Looper
import android.util.Log
import android.view.Surface
import androidx.media3.common.MediaItem
import androidx.media3.common.PlaybackException
import androidx.media3.common.Player
import androidx.media3.common.VideoSize
import androidx.media3.common.util.UnstableApi
import androidx.media3.exoplayer.ExoPlayer
import io.flutter.view.TextureRegistry
/// One per Dart-side `XVideoPlayerController`. Owns:
/// - the [ExoPlayer] built with our renderer-factory (decoder
/// fallback + `OMX.hisi.*` deprioritise)
/// - a [VideoCompositor] that runs a one-shader GLES blit between
/// the codec's output surface and the Flutter
/// [TextureRegistry.SurfaceTextureEntry] the Dart-side
/// `Texture(textureId:)` widget samples
///
/// Methods run on the main thread.
@UnstableApi
internal class VideoPlayerInstance(
val handle: Int,
private val context: Context,
private val textureEntry: TextureRegistry.SurfaceTextureEntry,
) {
/// Pushed to Dart as `{event, handle, …}` payloads. Set by the plugin.
var onEvent: ((Map<String, Any?>) -> Unit)? = null
val textureId: Long get() = textureEntry.id()
private val mainHandler = Handler(Looper.getMainLooper())
private val player: ExoPlayer = ExoPlayer.Builder(context, Renderers.build(context)).build()
/// Wrapped around the Flutter `SurfaceTextureEntry`'s `SurfaceTexture`.
/// We deliberately do NOT call `setDefaultBufferSize` here — that
/// would lock the Flutter texture to an arbitrary aspect ratio
/// before the codec reports the real one, causing a visible flicker
/// at the moment the gallery flips from thumbnail to live texture.
/// The compositor delays creating its EGL window surface (and thus
/// allocating any backing buffer) until the codec's dimensions
/// arrive via [onOutputSized] below — at which point we size the
/// SurfaceTexture in the same render-thread tick.
private val outputSurface: Surface = Surface(textureEntry.surfaceTexture())
private val compositor = VideoCompositor(
outputSurface = outputSurface,
onInputSurfaceReady = { inputSurface ->
mainHandler.post {
if (disposed) {
Log.i(TAG, "h$handle setVideoSurface skipped (disposed)")
return@post
}
Log.i(TAG, "h$handle setVideoSurface=$inputSurface")
player.setVideoSurface(inputSurface)
}
},
onFirstFrame = {
// Fires on the compositor's render thread.
mainHandler.post {
if (disposed) return@post
Log.i(TAG, "h$handle firstFrameRendered")
firstFrameRendered = true
maybeResolvePrepare()
}
},
onOutputSized = { width, height ->
// Render thread — safe to touch the SurfaceTexture here.
// Setting `defaultBufferSize` immediately before the
// compositor calls `eglCreateWindowSurface` keeps the EGL
// surface's natural dimensions matched to the codec.
textureEntry.surfaceTexture().setDefaultBufferSize(width, height)
},
)
private var disposed = false
private var firstFrameRendered = false
private var stateReady = false
private var pendingPrepare: ((Throwable?, VideoSize, Long) -> Unit)? = null
private var lastReportedWidth: Int = -1
private var lastReportedHeight: Int = -1
private var lastReportedBufferedMs: Long = -1L
private var lastIsPlaying: Boolean = false
private var lastIsBuffering: Boolean = false
private val tickRunnable = object : Runnable {
override fun run() {
if (disposed) return
emitPositionAndBuffer()
mainHandler.postDelayed(this, POSITION_TICK_MS)
}
}
private val listener = object : Player.Listener {
override fun onPlaybackStateChanged(state: Int) {
val buffering = state == Player.STATE_BUFFERING
if (buffering != lastIsBuffering) {
lastIsBuffering = buffering
emitStateChange(isBuffering = buffering)
}
if (state == Player.STATE_ENDED &&
player.repeatMode != Player.REPEAT_MODE_ONE &&
player.repeatMode != Player.REPEAT_MODE_ALL
) {
emit(mapOf("event" to "completed"))
}
}
override fun onIsPlayingChanged(isPlaying: Boolean) {
if (isPlaying != lastIsPlaying) {
lastIsPlaying = isPlaying
emitStateChange(isPlaying = isPlaying)
}
}
override fun onVideoSizeChanged(videoSize: VideoSize) {
if (videoSize.width != lastReportedWidth || videoSize.height != lastReportedHeight) {
lastReportedWidth = videoSize.width
lastReportedHeight = videoSize.height
Log.i(TAG, "h$handle onVideoSizeChanged ${videoSize.width}x${videoSize.height}")
if (videoSize.width > 0 && videoSize.height > 0) {
// Compositor sizes the Flutter SurfaceTexture and creates
// its EGL window surface in one render-thread step (see
// [VideoCompositor.setDisplaySize] + the `onOutputSized`
// wiring above), so the Flutter `Texture` widget's first
// visible frame already has correct natural dimensions.
compositor.setDisplaySize(videoSize.width, videoSize.height)
}
emit(
mapOf(
"event" to "sizeChanged",
"size" to mapOf(
"width" to videoSize.width.toDouble(),
"height" to videoSize.height.toDouble(),
),
),
)
}
}
override fun onPlayerError(error: PlaybackException) {
emit(
mapOf(
"event" to "error",
"code" to error.errorCodeName,
"description" to (error.message ?: error.errorCodeName),
),
)
}
}
init {
player.addListener(listener)
// Compositor allocates EGL + GLES on its own thread, then hands
// back the input Surface for the codec via onInputSurfaceReady.
compositor.start()
}
fun setSource(uri: Uri) {
check(!disposed)
player.setMediaItem(MediaItem.fromUri(uri))
}
fun prepare(onReady: (Throwable?, VideoSize, Long) -> Unit) {
check(!disposed)
pendingPrepare = onReady
val prepListener = object : Player.Listener {
override fun onPlaybackStateChanged(state: Int) {
if (disposed) return
if (state == Player.STATE_READY) {
player.removeListener(this)
stateReady = true
maybeResolvePrepare()
}
}
override fun onPlayerError(error: PlaybackException) {
player.removeListener(this)
val cb = pendingPrepare ?: return
pendingPrepare = null
cb(error, VideoSize.UNKNOWN, 0L)
}
}
player.addListener(prepListener)
player.prepare()
}
/// Resolves the pending prepare future once *both* the ExoPlayer
/// has reached `STATE_READY` AND the compositor has landed the
/// first blitted frame on the Flutter output surface. The gallery
/// uses this future to swap from the thumbnail to the live
/// `Texture`; resolving early causes a black-flash on the hero
/// landing.
private fun maybeResolvePrepare() {
val cb = pendingPrepare ?: return
if (!stateReady || !firstFrameRendered) return
pendingPrepare = null
cb(null, player.videoSize, player.duration.coerceAtLeast(0L))
mainHandler.post(tickRunnable)
}
fun play() {
if (disposed) return
player.play()
}
fun pause() {
if (disposed) return
player.pause()
}
fun seekTo(positionMs: Long) {
if (disposed) return
player.seekTo(positionMs)
emitPositionAndBuffer(forcePosition = positionMs)
}
fun setLooping(loop: Boolean) {
if (disposed) return
player.repeatMode = if (loop) Player.REPEAT_MODE_ONE else Player.REPEAT_MODE_OFF
}
fun setVolume(volume: Float) {
if (disposed) return
player.volume = volume.coerceIn(0f, 1f)
}
fun setPlaybackSpeed(rate: Float) {
if (disposed) return
player.setPlaybackSpeed(rate.coerceIn(0.25f, 4f))
}
fun dispose() {
if (disposed) return
disposed = true
mainHandler.removeCallbacks(tickRunnable)
player.removeListener(listener)
player.release()
// Compositor disposal posts to its own thread; safe to call after
// player.release because the compositor stops drawing once the
// input SurfaceTexture stops receiving frames.
compositor.dispose()
outputSurface.release()
textureEntry.release()
onEvent = null
}
private fun emitPositionAndBuffer(forcePosition: Long? = null) {
if (disposed) return
val position = forcePosition ?: player.currentPosition.coerceAtLeast(0L)
val bufferedMs = player.bufferedPosition.coerceAtLeast(0L)
if (bufferedMs == lastReportedBufferedMs && forcePosition == null && !lastIsPlaying) {
return
}
lastReportedBufferedMs = bufferedMs
emit(
mapOf(
"event" to "stateChanged",
"positionMs" to position,
"buffered" to listOf(
mapOf(
"startMs" to 0L,
"endMs" to bufferedMs,
),
),
),
)
}
private fun emitStateChange(isPlaying: Boolean? = null, isBuffering: Boolean? = null) {
val payload = HashMap<String, Any?>()
payload["event"] = "stateChanged"
payload["positionMs"] = player.currentPosition.coerceAtLeast(0L)
if (isPlaying != null) payload["isPlaying"] = isPlaying
if (isBuffering != null) payload["isBuffering"] = isBuffering
emit(payload)
}
private fun emit(extras: Map<String, Any?>) {
val payload = HashMap<String, Any?>(extras.size + 1)
payload["handle"] = handle
payload.putAll(extras)
onEvent?.invoke(payload)
}
companion object {
private const val TAG = "UxVideoPlayer"
private const val POSITION_TICK_MS = 100L
}
}

View File

@@ -0,0 +1,207 @@
package io.swipelab.ux.video
import android.content.Context
import android.net.Uri
import android.os.Handler
import android.os.Looper
import androidx.media3.common.util.UnstableApi
import io.flutter.embedding.engine.plugins.FlutterPlugin
import io.flutter.plugin.common.EventChannel
import io.flutter.plugin.common.MethodCall
import io.flutter.plugin.common.MethodChannel
import io.flutter.view.TextureRegistry
import io.swipelab.ux.NativePlugin
/// `ux/video` + `ux/video/events` registrar. Routes channel calls to
/// per-handle [VideoPlayerInstance]s. Mirrors the shape of
/// [io.swipelab.ux.camera.CameraPlugin].
@UnstableApi
class VideoPlayerPlugin :
NativePlugin,
MethodChannel.MethodCallHandler,
EventChannel.StreamHandler {
private val main = Handler(Looper.getMainLooper())
private var context: Context? = null
private var textureRegistry: TextureRegistry? = null
private var methodChannel: MethodChannel? = null
private var eventChannel: EventChannel? = null
private var eventSink: EventChannel.EventSink? = null
private val instances = mutableMapOf<Int, VideoPlayerInstance>()
private var nextHandle = 1
override fun onAttachedToEngine(binding: FlutterPlugin.FlutterPluginBinding) {
context = binding.applicationContext
textureRegistry = binding.textureRegistry
val mc = MethodChannel(binding.binaryMessenger, "ux/video")
mc.setMethodCallHandler(this)
methodChannel = mc
val ec = EventChannel(binding.binaryMessenger, "ux/video/events")
ec.setStreamHandler(this)
eventChannel = ec
}
override fun onDetachedFromEngine(binding: FlutterPlugin.FlutterPluginBinding) {
instances.values.forEach { it.dispose() }
instances.clear()
methodChannel?.setMethodCallHandler(null)
methodChannel = null
eventChannel?.setStreamHandler(null)
eventChannel = null
eventSink = null
context = null
textureRegistry = null
}
// MARK: - EventChannel
override fun onListen(arguments: Any?, events: EventChannel.EventSink?) {
eventSink = events
}
override fun onCancel(arguments: Any?) {
eventSink = null
}
// MARK: - MethodChannel
override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) {
when (call.method) {
"create" -> handleCreate(call, result)
"initialize" -> handleInitialize(call, result)
"dispose" -> handleDispose(call, result)
"play" -> handlePlay(call, result)
"pause" -> handlePause(call, result)
"seekTo" -> handleSeekTo(call, result)
"setLooping" -> handleSetLooping(call, result)
"setVolume" -> handleSetVolume(call, result)
"setPlaybackSpeed" -> handleSetPlaybackSpeed(call, result)
else -> result.notImplemented()
}
}
private fun handleCreate(call: MethodCall, result: MethodChannel.Result) {
val args = call.arguments as? Map<*, *>
?: return result.error("bad_args", "create", null)
val uriRaw = args["uri"] as? String
?: return result.error("bad_args", "create", null)
val ctx = context ?: return result.error("no_context", "engine detached", null)
val registry = textureRegistry
?: return result.error("no_context", "no texture registry", null)
val textureEntry = registry.createSurfaceTexture()
val handle = nextHandle++
val instance = VideoPlayerInstance(handle, ctx, textureEntry)
instance.onEvent = { payload ->
main.post { eventSink?.success(payload) }
}
try {
instance.setSource(Uri.parse(uriRaw))
} catch (t: Throwable) {
instance.dispose()
return result.error("bad_args", t.localizedMessage, null)
}
instances[handle] = instance
result.success(
mapOf(
"handle" to handle,
"textureId" to instance.textureId,
),
)
}
private fun handleInitialize(call: MethodCall, result: MethodChannel.Result) {
withInstance(call, result) { instance ->
instance.prepare { error, size, durationMs ->
if (error != null) {
result.error(
"decode_failed",
error.message ?: "ExoPlayer prepare failed",
null,
)
return@prepare
}
result.success(
mapOf(
"size" to mapOf(
"width" to size.width.toDouble(),
"height" to size.height.toDouble(),
),
"durationMs" to durationMs,
// Android rotation is applied by ExoPlayer's MediaCodec
// configuration + the SurfaceTexture transform matrix our
// compositor's GLES blit already honours, so the Flutter
// `Texture` widget needs no additional rotation.
"rotationQuarterTurns" to 0,
),
)
}
}
}
private fun handleDispose(call: MethodCall, result: MethodChannel.Result) {
withInstance(call, result) { instance ->
instances.remove(instance.handle)
instance.dispose()
result.success(null)
}
}
private fun handlePlay(call: MethodCall, result: MethodChannel.Result) {
withInstance(call, result) { it.play(); result.success(null) }
}
private fun handlePause(call: MethodCall, result: MethodChannel.Result) {
withInstance(call, result) { it.pause(); result.success(null) }
}
private fun handleSeekTo(call: MethodCall, result: MethodChannel.Result) {
val args = call.arguments as? Map<*, *>
?: return result.error("bad_args", "seekTo", null)
val pos = (args["positionMs"] as? Number)?.toLong()
?: return result.error("bad_args", "seekTo positionMs", null)
withInstance(call, result) { it.seekTo(pos); result.success(null) }
}
private fun handleSetLooping(call: MethodCall, result: MethodChannel.Result) {
val args = call.arguments as? Map<*, *>
?: return result.error("bad_args", "setLooping", null)
val loop = args["loop"] as? Boolean ?: false
withInstance(call, result) { it.setLooping(loop); result.success(null) }
}
private fun handleSetVolume(call: MethodCall, result: MethodChannel.Result) {
val args = call.arguments as? Map<*, *>
?: return result.error("bad_args", "setVolume", null)
val volume = (args["volume"] as? Number)?.toFloat()
?: return result.error("bad_args", "setVolume volume", null)
withInstance(call, result) { it.setVolume(volume); result.success(null) }
}
private fun handleSetPlaybackSpeed(call: MethodCall, result: MethodChannel.Result) {
val args = call.arguments as? Map<*, *>
?: return result.error("bad_args", "setPlaybackSpeed", null)
val rate = (args["rate"] as? Number)?.toFloat()
?: return result.error("bad_args", "setPlaybackSpeed rate", null)
withInstance(call, result) { it.setPlaybackSpeed(rate); result.success(null) }
}
private inline fun withInstance(
call: MethodCall,
result: MethodChannel.Result,
body: (VideoPlayerInstance) -> Unit,
) {
val args = call.arguments as? Map<*, *>
?: return result.error("bad_args", "missing handle", null)
val handle = (args["handle"] as? Number)?.toInt()
?: return result.error("bad_args", "missing handle", null)
val instance = instances[handle]
?: return result.error("disposed", "no player for handle $handle", null)
body(instance)
}
}