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' implementation 'androidx.lifecycle:lifecycle-process:2.7.0'
// Pure-Kotlin/Java QR decoder. ~470 KB jar, no Play Services dep. // Pure-Kotlin/Java QR decoder. ~470 KB jar, no Play Services dep.
implementation 'com.google.zxing:core:3.5.3' 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.ActivityAware
import io.flutter.embedding.engine.plugins.activity.ActivityPluginBinding import io.flutter.embedding.engine.plugins.activity.ActivityPluginBinding
import io.swipelab.ux.camera.CameraPlugin 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 { class XPlugin : FlutterPlugin, ActivityAware {
private val plugins: List<NativePlugin> = listOf( private val plugins: List<NativePlugin> = listOf(
KeyboardPlugin(), KeyboardPlugin(),
@@ -14,6 +16,7 @@ class XPlugin : FlutterPlugin, ActivityAware {
ClipboardPlugin(), ClipboardPlugin(),
GalleryPlugin(), GalleryPlugin(),
CameraPlugin(), CameraPlugin(),
VideoPlayerPlugin(),
CrashPlugin(), CrashPlugin(),
UrlPlugin(), 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)
}
}

View File

@@ -0,0 +1,55 @@
import CoreVideo
import Foundation
#if canImport(UIKit)
import Flutter
#else
import FlutterMacOS
#endif
/// Single-slot latest-pixel-buffer sink that feeds a `FlutterTexture`.
/// Mirrors `PreviewSink` in `darwin/Camera/` receiver writes, the
/// engine reads via `copyPixelBuffer()`. We retain only the most
/// recent buffer; older ones get released the moment a new frame
/// arrives, bounding memory at one frame.
final class UxVideoPixelBufferSink: NSObject, FlutterTexture {
private weak var registry: FlutterTextureRegistry?
private var textureId: Int64 = -1
private let bufferQueue = DispatchQueue(
label: "ux.video.buffer",
qos: .userInitiated
)
private var latestPixelBuffer: CVPixelBuffer?
func register(with registry: FlutterTextureRegistry) -> Int64 {
self.registry = registry
textureId = registry.register(self)
return textureId
}
func unregister() {
registry?.unregisterTexture(textureId)
bufferQueue.sync { latestPixelBuffer = nil }
}
// MARK: - FlutterTexture
func copyPixelBuffer() -> Unmanaged<CVPixelBuffer>? {
var pb: CVPixelBuffer?
bufferQueue.sync {
pb = latestPixelBuffer
latestPixelBuffer = nil
}
if let pb = pb { return Unmanaged.passRetained(pb) }
return nil
}
/// Receives a new frame from the AVPlayerItemVideoOutput pump.
/// Cheap just swaps the pointer + notifies the registry.
func deliver(pixelBuffer: CVPixelBuffer) {
bufferQueue.sync { latestPixelBuffer = pixelBuffer }
if let registry = registry {
registry.textureFrameAvailable(textureId)
}
}
}

View File

@@ -0,0 +1,431 @@
import AVFoundation
import CoreMedia
import CoreVideo
import Foundation
import QuartzCore
#if canImport(UIKit)
import Flutter
import UIKit
#else
import FlutterMacOS
#endif
/// One per Dart-side `XVideoPlayerController`. Owns the `AVPlayer`,
/// an `AVPlayerItemVideoOutput` that pumps `CVPixelBuffer`s into the
/// Flutter texture, and a periodic timer that fires position +
/// buffered events back to Dart.
///
/// Mirrors the shape of `CameraInstance` main-thread methods,
/// per-handle event payloads tagged with `handle`.
final class UxVideoPlayerInstance: NSObject {
let handle: Int
let textureId: Int64
/// Pushed to Dart as `{event, handle, }` payloads. Set by the
/// plugin in `create`.
var onEvent: ((/* payload */ [String: Any?]) -> Void)?
private weak var textureRegistry: FlutterTextureRegistry?
private let sink: UxVideoPixelBufferSink
private let player = AVPlayer()
private var item: AVPlayerItem?
private var videoOutput: AVPlayerItemVideoOutput?
private var pumpTimer: Timer?
private var positionTimer: Timer?
private var prepareCompletion: ((Error?, CGSize, Int64, Int) -> Void)?
/// Quarter-turns of clockwise rotation the Flutter `Texture` needs
/// so the rendered frame reads upright. AVPlayerItemVideoOutput
/// delivers `CVPixelBuffer`s in the file's *natural* orientation
/// the file's `preferredTransform` rotation is NOT applied while
/// `item.presentationSize` already reflects the rotated dimensions.
/// Without surfacing this to Dart, the gallery wraps a
/// portrait-displayed-as-landscape buffer in a portrait
/// `AspectRatio` and stretches it. Computed from the first video
/// track's `preferredTransform` in [setSource]; reported back via
/// [prepare]'s completion alongside the presentation size.
private var rotationQuarterTurns: Int = 0
private var disposed = false
private var lastReportedIsPlaying = false
private var lastReportedIsBuffering = false
private var lastReportedSize: CGSize = .zero
private var lastReportedDurationMs: Int64 = -1
private var notifiedCompleted = false
private static let pumpHz: TimeInterval = 1.0 / 60.0
private static let positionHz: TimeInterval = 1.0 / 10.0
/// `kCVPixelFormatType_32BGRA` keeps the Flutter texture path
/// happy Flutter's iOS/macOS embedder expects BGRA8888.
private static let pixelBufferAttributes: [String: Any] = [
kCVPixelBufferPixelFormatTypeKey as String: kCVPixelFormatType_32BGRA,
kCVPixelBufferIOSurfacePropertiesKey as String: [:] as CFDictionary,
]
init(handle: Int, registry: FlutterTextureRegistry) {
self.handle = handle
self.textureRegistry = registry
self.sink = UxVideoPixelBufferSink()
self.textureId = sink.register(with: registry)
super.init()
}
// MARK: - Source / prepare
func setSource(url: URL) {
let asset = AVURLAsset(url: url)
rotationQuarterTurns = Self.rotationQuarterTurns(for: asset)
let newItem = AVPlayerItem(asset: asset)
let output = AVPlayerItemVideoOutput(
pixelBufferAttributes: Self.pixelBufferAttributes,
)
newItem.add(output)
item = newItem
videoOutput = output
player.replaceCurrentItem(with: newItem)
addObservers(item: newItem)
}
/// Maps the first video track's `preferredTransform` to quarter
/// turns of clockwise rotation. `atan2(b, a)` recovers the rotation
/// angle from the affine transform's rotation/scale components;
/// rounding to the nearest 90° gives a stable enum-like value
/// (`Int` mod 4) for the Dart-side `RotatedBox`.
private static func rotationQuarterTurns(for asset: AVAsset) -> Int {
guard let track = asset.tracks(withMediaType: .video).first else { return 0 }
let t = track.preferredTransform
let radians = atan2(t.b, t.a)
let degrees = radians * 180.0 / .pi
let normalized = (Int(degrees.rounded()) % 360 + 360) % 360
return (normalized / 90) % 4
}
func prepare(completion: @escaping (Error?, CGSize, Int64, Int) -> Void) {
guard let item = item else {
completion(
NSError(
domain: "ux.video",
code: -1,
userInfo: [NSLocalizedDescriptionKey: "no item"],
),
.zero,
0,
0,
)
return
}
prepareCompletion = completion
// If we already passed .readyToPlay, fire immediately.
if item.status == .readyToPlay {
let dims = item.presentationSize
let durMs = Self.toMs(item.duration)
prepareCompletion = nil
startPumping()
startPositionTimer()
completion(nil, dims, durMs, rotationQuarterTurns)
}
// Otherwise the KVO on `status` will resolve us when ready.
}
// MARK: - Controls
func play() {
guard !disposed else { return }
player.play()
}
func pause() {
guard !disposed else { return }
player.pause()
}
func seekTo(positionMs: Int64) {
guard !disposed else { return }
let time = CMTime(value: max(0, positionMs), timescale: 1000)
player.seek(
to: time,
toleranceBefore: .zero,
toleranceAfter: .zero,
)
emitPosition(forcePositionMs: positionMs)
}
func setLooping(_ loop: Bool) {
guard !disposed else { return }
loopingEnabled = loop
}
func setVolume(_ volume: Float) {
guard !disposed else { return }
player.volume = max(0, min(1, volume))
}
func setPlaybackSpeed(_ rate: Float) {
guard !disposed else { return }
let clamped = max(0.25, min(4.0, rate))
defaultRate = clamped
if player.rate != 0 {
player.rate = clamped
}
}
func dispose() {
guard !disposed else { return }
disposed = true
pumpTimer?.invalidate()
pumpTimer = nil
positionTimer?.invalidate()
positionTimer = nil
if let item = item {
removeObservers(item: item)
}
if let videoOutput = videoOutput, let item = item {
item.remove(videoOutput)
}
videoOutput = nil
item = nil
player.replaceCurrentItem(with: nil)
sink.unregister()
onEvent = nil
}
// MARK: - Observers / completion
private var loopingEnabled = false
private var defaultRate: Float = 1.0
private var statusContext = 0
private var bufferEmptyContext = 1
private var likelyToKeepUpContext = 2
private var rateContext = 3
private func addObservers(item: AVPlayerItem) {
item.addObserver(
self,
forKeyPath: "status",
options: [.new, .initial],
context: &statusContext,
)
item.addObserver(
self,
forKeyPath: "playbackBufferEmpty",
options: [.new],
context: &bufferEmptyContext,
)
item.addObserver(
self,
forKeyPath: "playbackLikelyToKeepUp",
options: [.new],
context: &likelyToKeepUpContext,
)
player.addObserver(
self,
forKeyPath: "rate",
options: [.new],
context: &rateContext,
)
NotificationCenter.default.addObserver(
self,
selector: #selector(playbackEnded(_:)),
name: .AVPlayerItemDidPlayToEndTime,
object: item,
)
}
private func removeObservers(item: AVPlayerItem) {
item.removeObserver(self, forKeyPath: "status", context: &statusContext)
item.removeObserver(self, forKeyPath: "playbackBufferEmpty", context: &bufferEmptyContext)
item.removeObserver(self, forKeyPath: "playbackLikelyToKeepUp", context: &likelyToKeepUpContext)
player.removeObserver(self, forKeyPath: "rate", context: &rateContext)
NotificationCenter.default.removeObserver(
self,
name: .AVPlayerItemDidPlayToEndTime,
object: item,
)
}
override func observeValue(
forKeyPath keyPath: String?,
of object: Any?,
change: [NSKeyValueChangeKey: Any]?,
context: UnsafeMutableRawPointer?,
) {
DispatchQueue.main.async { [weak self] in
guard let self = self, !self.disposed else { return }
self.handleObservation(keyPath: keyPath, context: context)
}
}
private func handleObservation(keyPath: String?, context: UnsafeMutableRawPointer?) {
if context == &statusContext, let item = item {
switch item.status {
case .readyToPlay:
if let completion = prepareCompletion {
prepareCompletion = nil
let dims = item.presentationSize
let durMs = Self.toMs(item.duration)
lastReportedSize = dims
lastReportedDurationMs = durMs
startPumping()
startPositionTimer()
emitSizeChanged(dims)
completion(nil, dims, durMs, rotationQuarterTurns)
}
case .failed:
if let completion = prepareCompletion {
prepareCompletion = nil
completion(
item.error
?? NSError(
domain: "ux.video",
code: -2,
userInfo: [NSLocalizedDescriptionKey: "AVPlayerItem failed"],
),
.zero,
0,
0,
)
} else if let error = item.error {
emit([
"event": "error",
"code": "playback_failed",
"description": error.localizedDescription,
])
}
default:
break
}
} else if context == &bufferEmptyContext, let item = item {
let buffering = item.isPlaybackBufferEmpty
if buffering != lastReportedIsBuffering {
lastReportedIsBuffering = buffering
emit([
"event": "stateChanged",
"isBuffering": buffering,
"positionMs": currentPositionMs(),
])
}
} else if context == &likelyToKeepUpContext, let item = item {
if item.isPlaybackLikelyToKeepUp && lastReportedIsBuffering {
lastReportedIsBuffering = false
emit([
"event": "stateChanged",
"isBuffering": false,
"positionMs": currentPositionMs(),
])
}
} else if context == &rateContext {
let playing = player.rate != 0
if playing != lastReportedIsPlaying {
lastReportedIsPlaying = playing
emit([
"event": "stateChanged",
"isPlaying": playing,
"positionMs": currentPositionMs(),
])
}
}
}
@objc private func playbackEnded(_ note: Notification) {
if loopingEnabled {
player.seek(to: .zero) { [weak self] _ in
self?.player.play()
}
return
}
if !notifiedCompleted {
notifiedCompleted = true
emit([
"event": "completed",
])
}
}
// MARK: - Pumps
private func startPumping() {
pumpTimer?.invalidate()
let timer = Timer(timeInterval: Self.pumpHz, repeats: true) { [weak self] _ in
self?.pumpFrame()
}
RunLoop.main.add(timer, forMode: .common)
pumpTimer = timer
}
private func startPositionTimer() {
positionTimer?.invalidate()
let timer = Timer(timeInterval: Self.positionHz, repeats: true) { [weak self] _ in
self?.emitPosition()
}
RunLoop.main.add(timer, forMode: .common)
positionTimer = timer
}
private func pumpFrame() {
guard let videoOutput = videoOutput else { return }
let host = videoOutput.itemTime(forHostTime: CACurrentMediaTime())
guard videoOutput.hasNewPixelBuffer(forItemTime: host) else { return }
guard let pixelBuffer = videoOutput.copyPixelBuffer(
forItemTime: host,
itemTimeForDisplay: nil,
) else { return }
sink.deliver(pixelBuffer: pixelBuffer)
}
private func emitPosition(forcePositionMs: Int64? = nil) {
let positionMs = forcePositionMs ?? currentPositionMs()
let buffered = bufferedRanges()
emit([
"event": "stateChanged",
"positionMs": positionMs,
"buffered": buffered,
])
}
private func bufferedRanges() -> [[String: Int64]] {
guard let item = item else { return [] }
return item.loadedTimeRanges.map { value -> [String: Int64] in
let r = value.timeRangeValue
let startMs = Int64(CMTimeGetSeconds(r.start) * 1000.0)
let endMs = Int64(CMTimeGetSeconds(r.start + r.duration) * 1000.0)
return [
"startMs": max(0, startMs),
"endMs": max(0, endMs),
]
}
}
private func currentPositionMs() -> Int64 {
let t = player.currentTime()
return Int64(max(0, CMTimeGetSeconds(t)) * 1000.0)
}
private func emitSizeChanged(_ size: CGSize) {
emit([
"event": "sizeChanged",
"size": [
"width": Double(size.width),
"height": Double(size.height),
] as [String: Any],
])
}
private func emit(_ extras: [String: Any?]) {
var payload = extras
payload["handle"] = handle
onEvent?(payload)
}
private static func toMs(_ t: CMTime) -> Int64 {
guard t.isValid, !t.isIndefinite else { return 0 }
let seconds = CMTimeGetSeconds(t)
guard seconds.isFinite, !seconds.isNaN else { return 0 }
return Int64(max(0, seconds) * 1000.0)
}
}

View File

@@ -0,0 +1,205 @@
import AVFoundation
import Foundation
#if canImport(UIKit)
import Flutter
#else
import FlutterMacOS
#endif
/// `ux/video` + `ux/video/events` registrar. Routes channel calls to
/// per-handle [UxVideoPlayerInstance]s. Mirrors `CameraPlugin`'s shape.
public class UxVideoPlayerPlugin: NSObject, NativePlugin, FlutterStreamHandler {
private weak var textureRegistry: FlutterTextureRegistry?
private var instances: [Int: UxVideoPlayerInstance] = [:]
private var nextHandle: Int = 1
private var eventSink: FlutterEventSink?
public func register(with registrar: FlutterPluginRegistrar) {
textureRegistry = registrar.uxTextures
let methods = FlutterMethodChannel(
name: "ux/video",
binaryMessenger: registrar.uxMessenger,
)
methods.setMethodCallHandler { [weak self] call, result in
self?.handle(call, result: result)
}
let events = FlutterEventChannel(
name: "ux/video/events",
binaryMessenger: registrar.uxMessenger,
)
events.setStreamHandler(self)
}
// MARK: - FlutterStreamHandler
public func onListen(
withArguments arguments: Any?,
eventSink events: @escaping FlutterEventSink,
) -> FlutterError? {
eventSink = events
return nil
}
public func onCancel(withArguments arguments: Any?) -> FlutterError? {
eventSink = nil
return nil
}
// MARK: - Dispatch
private func handle(_ call: FlutterMethodCall, result: @escaping FlutterResult) {
switch call.method {
case "create":
create(args: call.arguments, result: result)
case "initialize":
withInstance(call.arguments, result: result) { instance in
instance.prepare { error, size, durationMs, rotationQuarterTurns in
DispatchQueue.main.async {
if let error = error {
result(
FlutterError(
code: "decode_failed",
message: error.localizedDescription,
details: nil,
)
)
return
}
result(
[
"size": [
"width": Double(size.width),
"height": Double(size.height),
] as [String: Any],
"durationMs": durationMs,
"rotationQuarterTurns": rotationQuarterTurns,
] as [String: Any]
)
}
}
}
case "dispose":
withInstance(call.arguments, result: result) { instance in
self.instances.removeValue(forKey: instance.handle)
instance.dispose()
result(nil)
}
case "play":
withInstance(call.arguments, result: result) { instance in
instance.play()
result(nil)
}
case "pause":
withInstance(call.arguments, result: result) { instance in
instance.pause()
result(nil)
}
case "seekTo":
guard let args = call.arguments as? [String: Any],
let pos = (args["positionMs"] as? NSNumber)?.int64Value
else {
result(badArgs("seekTo"))
return
}
withInstance(args, result: result) { instance in
instance.seekTo(positionMs: pos)
result(nil)
}
case "setLooping":
guard let args = call.arguments as? [String: Any],
let loop = args["loop"] as? Bool
else {
result(badArgs("setLooping"))
return
}
withInstance(args, result: result) { instance in
instance.setLooping(loop)
result(nil)
}
case "setVolume":
guard let args = call.arguments as? [String: Any],
let volume = (args["volume"] as? NSNumber)?.floatValue
else {
result(badArgs("setVolume"))
return
}
withInstance(args, result: result) { instance in
instance.setVolume(volume)
result(nil)
}
case "setPlaybackSpeed":
guard let args = call.arguments as? [String: Any],
let rate = (args["rate"] as? NSNumber)?.floatValue
else {
result(badArgs("setPlaybackSpeed"))
return
}
withInstance(args, result: result) { instance in
instance.setPlaybackSpeed(rate)
result(nil)
}
default:
result(FlutterMethodNotImplemented)
}
}
private func create(args: Any?, result: @escaping FlutterResult) {
guard let dict = args as? [String: Any],
let uri = dict["uri"] as? String,
let registry = textureRegistry
else {
result(badArgs("create"))
return
}
guard let url = URL(string: uri) else {
result(badArgs("create uri"))
return
}
let handle = nextHandle
nextHandle += 1
let instance = UxVideoPlayerInstance(handle: handle, registry: registry)
instance.onEvent = { [weak self] payload in
DispatchQueue.main.async {
self?.eventSink?(payload)
}
}
instance.setSource(url: url)
instances[handle] = instance
result(
[
"handle": handle,
"textureId": instance.textureId,
] as [String: Any]
)
}
private func withInstance(
_ args: Any?,
result: @escaping FlutterResult,
body: (UxVideoPlayerInstance) -> Void,
) {
guard let dict = args as? [String: Any],
let handle = (dict["handle"] as? NSNumber)?.intValue
else {
result(badArgs("missing handle"))
return
}
guard let instance = instances[handle] else {
result(
FlutterError(
code: "disposed",
message: "no player for handle \(handle)",
details: nil,
)
)
return
}
body(instance)
}
private func badArgs(_ method: String) -> FlutterError {
FlutterError(code: "bad_args", message: method, details: nil)
}
}

View File

@@ -0,0 +1,55 @@
import CoreVideo
import Foundation
#if canImport(UIKit)
import Flutter
#else
import FlutterMacOS
#endif
/// Single-slot latest-pixel-buffer sink that feeds a `FlutterTexture`.
/// Mirrors `PreviewSink` in `darwin/Camera/` receiver writes, the
/// engine reads via `copyPixelBuffer()`. We retain only the most
/// recent buffer; older ones get released the moment a new frame
/// arrives, bounding memory at one frame.
final class UxVideoPixelBufferSink: NSObject, FlutterTexture {
private weak var registry: FlutterTextureRegistry?
private var textureId: Int64 = -1
private let bufferQueue = DispatchQueue(
label: "ux.video.buffer",
qos: .userInitiated
)
private var latestPixelBuffer: CVPixelBuffer?
func register(with registry: FlutterTextureRegistry) -> Int64 {
self.registry = registry
textureId = registry.register(self)
return textureId
}
func unregister() {
registry?.unregisterTexture(textureId)
bufferQueue.sync { latestPixelBuffer = nil }
}
// MARK: - FlutterTexture
func copyPixelBuffer() -> Unmanaged<CVPixelBuffer>? {
var pb: CVPixelBuffer?
bufferQueue.sync {
pb = latestPixelBuffer
latestPixelBuffer = nil
}
if let pb = pb { return Unmanaged.passRetained(pb) }
return nil
}
/// Receives a new frame from the AVPlayerItemVideoOutput pump.
/// Cheap just swaps the pointer + notifies the registry.
func deliver(pixelBuffer: CVPixelBuffer) {
bufferQueue.sync { latestPixelBuffer = pixelBuffer }
if let registry = registry {
registry.textureFrameAvailable(textureId)
}
}
}

View File

@@ -0,0 +1,431 @@
import AVFoundation
import CoreMedia
import CoreVideo
import Foundation
import QuartzCore
#if canImport(UIKit)
import Flutter
import UIKit
#else
import FlutterMacOS
#endif
/// One per Dart-side `XVideoPlayerController`. Owns the `AVPlayer`,
/// an `AVPlayerItemVideoOutput` that pumps `CVPixelBuffer`s into the
/// Flutter texture, and a periodic timer that fires position +
/// buffered events back to Dart.
///
/// Mirrors the shape of `CameraInstance` main-thread methods,
/// per-handle event payloads tagged with `handle`.
final class UxVideoPlayerInstance: NSObject {
let handle: Int
let textureId: Int64
/// Pushed to Dart as `{event, handle, }` payloads. Set by the
/// plugin in `create`.
var onEvent: ((/* payload */ [String: Any?]) -> Void)?
private weak var textureRegistry: FlutterTextureRegistry?
private let sink: UxVideoPixelBufferSink
private let player = AVPlayer()
private var item: AVPlayerItem?
private var videoOutput: AVPlayerItemVideoOutput?
private var pumpTimer: Timer?
private var positionTimer: Timer?
private var prepareCompletion: ((Error?, CGSize, Int64, Int) -> Void)?
/// Quarter-turns of clockwise rotation the Flutter `Texture` needs
/// so the rendered frame reads upright. AVPlayerItemVideoOutput
/// delivers `CVPixelBuffer`s in the file's *natural* orientation
/// the file's `preferredTransform` rotation is NOT applied while
/// `item.presentationSize` already reflects the rotated dimensions.
/// Without surfacing this to Dart, the gallery wraps a
/// portrait-displayed-as-landscape buffer in a portrait
/// `AspectRatio` and stretches it. Computed from the first video
/// track's `preferredTransform` in [setSource]; reported back via
/// [prepare]'s completion alongside the presentation size.
private var rotationQuarterTurns: Int = 0
private var disposed = false
private var lastReportedIsPlaying = false
private var lastReportedIsBuffering = false
private var lastReportedSize: CGSize = .zero
private var lastReportedDurationMs: Int64 = -1
private var notifiedCompleted = false
private static let pumpHz: TimeInterval = 1.0 / 60.0
private static let positionHz: TimeInterval = 1.0 / 10.0
/// `kCVPixelFormatType_32BGRA` keeps the Flutter texture path
/// happy Flutter's iOS/macOS embedder expects BGRA8888.
private static let pixelBufferAttributes: [String: Any] = [
kCVPixelBufferPixelFormatTypeKey as String: kCVPixelFormatType_32BGRA,
kCVPixelBufferIOSurfacePropertiesKey as String: [:] as CFDictionary,
]
init(handle: Int, registry: FlutterTextureRegistry) {
self.handle = handle
self.textureRegistry = registry
self.sink = UxVideoPixelBufferSink()
self.textureId = sink.register(with: registry)
super.init()
}
// MARK: - Source / prepare
func setSource(url: URL) {
let asset = AVURLAsset(url: url)
rotationQuarterTurns = Self.rotationQuarterTurns(for: asset)
let newItem = AVPlayerItem(asset: asset)
let output = AVPlayerItemVideoOutput(
pixelBufferAttributes: Self.pixelBufferAttributes,
)
newItem.add(output)
item = newItem
videoOutput = output
player.replaceCurrentItem(with: newItem)
addObservers(item: newItem)
}
/// Maps the first video track's `preferredTransform` to quarter
/// turns of clockwise rotation. `atan2(b, a)` recovers the rotation
/// angle from the affine transform's rotation/scale components;
/// rounding to the nearest 90° gives a stable enum-like value
/// (`Int` mod 4) for the Dart-side `RotatedBox`.
private static func rotationQuarterTurns(for asset: AVAsset) -> Int {
guard let track = asset.tracks(withMediaType: .video).first else { return 0 }
let t = track.preferredTransform
let radians = atan2(t.b, t.a)
let degrees = radians * 180.0 / .pi
let normalized = (Int(degrees.rounded()) % 360 + 360) % 360
return (normalized / 90) % 4
}
func prepare(completion: @escaping (Error?, CGSize, Int64, Int) -> Void) {
guard let item = item else {
completion(
NSError(
domain: "ux.video",
code: -1,
userInfo: [NSLocalizedDescriptionKey: "no item"],
),
.zero,
0,
0,
)
return
}
prepareCompletion = completion
// If we already passed .readyToPlay, fire immediately.
if item.status == .readyToPlay {
let dims = item.presentationSize
let durMs = Self.toMs(item.duration)
prepareCompletion = nil
startPumping()
startPositionTimer()
completion(nil, dims, durMs, rotationQuarterTurns)
}
// Otherwise the KVO on `status` will resolve us when ready.
}
// MARK: - Controls
func play() {
guard !disposed else { return }
player.play()
}
func pause() {
guard !disposed else { return }
player.pause()
}
func seekTo(positionMs: Int64) {
guard !disposed else { return }
let time = CMTime(value: max(0, positionMs), timescale: 1000)
player.seek(
to: time,
toleranceBefore: .zero,
toleranceAfter: .zero,
)
emitPosition(forcePositionMs: positionMs)
}
func setLooping(_ loop: Bool) {
guard !disposed else { return }
loopingEnabled = loop
}
func setVolume(_ volume: Float) {
guard !disposed else { return }
player.volume = max(0, min(1, volume))
}
func setPlaybackSpeed(_ rate: Float) {
guard !disposed else { return }
let clamped = max(0.25, min(4.0, rate))
defaultRate = clamped
if player.rate != 0 {
player.rate = clamped
}
}
func dispose() {
guard !disposed else { return }
disposed = true
pumpTimer?.invalidate()
pumpTimer = nil
positionTimer?.invalidate()
positionTimer = nil
if let item = item {
removeObservers(item: item)
}
if let videoOutput = videoOutput, let item = item {
item.remove(videoOutput)
}
videoOutput = nil
item = nil
player.replaceCurrentItem(with: nil)
sink.unregister()
onEvent = nil
}
// MARK: - Observers / completion
private var loopingEnabled = false
private var defaultRate: Float = 1.0
private var statusContext = 0
private var bufferEmptyContext = 1
private var likelyToKeepUpContext = 2
private var rateContext = 3
private func addObservers(item: AVPlayerItem) {
item.addObserver(
self,
forKeyPath: "status",
options: [.new, .initial],
context: &statusContext,
)
item.addObserver(
self,
forKeyPath: "playbackBufferEmpty",
options: [.new],
context: &bufferEmptyContext,
)
item.addObserver(
self,
forKeyPath: "playbackLikelyToKeepUp",
options: [.new],
context: &likelyToKeepUpContext,
)
player.addObserver(
self,
forKeyPath: "rate",
options: [.new],
context: &rateContext,
)
NotificationCenter.default.addObserver(
self,
selector: #selector(playbackEnded(_:)),
name: .AVPlayerItemDidPlayToEndTime,
object: item,
)
}
private func removeObservers(item: AVPlayerItem) {
item.removeObserver(self, forKeyPath: "status", context: &statusContext)
item.removeObserver(self, forKeyPath: "playbackBufferEmpty", context: &bufferEmptyContext)
item.removeObserver(self, forKeyPath: "playbackLikelyToKeepUp", context: &likelyToKeepUpContext)
player.removeObserver(self, forKeyPath: "rate", context: &rateContext)
NotificationCenter.default.removeObserver(
self,
name: .AVPlayerItemDidPlayToEndTime,
object: item,
)
}
override func observeValue(
forKeyPath keyPath: String?,
of object: Any?,
change: [NSKeyValueChangeKey: Any]?,
context: UnsafeMutableRawPointer?,
) {
DispatchQueue.main.async { [weak self] in
guard let self = self, !self.disposed else { return }
self.handleObservation(keyPath: keyPath, context: context)
}
}
private func handleObservation(keyPath: String?, context: UnsafeMutableRawPointer?) {
if context == &statusContext, let item = item {
switch item.status {
case .readyToPlay:
if let completion = prepareCompletion {
prepareCompletion = nil
let dims = item.presentationSize
let durMs = Self.toMs(item.duration)
lastReportedSize = dims
lastReportedDurationMs = durMs
startPumping()
startPositionTimer()
emitSizeChanged(dims)
completion(nil, dims, durMs, rotationQuarterTurns)
}
case .failed:
if let completion = prepareCompletion {
prepareCompletion = nil
completion(
item.error
?? NSError(
domain: "ux.video",
code: -2,
userInfo: [NSLocalizedDescriptionKey: "AVPlayerItem failed"],
),
.zero,
0,
0,
)
} else if let error = item.error {
emit([
"event": "error",
"code": "playback_failed",
"description": error.localizedDescription,
])
}
default:
break
}
} else if context == &bufferEmptyContext, let item = item {
let buffering = item.isPlaybackBufferEmpty
if buffering != lastReportedIsBuffering {
lastReportedIsBuffering = buffering
emit([
"event": "stateChanged",
"isBuffering": buffering,
"positionMs": currentPositionMs(),
])
}
} else if context == &likelyToKeepUpContext, let item = item {
if item.isPlaybackLikelyToKeepUp && lastReportedIsBuffering {
lastReportedIsBuffering = false
emit([
"event": "stateChanged",
"isBuffering": false,
"positionMs": currentPositionMs(),
])
}
} else if context == &rateContext {
let playing = player.rate != 0
if playing != lastReportedIsPlaying {
lastReportedIsPlaying = playing
emit([
"event": "stateChanged",
"isPlaying": playing,
"positionMs": currentPositionMs(),
])
}
}
}
@objc private func playbackEnded(_ note: Notification) {
if loopingEnabled {
player.seek(to: .zero) { [weak self] _ in
self?.player.play()
}
return
}
if !notifiedCompleted {
notifiedCompleted = true
emit([
"event": "completed",
])
}
}
// MARK: - Pumps
private func startPumping() {
pumpTimer?.invalidate()
let timer = Timer(timeInterval: Self.pumpHz, repeats: true) { [weak self] _ in
self?.pumpFrame()
}
RunLoop.main.add(timer, forMode: .common)
pumpTimer = timer
}
private func startPositionTimer() {
positionTimer?.invalidate()
let timer = Timer(timeInterval: Self.positionHz, repeats: true) { [weak self] _ in
self?.emitPosition()
}
RunLoop.main.add(timer, forMode: .common)
positionTimer = timer
}
private func pumpFrame() {
guard let videoOutput = videoOutput else { return }
let host = videoOutput.itemTime(forHostTime: CACurrentMediaTime())
guard videoOutput.hasNewPixelBuffer(forItemTime: host) else { return }
guard let pixelBuffer = videoOutput.copyPixelBuffer(
forItemTime: host,
itemTimeForDisplay: nil,
) else { return }
sink.deliver(pixelBuffer: pixelBuffer)
}
private func emitPosition(forcePositionMs: Int64? = nil) {
let positionMs = forcePositionMs ?? currentPositionMs()
let buffered = bufferedRanges()
emit([
"event": "stateChanged",
"positionMs": positionMs,
"buffered": buffered,
])
}
private func bufferedRanges() -> [[String: Int64]] {
guard let item = item else { return [] }
return item.loadedTimeRanges.map { value -> [String: Int64] in
let r = value.timeRangeValue
let startMs = Int64(CMTimeGetSeconds(r.start) * 1000.0)
let endMs = Int64(CMTimeGetSeconds(r.start + r.duration) * 1000.0)
return [
"startMs": max(0, startMs),
"endMs": max(0, endMs),
]
}
}
private func currentPositionMs() -> Int64 {
let t = player.currentTime()
return Int64(max(0, CMTimeGetSeconds(t)) * 1000.0)
}
private func emitSizeChanged(_ size: CGSize) {
emit([
"event": "sizeChanged",
"size": [
"width": Double(size.width),
"height": Double(size.height),
] as [String: Any],
])
}
private func emit(_ extras: [String: Any?]) {
var payload = extras
payload["handle"] = handle
onEvent?(payload)
}
private static func toMs(_ t: CMTime) -> Int64 {
guard t.isValid, !t.isIndefinite else { return 0 }
let seconds = CMTimeGetSeconds(t)
guard seconds.isFinite, !seconds.isNaN else { return 0 }
return Int64(max(0, seconds) * 1000.0)
}
}

View File

@@ -0,0 +1,205 @@
import AVFoundation
import Foundation
#if canImport(UIKit)
import Flutter
#else
import FlutterMacOS
#endif
/// `ux/video` + `ux/video/events` registrar. Routes channel calls to
/// per-handle [UxVideoPlayerInstance]s. Mirrors `CameraPlugin`'s shape.
public class UxVideoPlayerPlugin: NSObject, NativePlugin, FlutterStreamHandler {
private weak var textureRegistry: FlutterTextureRegistry?
private var instances: [Int: UxVideoPlayerInstance] = [:]
private var nextHandle: Int = 1
private var eventSink: FlutterEventSink?
public func register(with registrar: FlutterPluginRegistrar) {
textureRegistry = registrar.uxTextures
let methods = FlutterMethodChannel(
name: "ux/video",
binaryMessenger: registrar.uxMessenger,
)
methods.setMethodCallHandler { [weak self] call, result in
self?.handle(call, result: result)
}
let events = FlutterEventChannel(
name: "ux/video/events",
binaryMessenger: registrar.uxMessenger,
)
events.setStreamHandler(self)
}
// MARK: - FlutterStreamHandler
public func onListen(
withArguments arguments: Any?,
eventSink events: @escaping FlutterEventSink,
) -> FlutterError? {
eventSink = events
return nil
}
public func onCancel(withArguments arguments: Any?) -> FlutterError? {
eventSink = nil
return nil
}
// MARK: - Dispatch
private func handle(_ call: FlutterMethodCall, result: @escaping FlutterResult) {
switch call.method {
case "create":
create(args: call.arguments, result: result)
case "initialize":
withInstance(call.arguments, result: result) { instance in
instance.prepare { error, size, durationMs, rotationQuarterTurns in
DispatchQueue.main.async {
if let error = error {
result(
FlutterError(
code: "decode_failed",
message: error.localizedDescription,
details: nil,
)
)
return
}
result(
[
"size": [
"width": Double(size.width),
"height": Double(size.height),
] as [String: Any],
"durationMs": durationMs,
"rotationQuarterTurns": rotationQuarterTurns,
] as [String: Any]
)
}
}
}
case "dispose":
withInstance(call.arguments, result: result) { instance in
self.instances.removeValue(forKey: instance.handle)
instance.dispose()
result(nil)
}
case "play":
withInstance(call.arguments, result: result) { instance in
instance.play()
result(nil)
}
case "pause":
withInstance(call.arguments, result: result) { instance in
instance.pause()
result(nil)
}
case "seekTo":
guard let args = call.arguments as? [String: Any],
let pos = (args["positionMs"] as? NSNumber)?.int64Value
else {
result(badArgs("seekTo"))
return
}
withInstance(args, result: result) { instance in
instance.seekTo(positionMs: pos)
result(nil)
}
case "setLooping":
guard let args = call.arguments as? [String: Any],
let loop = args["loop"] as? Bool
else {
result(badArgs("setLooping"))
return
}
withInstance(args, result: result) { instance in
instance.setLooping(loop)
result(nil)
}
case "setVolume":
guard let args = call.arguments as? [String: Any],
let volume = (args["volume"] as? NSNumber)?.floatValue
else {
result(badArgs("setVolume"))
return
}
withInstance(args, result: result) { instance in
instance.setVolume(volume)
result(nil)
}
case "setPlaybackSpeed":
guard let args = call.arguments as? [String: Any],
let rate = (args["rate"] as? NSNumber)?.floatValue
else {
result(badArgs("setPlaybackSpeed"))
return
}
withInstance(args, result: result) { instance in
instance.setPlaybackSpeed(rate)
result(nil)
}
default:
result(FlutterMethodNotImplemented)
}
}
private func create(args: Any?, result: @escaping FlutterResult) {
guard let dict = args as? [String: Any],
let uri = dict["uri"] as? String,
let registry = textureRegistry
else {
result(badArgs("create"))
return
}
guard let url = URL(string: uri) else {
result(badArgs("create uri"))
return
}
let handle = nextHandle
nextHandle += 1
let instance = UxVideoPlayerInstance(handle: handle, registry: registry)
instance.onEvent = { [weak self] payload in
DispatchQueue.main.async {
self?.eventSink?(payload)
}
}
instance.setSource(url: url)
instances[handle] = instance
result(
[
"handle": handle,
"textureId": instance.textureId,
] as [String: Any]
)
}
private func withInstance(
_ args: Any?,
result: @escaping FlutterResult,
body: (UxVideoPlayerInstance) -> Void,
) {
guard let dict = args as? [String: Any],
let handle = (dict["handle"] as? NSNumber)?.intValue
else {
result(badArgs("missing handle"))
return
}
guard let instance = instances[handle] else {
result(
FlutterError(
code: "disposed",
message: "no player for handle \(handle)",
details: nil,
)
)
return
}
body(instance)
}
private func badArgs(_ method: String) -> FlutterError {
FlutterError(code: "bad_args", message: method, details: nil)
}
}

View File

@@ -14,6 +14,7 @@ public class XPlugin: NSObject, FlutterPlugin {
GalleryPlugin(), GalleryPlugin(),
CrashPlugin(), CrashPlugin(),
CameraPlugin(), CameraPlugin(),
UxVideoPlayerPlugin(),
UrlPlugin(), UrlPlugin(),
] ]
for plugin in plugins { for plugin in plugins {

View File

@@ -16,17 +16,22 @@ Pod::Spec.new do |s|
# binary — the mirror would be stale. The build script phase # binary — the mirror would be stale. The build script phase
# below re-runs the mirror before each compile to keep contents # below re-runs the mirror before each compile to keep contents
# fresh. # fresh.
s.prepare_command = 'rm -rf Classes/Camera-shared && cp -R ../darwin/Camera Classes/Camera-shared' s.prepare_command = <<-CMD
rm -rf Classes/Camera-shared && cp -R ../darwin/Camera Classes/Camera-shared
rm -rf Classes/Video-shared && cp -R ../darwin/Video Classes/Video-shared
CMD
s.script_phases = [{ s.script_phases = [{
:name => 'Mirror darwin/Camera', :name => 'Mirror darwin/{Camera,Video}',
:execution_position => :before_compile, :execution_position => :before_compile,
:script => <<-CMD :script => <<-CMD
set -e set -e
SRC="${PODS_TARGET_SRCROOT}/../darwin/Camera" for MOD in Camera Video; do
DEST="${PODS_TARGET_SRCROOT}/Classes/Camera-shared" SRC="${PODS_TARGET_SRCROOT}/../darwin/${MOD}"
[ -d "$SRC" ] || exit 0 DEST="${PODS_TARGET_SRCROOT}/Classes/${MOD}-shared"
mkdir -p "$DEST" [ -d "$SRC" ] || continue
rsync -a --delete "$SRC/" "$DEST/" mkdir -p "$DEST"
rsync -a --delete "$SRC/" "$DEST/"
done
CMD CMD
}] }]
s.source_files = 'Classes/**/*.{swift,m}' s.source_files = 'Classes/**/*.{swift,m}'

View File

@@ -0,0 +1,155 @@
import 'dart:async';
import 'dart:ui' show Size;
import 'package:ux/src/video/x_video_player.dart' show XDurationRange, XVideoPlayerException;
import 'package:ux/src/video/x_video_player_backend.dart';
/// In-memory backend for [XVideoPlayerController] tests. Swap in via
/// `XVideoPlayerBackend.instance = FakeXVideoPlayerBackend()` before
/// any UI mounts; restore with
/// `XVideoPlayerBackend.instance = MethodChannelXVideoPlayerBackend()`
/// in `tearDown`.
class FakeXVideoPlayerBackend implements XVideoPlayerBackend {
FakeXVideoPlayerBackend({
this.size = const Size(720, 1280),
this.duration = const Duration(seconds: 10),
this.rotationQuarterTurns = 0,
this.textureIdSeed = 100,
});
// ---- captured calls ---------------------------------------------
final List<String> createCalls = [];
final List<int> initializeCalls = [];
final List<int> disposeCalls = [];
final List<int> playCalls = [];
final List<int> pauseCalls = [];
final List<({int handle, Duration position})> seekToCalls = [];
final List<({int handle, bool loop})> setLoopingCalls = [];
final List<({int handle, double volume})> setVolumeCalls = [];
final List<({int handle, double rate})> setPlaybackSpeedCalls = [];
// ---- configurable returns ---------------------------------------
Size size;
Duration duration;
int rotationQuarterTurns;
int textureIdSeed;
/// If non-null, [create] throws this exception. Drives error paths.
XVideoPlayerException? createError;
XVideoPlayerException? initializeError;
// ---- internal ---------------------------------------------------
int _nextHandle = 1;
final Map<int, StreamController<XVideoPlayerEvent>> _events = {};
StreamController<XVideoPlayerEvent> _controllerFor(int handle) {
return _events.putIfAbsent(
handle,
() => StreamController<XVideoPlayerEvent>.broadcast(),
);
}
// ---- XVideoPlayerBackend ----------------------------------------
@override
Future<XVideoPlayerCreateResult> create({required String uri}) async {
createCalls.add(uri);
if (createError != null) throw createError!;
final handle = _nextHandle++;
_controllerFor(handle); // pre-warm
return XVideoPlayerCreateResult(
handle: handle,
textureId: textureIdSeed + handle,
);
}
@override
Future<XVideoPlayerMetadata> initialize(int handle) async {
initializeCalls.add(handle);
if (initializeError != null) throw initializeError!;
return XVideoPlayerMetadata(
size: size,
duration: duration,
rotationQuarterTurns: rotationQuarterTurns,
);
}
@override
Future<void> disposeInstance(int handle) async {
disposeCalls.add(handle);
final controller = _events.remove(handle);
await controller?.close();
}
@override
Future<void> play(int handle) async {
playCalls.add(handle);
emitState(handle, isPlaying: true);
}
@override
Future<void> pause(int handle) async {
pauseCalls.add(handle);
emitState(handle, isPlaying: false);
}
@override
Future<void> seekTo(int handle, Duration position) async {
seekToCalls.add((handle: handle, position: position));
emitState(handle, position: position);
}
@override
Future<void> setLooping(int handle, bool loop) async {
setLoopingCalls.add((handle: handle, loop: loop));
}
@override
Future<void> setVolume(int handle, double volume) async {
setVolumeCalls.add((handle: handle, volume: volume));
}
@override
Future<void> setPlaybackSpeed(int handle, double rate) async {
setPlaybackSpeedCalls.add((handle: handle, rate: rate));
}
@override
Stream<XVideoPlayerEvent> events(int handle) =>
_controllerFor(handle).stream;
// ---- test helpers ------------------------------------------------
void emitState(
int handle, {
bool? isPlaying,
bool? isBuffering,
Duration? position,
List<XDurationRange>? buffered,
}) {
_controllerFor(handle).add(
XVideoPlayerStateChanged(
handle,
isPlaying: isPlaying,
isBuffering: isBuffering,
position: position,
buffered: buffered,
),
);
}
void emitSizeChanged(int handle, Size size) {
_controllerFor(handle).add(XVideoPlayerSizeChanged(handle, size));
}
void emitCompleted(int handle) {
_controllerFor(handle).add(XVideoPlayerCompleted(handle));
}
void emitError(int handle, String code, [String? description]) {
_controllerFor(handle).add(XVideoPlayerError(handle, code, description));
}
}

View File

@@ -0,0 +1,325 @@
import 'dart:async';
import 'dart:io' as io;
import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart' show PlatformException;
import 'package:flutter/widgets.dart';
import 'x_video_player_backend.dart';
import 'x_video_player_view.dart' show XVideoPlayerView;
/// Throws when a [XVideoPlayerController] call fails on the platform
/// side. Maps from native `PlatformException` codes.
class XVideoPlayerException implements Exception {
const XVideoPlayerException(this.code, [this.description]);
final String code;
final String? description;
@override
String toString() =>
'XVideoPlayerException($code${description == null ? '' : ': $description'})';
}
/// One contiguous range of buffered playback time. Matches the shape
/// `package:video_player` exposes today so call sites that read it
/// (gallery scrub bar) port mechanically.
class XDurationRange {
const XDurationRange(this.start, this.end);
final Duration start;
final Duration end;
}
/// Immutable snapshot of an [XVideoPlayerController]'s state.
class XVideoPlayerValue {
const XVideoPlayerValue({
this.isInitialized = false,
this.isPlaying = false,
this.isBuffering = false,
this.isLooping = false,
this.rotationQuarterTurns = 0,
this.position = Duration.zero,
this.duration = Duration.zero,
this.size = Size.zero,
this.volume = 1.0,
this.playbackSpeed = 1.0,
this.buffered = const [],
this.errorDescription,
});
static const uninitialized = XVideoPlayerValue();
final bool isInitialized;
final bool isPlaying;
final bool isBuffering;
final bool isLooping;
/// Quarter-turns of clockwise rotation [XVideoPlayerView] applies
/// to the underlying `Texture`. Populated from the native side at
/// initialize-time — Android always 0 (codec + GLES blit handle
/// rotation natively), Apple non-zero when the asset's
/// `preferredTransform` rotates the natural pixel buffer.
final int rotationQuarterTurns;
/// Last known playhead position. Reported by the native side on a
/// fixed cadence (~10 Hz) — read this through a
/// [ValueListenableBuilder] / `addListener` for live scrubbing.
final Duration position;
/// Total duration of the media. `Duration.zero` until [isInitialized].
final Duration duration;
/// Natural pixel dimensions of the video frame. `Size.zero` until
/// the native side reports metadata.
final Size size;
final double volume;
final double playbackSpeed;
/// One range per contiguous buffered span. Empty before init; on
/// local-file playback this is typically a single `[0..duration]`
/// range that appears once the first frame is decoded.
final List<XDurationRange> buffered;
final String? errorDescription;
bool get hasError => errorDescription != null;
/// Aspect ratio with a graceful fall-through to 1:1 before the first
/// frame is decoded. Compose / gallery wrap the player in
/// `AspectRatio` and would otherwise divide by zero.
double get aspectRatio {
if (size.width <= 0 || size.height <= 0) return 1.0;
return size.width / size.height;
}
XVideoPlayerValue copyWith({
bool? isInitialized,
bool? isPlaying,
bool? isBuffering,
bool? isLooping,
int? rotationQuarterTurns,
Duration? position,
Duration? duration,
Size? size,
double? volume,
double? playbackSpeed,
List<XDurationRange>? buffered,
Object? errorDescription = _unset,
}) =>
XVideoPlayerValue(
isInitialized: isInitialized ?? this.isInitialized,
isPlaying: isPlaying ?? this.isPlaying,
isBuffering: isBuffering ?? this.isBuffering,
isLooping: isLooping ?? this.isLooping,
rotationQuarterTurns:
rotationQuarterTurns ?? this.rotationQuarterTurns,
position: position ?? this.position,
duration: duration ?? this.duration,
size: size ?? this.size,
volume: volume ?? this.volume,
playbackSpeed: playbackSpeed ?? this.playbackSpeed,
buffered: buffered ?? this.buffered,
errorDescription: identical(errorDescription, _unset)
? this.errorDescription
: errorDescription as String?,
);
static const _unset = Object();
}
/// Owns one native video-player session. The surface mirrors the
/// subset of `package:video_player`'s `VideoPlayerController` the app
/// currently uses — only the `.file(...)` constructor, the standard
/// playback controls, and the `value` getters compose / gallery /
/// outgoing-media read.
///
/// Lifecycle: construct → [initialize] → use → [dispose]. After
/// [dispose] every other method throws [XVideoPlayerException("disposed")].
class XVideoPlayerController extends ChangeNotifier
implements ValueListenable<XVideoPlayerValue> {
XVideoPlayerController.file(io.File file) : _file = file;
final io.File _file;
XVideoPlayerValue _value = XVideoPlayerValue.uninitialized;
@override
XVideoPlayerValue get value => _value;
set _setValue(XVideoPlayerValue next) {
if (identical(_value, next)) return;
_value = next;
notifyListeners();
}
int? _handle;
int? _textureId;
StreamSubscription<XVideoPlayerEvent>? _eventsSub;
bool _disposed = false;
Completer<void>? _initCompleter;
/// Native handle once [initialize] has resolved. Null otherwise.
int? get handle => _handle;
/// Texture id on Apple platforms. On Android playback uses a platform
/// view (no texture id), and this stays null.
int? get textureId => _textureId;
/// Configure the native session and load the file. Resolves once the
/// codec is ready and `value.size` / `value.duration` are populated.
/// Throws if the file is missing, unreadable, or unsupported.
Future<void> initialize() {
_throwIfDisposed('initialize');
final existing = _initCompleter;
if (existing != null) return existing.future;
final completer = Completer<void>();
_initCompleter = completer;
_initInternal().then(
(_) => completer.complete(),
onError: completer.completeError,
);
return completer.future;
}
Future<void> _initInternal() async {
try {
final result = await XVideoPlayerBackend.instance.create(
uri: 'file://${_file.path}',
);
_handle = result.handle;
_textureId = result.textureId;
_eventsSub = XVideoPlayerBackend.instance.events(result.handle).listen(
_onEvent,
onError: (Object error, StackTrace? stack) {
_setValue = _value.copyWith(errorDescription: error.toString());
},
);
final metadata =
await XVideoPlayerBackend.instance.initialize(result.handle);
_setValue = _value.copyWith(
isInitialized: true,
size: metadata.size,
duration: metadata.duration,
rotationQuarterTurns: metadata.rotationQuarterTurns,
);
} catch (_) {
// Tear down anything the native side allocated mid-failure so a
// retry isn't blocked by leaked resources.
await dispose();
rethrow;
}
}
void _onEvent(XVideoPlayerEvent event) {
switch (event) {
case XVideoPlayerStateChanged(
:final isPlaying,
:final isBuffering,
:final position,
:final buffered
):
_setValue = _value.copyWith(
isPlaying: isPlaying ?? _value.isPlaying,
isBuffering: isBuffering ?? _value.isBuffering,
position: position ?? _value.position,
buffered: buffered ?? _value.buffered,
);
case XVideoPlayerSizeChanged(:final size):
_setValue = _value.copyWith(size: size);
case XVideoPlayerCompleted():
if (!_value.isLooping) {
_setValue = _value.copyWith(isPlaying: false);
}
case XVideoPlayerError(:final code, :final description):
_setValue = _value.copyWith(errorDescription: description ?? code);
}
}
Future<void> play() async {
final handle = _requireHandle('play');
await XVideoPlayerBackend.instance.play(handle);
_setValue = _value.copyWith(isPlaying: true);
}
Future<void> pause() async {
final handle = _requireHandle('pause');
await XVideoPlayerBackend.instance.pause(handle);
_setValue = _value.copyWith(isPlaying: false);
}
Future<void> seekTo(Duration position) async {
final handle = _requireHandle('seekTo');
final clamped = position < Duration.zero
? Duration.zero
: position > _value.duration && _value.duration > Duration.zero
? _value.duration
: position;
await XVideoPlayerBackend.instance.seekTo(handle, clamped);
_setValue = _value.copyWith(position: clamped);
}
Future<void> setLooping(bool loop) async {
final handle = _requireHandle('setLooping');
await XVideoPlayerBackend.instance.setLooping(handle, loop);
_setValue = _value.copyWith(isLooping: loop);
}
Future<void> setVolume(double volume) async {
final handle = _requireHandle('setVolume');
final clamped = volume.clamp(0.0, 1.0).toDouble();
await XVideoPlayerBackend.instance.setVolume(handle, clamped);
_setValue = _value.copyWith(volume: clamped);
}
Future<void> setPlaybackSpeed(double rate) async {
final handle = _requireHandle('setPlaybackSpeed');
final clamped = rate.clamp(0.25, 4.0).toDouble();
await XVideoPlayerBackend.instance.setPlaybackSpeed(handle, clamped);
_setValue = _value.copyWith(playbackSpeed: clamped);
}
@override
Future<void> dispose() async {
if (_disposed) return;
_disposed = true;
final handle = _handle;
_handle = null;
final sub = _eventsSub;
_eventsSub = null;
if (handle != null) {
try {
await XVideoPlayerBackend.instance.disposeInstance(handle);
} on PlatformException catch (_) {
// Native side may have already torn down (e.g. engine
// detaching). Don't propagate — dispose() must be safe to
// call repeatedly.
}
}
await sub?.cancel();
super.dispose();
}
/// Texture-backed (Apple) or platform-view-backed (Android) render
/// widget. Equivalent to `VideoPlayer(controller)` — wrap in
/// `AspectRatio` to control framing.
Widget buildPlayer() => XVideoPlayerView(controller: this);
int _requireHandle(String op) {
_throwIfDisposed(op);
final h = _handle;
if (h == null) {
throw const XVideoPlayerException('not_initialized');
}
return h;
}
void _throwIfDisposed(String op) {
if (_disposed) {
throw XVideoPlayerException(
'disposed',
'$op called on a disposed controller',
);
}
}
}

View File

@@ -0,0 +1,132 @@
import 'dart:async';
import 'dart:ui' show Size;
import 'x_video_player.dart' show XDurationRange;
import 'x_video_player_channel.dart' show MethodChannelXVideoPlayerBackend;
/// Backend contract that [XVideoPlayerController] dispatches into. The
/// default implementation calls into native code via the `ux/video` /
/// `ux/video/events` channels; tests substitute their own (see
/// `package:ux/testing.dart`'s `FakeXVideoPlayerBackend`).
///
/// Every per-instance call carries a `handle` returned by [create] so
/// the plugin can route to the right native session. Multiple
/// controllers can hold simultaneous handles.
abstract class XVideoPlayerBackend {
/// Swap to inject a fake before any UI code mounts a controller.
static XVideoPlayerBackend instance = MethodChannelXVideoPlayerBackend();
/// Allocate a native player instance bound to [uri] (currently always
/// `file://...`). Returns the handle and, on Apple platforms, the
/// Flutter texture id; Android returns `textureId: null` because the
/// render path is a platform view that takes the handle as creation
/// params.
Future<XVideoPlayerCreateResult> create({required String uri});
/// Load the media. Resolves once the codec is configured and metadata
/// (size, duration) is known. Throws
/// [XVideoPlayerException("decode_failed")] if the file can't be
/// opened or the codec init failed permanently.
Future<XVideoPlayerMetadata> initialize(int handle);
/// Tear down the session. Releases the codec and any held surface /
/// texture. Safe to call repeatedly; no-op once disposed.
Future<void> disposeInstance(int handle);
Future<void> play(int handle);
Future<void> pause(int handle);
Future<void> seekTo(int handle, Duration position);
Future<void> setLooping(int handle, bool loop);
Future<void> setVolume(int handle, double volume);
Future<void> setPlaybackSpeed(int handle, double rate);
/// Live event stream for [handle]. The controller subscribes during
/// [create] / [initialize] and unsubscribes on [disposeInstance].
Stream<XVideoPlayerEvent> events(int handle);
}
/// The tuple returned by [XVideoPlayerBackend.create] — everything the
/// controller needs to start the load + route subsequent calls.
class XVideoPlayerCreateResult {
const XVideoPlayerCreateResult({
required this.handle,
this.textureId,
});
final int handle;
/// Apple-platform Flutter texture id, or null on Android (which uses
/// a platform view keyed off [handle] instead).
final int? textureId;
}
/// Media metadata returned by [XVideoPlayerBackend.initialize] once the
/// codec is ready.
class XVideoPlayerMetadata {
const XVideoPlayerMetadata({
required this.size,
required this.duration,
this.rotationQuarterTurns = 0,
});
final Size size;
final Duration duration;
/// Number of 90° clockwise rotations the Flutter `Texture` widget
/// needs so the rendered frame reads upright. Android always
/// reports 0 (the codec + GLES blit apply the rotation upstream of
/// Flutter). Apple reports the rotation derived from the video
/// track's `preferredTransform` — `AVPlayerItemVideoOutput`
/// delivers pixel buffers in the file's natural orientation, so the
/// `RotatedBox` wrapper applies it Dart-side.
final int rotationQuarterTurns;
}
/// Events pushed by the native side over `ux/video/events`. Sealed —
/// new variants land here as the contract grows.
sealed class XVideoPlayerEvent {
const XVideoPlayerEvent(this.handle);
final int handle;
}
/// Periodic + edge-triggered state update. Any field may be null,
/// meaning "no change since the last snapshot." Position is included
/// on every emit; the rest are emitted on transition.
class XVideoPlayerStateChanged extends XVideoPlayerEvent {
const XVideoPlayerStateChanged(
super.handle, {
this.isPlaying,
this.isBuffering,
this.position,
this.buffered,
});
final bool? isPlaying;
final bool? isBuffering;
final Duration? position;
final List<XDurationRange>? buffered;
}
/// Fired when the codec reports a video-size change. Usually exactly
/// once shortly after the first frame is decoded; some adaptive
/// streams emit it on resolution changes (not in scope for the
/// file-only constructor today but the event remains valid).
class XVideoPlayerSizeChanged extends XVideoPlayerEvent {
const XVideoPlayerSizeChanged(super.handle, this.size);
final Size size;
}
/// Fired when playback hits the end of the media. Loop-mode is
/// observed natively, so the controller only sees this event when
/// playback truly stops at the tail.
class XVideoPlayerCompleted extends XVideoPlayerEvent {
const XVideoPlayerCompleted(super.handle);
}
/// Terminal error. The controller marks the value as `hasError = true`
/// and stops emitting state updates until [XVideoPlayerBackend.disposeInstance].
class XVideoPlayerError extends XVideoPlayerEvent {
const XVideoPlayerError(super.handle, this.code, this.description);
final String code;
final String? description;
}

View File

@@ -0,0 +1,155 @@
import 'dart:async';
import 'dart:ui' show Size;
import 'package:flutter/services.dart';
import 'x_video_player.dart' show XDurationRange, XVideoPlayerException;
import 'x_video_player_backend.dart';
/// Production [XVideoPlayerBackend]. Hand-rolled MethodChannel +
/// EventChannel — matches the rest of `package:ux`, no pigeon.
class MethodChannelXVideoPlayerBackend implements XVideoPlayerBackend {
MethodChannelXVideoPlayerBackend();
static const _channel = MethodChannel('ux/video');
static const _eventsChannel = EventChannel('ux/video/events');
late final Stream<Object?> _rawEvents =
_eventsChannel.receiveBroadcastStream();
@override
Future<XVideoPlayerCreateResult> create({required String uri}) async {
final m = await _invokeMap('create', {'uri': uri});
return XVideoPlayerCreateResult(
handle: (m['handle'] as num).toInt(),
textureId: (m['textureId'] as num?)?.toInt(),
);
}
@override
Future<XVideoPlayerMetadata> initialize(int handle) async {
final m = await _invokeMap('initialize', {'handle': handle});
final s = (m['size'] as Map).cast<Object?, Object?>();
return XVideoPlayerMetadata(
size: Size(
(s['width'] as num).toDouble(),
(s['height'] as num).toDouble(),
),
duration: Duration(milliseconds: (m['durationMs'] as num).toInt()),
rotationQuarterTurns:
(m['rotationQuarterTurns'] as num?)?.toInt() ?? 0,
);
}
@override
Future<void> disposeInstance(int handle) =>
_invokeVoid('dispose', {'handle': handle});
@override
Future<void> play(int handle) =>
_invokeVoid('play', {'handle': handle});
@override
Future<void> pause(int handle) =>
_invokeVoid('pause', {'handle': handle});
@override
Future<void> seekTo(int handle, Duration position) =>
_invokeVoid('seekTo', {
'handle': handle,
'positionMs': position.inMilliseconds,
});
@override
Future<void> setLooping(int handle, bool loop) =>
_invokeVoid('setLooping', {'handle': handle, 'loop': loop});
@override
Future<void> setVolume(int handle, double volume) =>
_invokeVoid('setVolume', {'handle': handle, 'volume': volume});
@override
Future<void> setPlaybackSpeed(int handle, double rate) =>
_invokeVoid('setPlaybackSpeed', {'handle': handle, 'rate': rate});
@override
Stream<XVideoPlayerEvent> events(int handle) {
return _rawEvents
.map((e) => (e as Map).cast<Object?, Object?>())
.where((m) => (m['handle'] as num).toInt() == handle)
.map(_decodeEvent);
}
// ---- parsers / arg encoders -------------------------------------
static XVideoPlayerEvent _decodeEvent(Map<Object?, Object?> m) {
final handle = (m['handle'] as num).toInt();
switch (m['event'] as String?) {
case 'stateChanged':
return XVideoPlayerStateChanged(
handle,
isPlaying: m['isPlaying'] as bool?,
isBuffering: m['isBuffering'] as bool?,
position: _parseMs(m['positionMs']),
buffered: _parseBuffered(m['buffered']),
);
case 'sizeChanged':
final s = (m['size'] as Map).cast<Object?, Object?>();
return XVideoPlayerSizeChanged(
handle,
Size(
(s['width'] as num).toDouble(),
(s['height'] as num).toDouble(),
),
);
case 'completed':
return XVideoPlayerCompleted(handle);
case 'error':
return XVideoPlayerError(
handle,
m['code'] as String? ?? 'player_runtime_error',
m['description'] as String?,
);
default:
return XVideoPlayerError(handle, 'unknown_event', null);
}
}
static Duration? _parseMs(Object? raw) {
if (raw == null) return null;
return Duration(milliseconds: (raw as num).toInt());
}
static List<XDurationRange>? _parseBuffered(Object? raw) {
if (raw == null) return null;
return [
for (final r in (raw as List).cast<Map<Object?, Object?>>())
XDurationRange(
Duration(milliseconds: (r['startMs'] as num).toInt()),
Duration(milliseconds: (r['endMs'] as num).toInt()),
),
];
}
// ---- channel adapter --------------------------------------------
Future<Map<Object?, Object?>> _invokeMap(
String method, [
Map<String, Object?>? args,
]) async {
try {
final result = await _channel.invokeMethod<Object?>(method, args);
return (result as Map).cast<Object?, Object?>();
} on PlatformException catch (e) {
throw XVideoPlayerException(e.code, e.message);
}
}
Future<void> _invokeVoid(String method, [Map<String, Object?>? args]) async {
try {
await _channel.invokeMethod<void>(method, args);
} on PlatformException catch (e) {
throw XVideoPlayerException(e.code, e.message);
}
}
}

View File

@@ -0,0 +1,57 @@
import 'package:flutter/widgets.dart';
import 'x_video_player.dart' show XVideoPlayerController, XVideoPlayerValue;
/// Renders the active frame of [controller] into the parent's box.
/// Sizes itself to the parent — wrap in `AspectRatio` / `FittedBox` /
/// `Hero` to control framing.
///
/// Single render path: a Flutter [Texture] widget over the texture
/// the native side hands back at `create()` time. Android feeds the
/// texture from an [`ExoPlayer`] via a `SurfaceTexture`; Apple feeds
/// it from `AVPlayerItemVideoOutput`. Because the rendered content
/// lives inside Flutter's compositor:
///
/// - gallery hero / dismiss animations stay buttery
/// - `RenderRepaintBoundary.toImage` (the gallery's `snapshot`
/// mechanism) sees the actual frame and can freeze it across
/// transitions
/// - no per-frame `ImageReader` round-trip — scrub latency stays at
/// codec-seek speed
///
/// Codec crop is handled native-side: Media3's
/// `DefaultVideoFrameProcessor` (Android) re-applies the codec's crop
/// rect downstream of the broken Huawei `SurfaceTexture` transform
/// matrix, so the green right-edge artifact never reaches Flutter's
/// sampler. Apple's `AVPlayerItemVideoOutput` is crop-correct by
/// construction.
class XVideoPlayerView extends StatelessWidget {
const XVideoPlayerView({super.key, required this.controller});
final XVideoPlayerController controller;
@override
Widget build(BuildContext context) {
return ValueListenableBuilder<XVideoPlayerValue>(
valueListenable: controller,
builder: (context, value, _) {
if (!value.isInitialized) return SizedBox.expand();
final textureId = controller.textureId;
if (textureId == null) return SizedBox.expand();
Widget child = Texture(textureId: textureId);
// Apple's `AVPlayerItemVideoOutput` hands us `CVPixelBuffer`s in
// the file's natural orientation — the video track's
// `preferredTransform` rotation is NOT applied. The native side
// reports the rotation as quarter-turns; we apply it here so
// the rendered frame reads upright. Android always reports 0
// because the codec + GLES blit handle rotation upstream of
// Flutter.
final turns = value.rotationQuarterTurns;
if (turns != 0) {
child = RotatedBox(quarterTurns: turns, child: child);
}
return child;
},
);
}
}

132
lib/src/view_padding.dart Normal file
View File

@@ -0,0 +1,132 @@
import 'dart:ui' show FlutterView;
import 'package:flutter/scheduler.dart';
import 'package:flutter/widgets.dart';
/// Smoothly-animated view padding (safe-area insets).
///
/// Replaces a raw `view.viewPadding` read with a value that lerps toward the
/// system inset over [duration] when it changes — so when the OS bars toggle
/// visibility (e.g. switching out of `SystemUiMode.immersiveSticky` back to
/// `edgeToEdge`) bottom- and top-anchored UI slides into place instead of
/// snapping by the nav-bar / status-bar height.
///
/// Use the singleton [instance] and listen via [addListener]; or wrap a
/// subtree in [XAnimatedInsets] so descendant `MediaQuery.viewPaddingOf`
/// reads pick up the animated value.
///
/// Tracks only `viewPadding` — keyboard insets (`viewInsets`) are handled by
/// [XKeyboard] and intentionally bypass this smoothing.
class XInsets with ChangeNotifier, WidgetsBindingObserver {
XInsets._() {
WidgetsBinding.instance.addObserver(this);
final view = WidgetsBinding.instance.platformDispatcher.implicitView;
_system = _read(view);
_current = _system;
}
/// Singleton instance. Constructed lazily on first access; requires
/// `WidgetsFlutterBinding.ensureInitialized()` to have been called.
static final XInsets instance = XInsets._();
EdgeInsets _system = EdgeInsets.zero;
EdgeInsets _current = EdgeInsets.zero;
EdgeInsets _from = EdgeInsets.zero;
Duration _start = Duration.zero;
bool _ticking = false;
/// Lerp duration. Set to [Duration.zero] in tests for deterministic
/// frame-by-frame goldens.
Duration duration = const Duration(milliseconds: 220);
/// Lerp curve.
Curve curve = Curves.easeOut;
/// Smoothed view-padding in logical pixels.
EdgeInsets get viewPadding => _current;
/// Raw system view-padding (the un-lerped target). Use sparingly — most
/// callers want [viewPadding].
EdgeInsets get systemViewPadding => _system;
@override
void didChangeMetrics() {
final next = _read(WidgetsBinding.instance.platformDispatcher.implicitView);
if (next == _system) return;
_system = next;
if (duration == Duration.zero) {
_current = next;
_ticking = false;
notifyListeners();
return;
}
_from = _current;
_start = SchedulerBinding.instance.currentSystemFrameTimeStamp;
if (!_ticking) {
_ticking = true;
SchedulerBinding.instance.scheduleFrameCallback(_tick);
SchedulerBinding.instance.scheduleFrame();
}
}
void _tick(Duration ts) {
if (!_ticking) return;
final dt = (ts - _start).inMicroseconds / duration.inMicroseconds;
final t = curve.transform(dt.clamp(0.0, 1.0));
_current = EdgeInsets.lerp(_from, _system, t)!;
notifyListeners();
if (dt < 1.0) {
SchedulerBinding.instance.scheduleFrameCallback(_tick);
SchedulerBinding.instance.scheduleFrame();
} else {
_ticking = false;
}
}
static EdgeInsets _read(FlutterView? v) {
if (v == null) return EdgeInsets.zero;
final r = v.devicePixelRatio;
final p = v.viewPadding;
return EdgeInsets.fromLTRB(p.left / r, p.top / r, p.right / r, p.bottom / r);
}
}
/// Wraps [child] in a [MediaQuery] whose `viewPadding` is sourced from
/// [XInsets.instance] (smoothly animated). Use once at the app root so every
/// descendant `MediaQuery.viewPaddingOf(context)` read returns the animated
/// value.
class XAnimatedInsets extends StatefulWidget {
const XAnimatedInsets({super.key, required this.child});
final Widget child;
@override
State<XAnimatedInsets> createState() => _XAnimatedInsetsState();
}
class _XAnimatedInsetsState extends State<XAnimatedInsets> {
@override
void initState() {
super.initState();
XInsets.instance.addListener(_onTick);
}
@override
void dispose() {
XInsets.instance.removeListener(_onTick);
super.dispose();
}
void _onTick() {
if (mounted) setState(() {});
}
@override
Widget build(BuildContext context) {
final mq = MediaQuery.of(context);
return MediaQuery(
data: mq.copyWith(viewPadding: XInsets.instance.viewPadding),
child: widget.child,
);
}
}

View File

@@ -7,4 +7,5 @@ library;
export 'src/testing/fake_camera.dart'; export 'src/testing/fake_camera.dart';
export 'src/testing/fake_gallery.dart'; export 'src/testing/fake_gallery.dart';
export 'src/testing/fake_video_player.dart';
export 'src/testing/text_golden.dart'; export 'src/testing/text_golden.dart';

View File

@@ -14,10 +14,15 @@ export 'src/camera/camera.dart';
export 'src/camera/camera_backend.dart' show XCameraBackend, XCameraCreateResult, XCameraEvent, XCameraDeviceOrientationChanged, XCameraSessionError, XCameraSessionInterrupted, XCameraSessionResumed, XCameraDiagnostic, XCameraPreviewSizeChanged; export 'src/camera/camera_backend.dart' show XCameraBackend, XCameraCreateResult, XCameraEvent, XCameraDeviceOrientationChanged, XCameraSessionError, XCameraSessionInterrupted, XCameraSessionResumed, XCameraDiagnostic, XCameraPreviewSizeChanged;
export 'src/camera/camera_channel.dart' show MethodChannelXCameraBackend; export 'src/camera/camera_channel.dart' show MethodChannelXCameraBackend;
export 'src/camera/camera_preview.dart'; export 'src/camera/camera_preview.dart';
export 'src/video/x_video_player.dart';
export 'src/video/x_video_player_backend.dart' show XVideoPlayerBackend, XVideoPlayerCreateResult, XVideoPlayerMetadata, XVideoPlayerEvent, XVideoPlayerStateChanged, XVideoPlayerSizeChanged, XVideoPlayerCompleted, XVideoPlayerError;
export 'src/video/x_video_player_channel.dart' show MethodChannelXVideoPlayerBackend;
export 'src/video/x_video_player_view.dart' show XVideoPlayerView;
export 'src/clipboard.dart'; export 'src/clipboard.dart';
export 'src/file.dart'; export 'src/file.dart';
export 'src/gallery.dart'; export 'src/gallery.dart';
export 'src/keyboard.dart'; export 'src/keyboard.dart';
export 'src/view_padding.dart';
export 'src/auto_map.dart'; export 'src/auto_map.dart';
export 'src/scanner.dart'; export 'src/scanner.dart';
export 'src/url.dart'; export 'src/url.dart';

View File

@@ -0,0 +1,55 @@
import CoreVideo
import Foundation
#if canImport(UIKit)
import Flutter
#else
import FlutterMacOS
#endif
/// Single-slot latest-pixel-buffer sink that feeds a `FlutterTexture`.
/// Mirrors `PreviewSink` in `darwin/Camera/` receiver writes, the
/// engine reads via `copyPixelBuffer()`. We retain only the most
/// recent buffer; older ones get released the moment a new frame
/// arrives, bounding memory at one frame.
final class UxVideoPixelBufferSink: NSObject, FlutterTexture {
private weak var registry: FlutterTextureRegistry?
private var textureId: Int64 = -1
private let bufferQueue = DispatchQueue(
label: "ux.video.buffer",
qos: .userInitiated
)
private var latestPixelBuffer: CVPixelBuffer?
func register(with registry: FlutterTextureRegistry) -> Int64 {
self.registry = registry
textureId = registry.register(self)
return textureId
}
func unregister() {
registry?.unregisterTexture(textureId)
bufferQueue.sync { latestPixelBuffer = nil }
}
// MARK: - FlutterTexture
func copyPixelBuffer() -> Unmanaged<CVPixelBuffer>? {
var pb: CVPixelBuffer?
bufferQueue.sync {
pb = latestPixelBuffer
latestPixelBuffer = nil
}
if let pb = pb { return Unmanaged.passRetained(pb) }
return nil
}
/// Receives a new frame from the AVPlayerItemVideoOutput pump.
/// Cheap just swaps the pointer + notifies the registry.
func deliver(pixelBuffer: CVPixelBuffer) {
bufferQueue.sync { latestPixelBuffer = pixelBuffer }
if let registry = registry {
registry.textureFrameAvailable(textureId)
}
}
}

View File

@@ -0,0 +1,431 @@
import AVFoundation
import CoreMedia
import CoreVideo
import Foundation
import QuartzCore
#if canImport(UIKit)
import Flutter
import UIKit
#else
import FlutterMacOS
#endif
/// One per Dart-side `XVideoPlayerController`. Owns the `AVPlayer`,
/// an `AVPlayerItemVideoOutput` that pumps `CVPixelBuffer`s into the
/// Flutter texture, and a periodic timer that fires position +
/// buffered events back to Dart.
///
/// Mirrors the shape of `CameraInstance` main-thread methods,
/// per-handle event payloads tagged with `handle`.
final class UxVideoPlayerInstance: NSObject {
let handle: Int
let textureId: Int64
/// Pushed to Dart as `{event, handle, }` payloads. Set by the
/// plugin in `create`.
var onEvent: ((/* payload */ [String: Any?]) -> Void)?
private weak var textureRegistry: FlutterTextureRegistry?
private let sink: UxVideoPixelBufferSink
private let player = AVPlayer()
private var item: AVPlayerItem?
private var videoOutput: AVPlayerItemVideoOutput?
private var pumpTimer: Timer?
private var positionTimer: Timer?
private var prepareCompletion: ((Error?, CGSize, Int64, Int) -> Void)?
/// Quarter-turns of clockwise rotation the Flutter `Texture` needs
/// so the rendered frame reads upright. AVPlayerItemVideoOutput
/// delivers `CVPixelBuffer`s in the file's *natural* orientation
/// the file's `preferredTransform` rotation is NOT applied while
/// `item.presentationSize` already reflects the rotated dimensions.
/// Without surfacing this to Dart, the gallery wraps a
/// portrait-displayed-as-landscape buffer in a portrait
/// `AspectRatio` and stretches it. Computed from the first video
/// track's `preferredTransform` in [setSource]; reported back via
/// [prepare]'s completion alongside the presentation size.
private var rotationQuarterTurns: Int = 0
private var disposed = false
private var lastReportedIsPlaying = false
private var lastReportedIsBuffering = false
private var lastReportedSize: CGSize = .zero
private var lastReportedDurationMs: Int64 = -1
private var notifiedCompleted = false
private static let pumpHz: TimeInterval = 1.0 / 60.0
private static let positionHz: TimeInterval = 1.0 / 10.0
/// `kCVPixelFormatType_32BGRA` keeps the Flutter texture path
/// happy Flutter's iOS/macOS embedder expects BGRA8888.
private static let pixelBufferAttributes: [String: Any] = [
kCVPixelBufferPixelFormatTypeKey as String: kCVPixelFormatType_32BGRA,
kCVPixelBufferIOSurfacePropertiesKey as String: [:] as CFDictionary,
]
init(handle: Int, registry: FlutterTextureRegistry) {
self.handle = handle
self.textureRegistry = registry
self.sink = UxVideoPixelBufferSink()
self.textureId = sink.register(with: registry)
super.init()
}
// MARK: - Source / prepare
func setSource(url: URL) {
let asset = AVURLAsset(url: url)
rotationQuarterTurns = Self.rotationQuarterTurns(for: asset)
let newItem = AVPlayerItem(asset: asset)
let output = AVPlayerItemVideoOutput(
pixelBufferAttributes: Self.pixelBufferAttributes,
)
newItem.add(output)
item = newItem
videoOutput = output
player.replaceCurrentItem(with: newItem)
addObservers(item: newItem)
}
/// Maps the first video track's `preferredTransform` to quarter
/// turns of clockwise rotation. `atan2(b, a)` recovers the rotation
/// angle from the affine transform's rotation/scale components;
/// rounding to the nearest 90° gives a stable enum-like value
/// (`Int` mod 4) for the Dart-side `RotatedBox`.
private static func rotationQuarterTurns(for asset: AVAsset) -> Int {
guard let track = asset.tracks(withMediaType: .video).first else { return 0 }
let t = track.preferredTransform
let radians = atan2(t.b, t.a)
let degrees = radians * 180.0 / .pi
let normalized = (Int(degrees.rounded()) % 360 + 360) % 360
return (normalized / 90) % 4
}
func prepare(completion: @escaping (Error?, CGSize, Int64, Int) -> Void) {
guard let item = item else {
completion(
NSError(
domain: "ux.video",
code: -1,
userInfo: [NSLocalizedDescriptionKey: "no item"],
),
.zero,
0,
0,
)
return
}
prepareCompletion = completion
// If we already passed .readyToPlay, fire immediately.
if item.status == .readyToPlay {
let dims = item.presentationSize
let durMs = Self.toMs(item.duration)
prepareCompletion = nil
startPumping()
startPositionTimer()
completion(nil, dims, durMs, rotationQuarterTurns)
}
// Otherwise the KVO on `status` will resolve us when ready.
}
// MARK: - Controls
func play() {
guard !disposed else { return }
player.play()
}
func pause() {
guard !disposed else { return }
player.pause()
}
func seekTo(positionMs: Int64) {
guard !disposed else { return }
let time = CMTime(value: max(0, positionMs), timescale: 1000)
player.seek(
to: time,
toleranceBefore: .zero,
toleranceAfter: .zero,
)
emitPosition(forcePositionMs: positionMs)
}
func setLooping(_ loop: Bool) {
guard !disposed else { return }
loopingEnabled = loop
}
func setVolume(_ volume: Float) {
guard !disposed else { return }
player.volume = max(0, min(1, volume))
}
func setPlaybackSpeed(_ rate: Float) {
guard !disposed else { return }
let clamped = max(0.25, min(4.0, rate))
defaultRate = clamped
if player.rate != 0 {
player.rate = clamped
}
}
func dispose() {
guard !disposed else { return }
disposed = true
pumpTimer?.invalidate()
pumpTimer = nil
positionTimer?.invalidate()
positionTimer = nil
if let item = item {
removeObservers(item: item)
}
if let videoOutput = videoOutput, let item = item {
item.remove(videoOutput)
}
videoOutput = nil
item = nil
player.replaceCurrentItem(with: nil)
sink.unregister()
onEvent = nil
}
// MARK: - Observers / completion
private var loopingEnabled = false
private var defaultRate: Float = 1.0
private var statusContext = 0
private var bufferEmptyContext = 1
private var likelyToKeepUpContext = 2
private var rateContext = 3
private func addObservers(item: AVPlayerItem) {
item.addObserver(
self,
forKeyPath: "status",
options: [.new, .initial],
context: &statusContext,
)
item.addObserver(
self,
forKeyPath: "playbackBufferEmpty",
options: [.new],
context: &bufferEmptyContext,
)
item.addObserver(
self,
forKeyPath: "playbackLikelyToKeepUp",
options: [.new],
context: &likelyToKeepUpContext,
)
player.addObserver(
self,
forKeyPath: "rate",
options: [.new],
context: &rateContext,
)
NotificationCenter.default.addObserver(
self,
selector: #selector(playbackEnded(_:)),
name: .AVPlayerItemDidPlayToEndTime,
object: item,
)
}
private func removeObservers(item: AVPlayerItem) {
item.removeObserver(self, forKeyPath: "status", context: &statusContext)
item.removeObserver(self, forKeyPath: "playbackBufferEmpty", context: &bufferEmptyContext)
item.removeObserver(self, forKeyPath: "playbackLikelyToKeepUp", context: &likelyToKeepUpContext)
player.removeObserver(self, forKeyPath: "rate", context: &rateContext)
NotificationCenter.default.removeObserver(
self,
name: .AVPlayerItemDidPlayToEndTime,
object: item,
)
}
override func observeValue(
forKeyPath keyPath: String?,
of object: Any?,
change: [NSKeyValueChangeKey: Any]?,
context: UnsafeMutableRawPointer?,
) {
DispatchQueue.main.async { [weak self] in
guard let self = self, !self.disposed else { return }
self.handleObservation(keyPath: keyPath, context: context)
}
}
private func handleObservation(keyPath: String?, context: UnsafeMutableRawPointer?) {
if context == &statusContext, let item = item {
switch item.status {
case .readyToPlay:
if let completion = prepareCompletion {
prepareCompletion = nil
let dims = item.presentationSize
let durMs = Self.toMs(item.duration)
lastReportedSize = dims
lastReportedDurationMs = durMs
startPumping()
startPositionTimer()
emitSizeChanged(dims)
completion(nil, dims, durMs, rotationQuarterTurns)
}
case .failed:
if let completion = prepareCompletion {
prepareCompletion = nil
completion(
item.error
?? NSError(
domain: "ux.video",
code: -2,
userInfo: [NSLocalizedDescriptionKey: "AVPlayerItem failed"],
),
.zero,
0,
0,
)
} else if let error = item.error {
emit([
"event": "error",
"code": "playback_failed",
"description": error.localizedDescription,
])
}
default:
break
}
} else if context == &bufferEmptyContext, let item = item {
let buffering = item.isPlaybackBufferEmpty
if buffering != lastReportedIsBuffering {
lastReportedIsBuffering = buffering
emit([
"event": "stateChanged",
"isBuffering": buffering,
"positionMs": currentPositionMs(),
])
}
} else if context == &likelyToKeepUpContext, let item = item {
if item.isPlaybackLikelyToKeepUp && lastReportedIsBuffering {
lastReportedIsBuffering = false
emit([
"event": "stateChanged",
"isBuffering": false,
"positionMs": currentPositionMs(),
])
}
} else if context == &rateContext {
let playing = player.rate != 0
if playing != lastReportedIsPlaying {
lastReportedIsPlaying = playing
emit([
"event": "stateChanged",
"isPlaying": playing,
"positionMs": currentPositionMs(),
])
}
}
}
@objc private func playbackEnded(_ note: Notification) {
if loopingEnabled {
player.seek(to: .zero) { [weak self] _ in
self?.player.play()
}
return
}
if !notifiedCompleted {
notifiedCompleted = true
emit([
"event": "completed",
])
}
}
// MARK: - Pumps
private func startPumping() {
pumpTimer?.invalidate()
let timer = Timer(timeInterval: Self.pumpHz, repeats: true) { [weak self] _ in
self?.pumpFrame()
}
RunLoop.main.add(timer, forMode: .common)
pumpTimer = timer
}
private func startPositionTimer() {
positionTimer?.invalidate()
let timer = Timer(timeInterval: Self.positionHz, repeats: true) { [weak self] _ in
self?.emitPosition()
}
RunLoop.main.add(timer, forMode: .common)
positionTimer = timer
}
private func pumpFrame() {
guard let videoOutput = videoOutput else { return }
let host = videoOutput.itemTime(forHostTime: CACurrentMediaTime())
guard videoOutput.hasNewPixelBuffer(forItemTime: host) else { return }
guard let pixelBuffer = videoOutput.copyPixelBuffer(
forItemTime: host,
itemTimeForDisplay: nil,
) else { return }
sink.deliver(pixelBuffer: pixelBuffer)
}
private func emitPosition(forcePositionMs: Int64? = nil) {
let positionMs = forcePositionMs ?? currentPositionMs()
let buffered = bufferedRanges()
emit([
"event": "stateChanged",
"positionMs": positionMs,
"buffered": buffered,
])
}
private func bufferedRanges() -> [[String: Int64]] {
guard let item = item else { return [] }
return item.loadedTimeRanges.map { value -> [String: Int64] in
let r = value.timeRangeValue
let startMs = Int64(CMTimeGetSeconds(r.start) * 1000.0)
let endMs = Int64(CMTimeGetSeconds(r.start + r.duration) * 1000.0)
return [
"startMs": max(0, startMs),
"endMs": max(0, endMs),
]
}
}
private func currentPositionMs() -> Int64 {
let t = player.currentTime()
return Int64(max(0, CMTimeGetSeconds(t)) * 1000.0)
}
private func emitSizeChanged(_ size: CGSize) {
emit([
"event": "sizeChanged",
"size": [
"width": Double(size.width),
"height": Double(size.height),
] as [String: Any],
])
}
private func emit(_ extras: [String: Any?]) {
var payload = extras
payload["handle"] = handle
onEvent?(payload)
}
private static func toMs(_ t: CMTime) -> Int64 {
guard t.isValid, !t.isIndefinite else { return 0 }
let seconds = CMTimeGetSeconds(t)
guard seconds.isFinite, !seconds.isNaN else { return 0 }
return Int64(max(0, seconds) * 1000.0)
}
}

View File

@@ -0,0 +1,205 @@
import AVFoundation
import Foundation
#if canImport(UIKit)
import Flutter
#else
import FlutterMacOS
#endif
/// `ux/video` + `ux/video/events` registrar. Routes channel calls to
/// per-handle [UxVideoPlayerInstance]s. Mirrors `CameraPlugin`'s shape.
public class UxVideoPlayerPlugin: NSObject, NativePlugin, FlutterStreamHandler {
private weak var textureRegistry: FlutterTextureRegistry?
private var instances: [Int: UxVideoPlayerInstance] = [:]
private var nextHandle: Int = 1
private var eventSink: FlutterEventSink?
public func register(with registrar: FlutterPluginRegistrar) {
textureRegistry = registrar.uxTextures
let methods = FlutterMethodChannel(
name: "ux/video",
binaryMessenger: registrar.uxMessenger,
)
methods.setMethodCallHandler { [weak self] call, result in
self?.handle(call, result: result)
}
let events = FlutterEventChannel(
name: "ux/video/events",
binaryMessenger: registrar.uxMessenger,
)
events.setStreamHandler(self)
}
// MARK: - FlutterStreamHandler
public func onListen(
withArguments arguments: Any?,
eventSink events: @escaping FlutterEventSink,
) -> FlutterError? {
eventSink = events
return nil
}
public func onCancel(withArguments arguments: Any?) -> FlutterError? {
eventSink = nil
return nil
}
// MARK: - Dispatch
private func handle(_ call: FlutterMethodCall, result: @escaping FlutterResult) {
switch call.method {
case "create":
create(args: call.arguments, result: result)
case "initialize":
withInstance(call.arguments, result: result) { instance in
instance.prepare { error, size, durationMs, rotationQuarterTurns in
DispatchQueue.main.async {
if let error = error {
result(
FlutterError(
code: "decode_failed",
message: error.localizedDescription,
details: nil,
)
)
return
}
result(
[
"size": [
"width": Double(size.width),
"height": Double(size.height),
] as [String: Any],
"durationMs": durationMs,
"rotationQuarterTurns": rotationQuarterTurns,
] as [String: Any]
)
}
}
}
case "dispose":
withInstance(call.arguments, result: result) { instance in
self.instances.removeValue(forKey: instance.handle)
instance.dispose()
result(nil)
}
case "play":
withInstance(call.arguments, result: result) { instance in
instance.play()
result(nil)
}
case "pause":
withInstance(call.arguments, result: result) { instance in
instance.pause()
result(nil)
}
case "seekTo":
guard let args = call.arguments as? [String: Any],
let pos = (args["positionMs"] as? NSNumber)?.int64Value
else {
result(badArgs("seekTo"))
return
}
withInstance(args, result: result) { instance in
instance.seekTo(positionMs: pos)
result(nil)
}
case "setLooping":
guard let args = call.arguments as? [String: Any],
let loop = args["loop"] as? Bool
else {
result(badArgs("setLooping"))
return
}
withInstance(args, result: result) { instance in
instance.setLooping(loop)
result(nil)
}
case "setVolume":
guard let args = call.arguments as? [String: Any],
let volume = (args["volume"] as? NSNumber)?.floatValue
else {
result(badArgs("setVolume"))
return
}
withInstance(args, result: result) { instance in
instance.setVolume(volume)
result(nil)
}
case "setPlaybackSpeed":
guard let args = call.arguments as? [String: Any],
let rate = (args["rate"] as? NSNumber)?.floatValue
else {
result(badArgs("setPlaybackSpeed"))
return
}
withInstance(args, result: result) { instance in
instance.setPlaybackSpeed(rate)
result(nil)
}
default:
result(FlutterMethodNotImplemented)
}
}
private func create(args: Any?, result: @escaping FlutterResult) {
guard let dict = args as? [String: Any],
let uri = dict["uri"] as? String,
let registry = textureRegistry
else {
result(badArgs("create"))
return
}
guard let url = URL(string: uri) else {
result(badArgs("create uri"))
return
}
let handle = nextHandle
nextHandle += 1
let instance = UxVideoPlayerInstance(handle: handle, registry: registry)
instance.onEvent = { [weak self] payload in
DispatchQueue.main.async {
self?.eventSink?(payload)
}
}
instance.setSource(url: url)
instances[handle] = instance
result(
[
"handle": handle,
"textureId": instance.textureId,
] as [String: Any]
)
}
private func withInstance(
_ args: Any?,
result: @escaping FlutterResult,
body: (UxVideoPlayerInstance) -> Void,
) {
guard let dict = args as? [String: Any],
let handle = (dict["handle"] as? NSNumber)?.intValue
else {
result(badArgs("missing handle"))
return
}
guard let instance = instances[handle] else {
result(
FlutterError(
code: "disposed",
message: "no player for handle \(handle)",
details: nil,
)
)
return
}
body(instance)
}
private func badArgs(_ method: String) -> FlutterError {
FlutterError(code: "bad_args", message: method, details: nil)
}
}

View File

@@ -10,6 +10,7 @@ public class XPlugin: NSObject, FlutterPlugin {
ClipboardPlugin(), ClipboardPlugin(),
GalleryPlugin(), GalleryPlugin(),
CameraPlugin(), CameraPlugin(),
UxVideoPlayerPlugin(),
UrlPlugin(), UrlPlugin(),
] ]
for plugin in plugins { for plugin in plugins {

View File

@@ -14,17 +14,22 @@ Pod::Spec.new do |s|
# always picked up. The two are belt-and-suspenders — pod install # always picked up. The two are belt-and-suspenders — pod install
# primes the file set so CocoaPods globs it; the build phase keeps # primes the file set so CocoaPods globs it; the build phase keeps
# contents fresh on every subsequent Swift change. # contents fresh on every subsequent Swift change.
s.prepare_command = 'rm -rf Classes/Camera-shared && cp -R ../darwin/Camera Classes/Camera-shared' s.prepare_command = <<-CMD
rm -rf Classes/Camera-shared && cp -R ../darwin/Camera Classes/Camera-shared
rm -rf Classes/Video-shared && cp -R ../darwin/Video Classes/Video-shared
CMD
s.script_phases = [{ s.script_phases = [{
:name => 'Mirror darwin/Camera', :name => 'Mirror darwin/{Camera,Video}',
:execution_position => :before_compile, :execution_position => :before_compile,
:script => <<-CMD :script => <<-CMD
set -e set -e
SRC="${PODS_TARGET_SRCROOT}/../darwin/Camera" for MOD in Camera Video; do
DEST="${PODS_TARGET_SRCROOT}/Classes/Camera-shared" SRC="${PODS_TARGET_SRCROOT}/../darwin/${MOD}"
[ -d "$SRC" ] || exit 0 DEST="${PODS_TARGET_SRCROOT}/Classes/${MOD}-shared"
mkdir -p "$DEST" [ -d "$SRC" ] || continue
rsync -a --delete "$SRC/" "$DEST/" mkdir -p "$DEST"
rsync -a --delete "$SRC/" "$DEST/"
done
CMD CMD
}] }]
s.source_files = 'Classes/**/*.{swift,m}' s.source_files = 'Classes/**/*.{swift,m}'

View File

@@ -0,0 +1,199 @@
import 'dart:io' as io;
import 'dart:ui' show Size;
import 'package:flutter_test/flutter_test.dart';
import 'package:ux/testing.dart';
import 'package:ux/ux.dart';
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
late FakeXVideoPlayerBackend fake;
setUp(() {
fake = FakeXVideoPlayerBackend(
size: const Size(720, 1280),
duration: const Duration(seconds: 30),
);
XVideoPlayerBackend.instance = fake;
});
tearDown(() {
XVideoPlayerBackend.instance = MethodChannelXVideoPlayerBackend();
});
test('initialize creates the native instance, subscribes to events, '
'and populates size + duration', () async {
final ctrl = XVideoPlayerController.file(io.File('/tmp/fake.mp4'));
addTearDown(ctrl.dispose);
expect(ctrl.value.isInitialized, isFalse);
expect(ctrl.value.size, Size.zero);
await ctrl.initialize();
expect(fake.createCalls.single, 'file:///tmp/fake.mp4');
expect(fake.initializeCalls.single, 1);
expect(ctrl.handle, 1);
expect(ctrl.textureId, 101);
expect(ctrl.value.isInitialized, isTrue);
expect(ctrl.value.size, const Size(720, 1280));
expect(ctrl.value.duration, const Duration(seconds: 30));
expect(ctrl.value.aspectRatio, 720.0 / 1280.0);
});
test('play / pause flip isPlaying and forward to the backend', () async {
final ctrl = XVideoPlayerController.file(io.File('/tmp/fake.mp4'));
addTearDown(ctrl.dispose);
await ctrl.initialize();
await ctrl.play();
expect(fake.playCalls.single, 1);
expect(ctrl.value.isPlaying, isTrue);
await ctrl.pause();
expect(fake.pauseCalls.single, 1);
expect(ctrl.value.isPlaying, isFalse);
});
test('seekTo clamps to [0, duration] and forwards the clamped value',
() async {
final ctrl = XVideoPlayerController.file(io.File('/tmp/fake.mp4'));
addTearDown(ctrl.dispose);
await ctrl.initialize();
await ctrl.seekTo(const Duration(seconds: -5));
expect(fake.seekToCalls.last.position, Duration.zero);
expect(ctrl.value.position, Duration.zero);
await ctrl.seekTo(const Duration(minutes: 99));
expect(fake.seekToCalls.last.position, const Duration(seconds: 30));
expect(ctrl.value.position, const Duration(seconds: 30));
await ctrl.seekTo(const Duration(seconds: 10));
expect(fake.seekToCalls.last.position, const Duration(seconds: 10));
});
test('setLooping / setVolume / setPlaybackSpeed clamp and forward',
() async {
final ctrl = XVideoPlayerController.file(io.File('/tmp/fake.mp4'));
addTearDown(ctrl.dispose);
await ctrl.initialize();
await ctrl.setLooping(true);
expect(fake.setLoopingCalls.single, (handle: 1, loop: true));
expect(ctrl.value.isLooping, isTrue);
await ctrl.setVolume(1.5);
expect(fake.setVolumeCalls.single.volume, 1.0);
expect(ctrl.value.volume, 1.0);
await ctrl.setVolume(-1.0);
expect(fake.setVolumeCalls.last.volume, 0.0);
expect(ctrl.value.volume, 0.0);
await ctrl.setPlaybackSpeed(0.0);
expect(fake.setPlaybackSpeedCalls.last.rate, 0.25);
expect(ctrl.value.playbackSpeed, 0.25);
await ctrl.setPlaybackSpeed(10.0);
expect(fake.setPlaybackSpeedCalls.last.rate, 4.0);
expect(ctrl.value.playbackSpeed, 4.0);
});
test('stateChanged events flow into value (position, buffered, isPlaying)',
() async {
final ctrl = XVideoPlayerController.file(io.File('/tmp/fake.mp4'));
addTearDown(ctrl.dispose);
await ctrl.initialize();
fake.emitState(
1,
isPlaying: true,
position: const Duration(seconds: 5),
buffered: [
XDurationRange(Duration.zero, const Duration(seconds: 20)),
],
);
await Future<void>.delayed(Duration.zero);
expect(ctrl.value.isPlaying, isTrue);
expect(ctrl.value.position, const Duration(seconds: 5));
expect(ctrl.value.buffered, hasLength(1));
expect(ctrl.value.buffered.single.end, const Duration(seconds: 20));
});
test('sizeChanged events update value.size', () async {
final ctrl = XVideoPlayerController.file(io.File('/tmp/fake.mp4'));
addTearDown(ctrl.dispose);
await ctrl.initialize();
fake.emitSizeChanged(1, const Size(1280, 720));
await Future<void>.delayed(Duration.zero);
expect(ctrl.value.size, const Size(1280, 720));
expect(ctrl.value.aspectRatio, 1280.0 / 720.0);
});
test('error events set hasError and stop further state mutations', () async {
final ctrl = XVideoPlayerController.file(io.File('/tmp/fake.mp4'));
addTearDown(ctrl.dispose);
await ctrl.initialize();
fake.emitError(1, 'decode_failed', 'codec init failure');
await Future<void>.delayed(Duration.zero);
expect(ctrl.value.hasError, isTrue);
expect(ctrl.value.errorDescription, 'codec init failure');
});
test('completed event with looping=false flips isPlaying off; '
'with looping=true it stays on (native loop is silent)', () async {
final ctrl = XVideoPlayerController.file(io.File('/tmp/fake.mp4'));
addTearDown(ctrl.dispose);
await ctrl.initialize();
await ctrl.play();
expect(ctrl.value.isPlaying, isTrue);
fake.emitCompleted(1);
await Future<void>.delayed(Duration.zero);
expect(ctrl.value.isPlaying, isFalse);
await ctrl.play();
await ctrl.setLooping(true);
fake.emitCompleted(1);
await Future<void>.delayed(Duration.zero);
// Looping mode: completion is a no-op on the value; native handles
// the rewind. value.isPlaying remains true.
expect(ctrl.value.isPlaying, isTrue);
});
test('dispose is idempotent and prevents further operations', () async {
final ctrl = XVideoPlayerController.file(io.File('/tmp/fake.mp4'));
await ctrl.initialize();
await ctrl.dispose();
await ctrl.dispose(); // second dispose is a no-op
expect(fake.disposeCalls, [1]);
expect(
() => ctrl.play(),
throwsA(isA<XVideoPlayerException>()),
);
});
test('initialize failure tears down the native instance so a retry '
'is not blocked by a leaked claim', () async {
fake.initializeError = const XVideoPlayerException('decode_failed');
final ctrl = XVideoPlayerController.file(io.File('/tmp/fake.mp4'));
addTearDown(ctrl.dispose);
await expectLater(
ctrl.initialize(),
throwsA(isA<XVideoPlayerException>()),
);
// The controller has a handle from `create` even though `initialize`
// threw; dispose must have been called to release it.
expect(fake.disposeCalls.single, 1);
});
}