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:
@@ -70,4 +70,10 @@ dependencies {
|
||||
implementation 'androidx.lifecycle:lifecycle-process:2.7.0'
|
||||
// Pure-Kotlin/Java QR decoder. ~470 KB jar, no Play Services dep.
|
||||
implementation 'com.google.zxing:core:3.5.3'
|
||||
// Media3 for ux.video_player. Same version line video_player_android
|
||||
// 2.9.5 pulls in (1.9.x) so the spike fork can coexist during the
|
||||
// Phase 2/3 migration without dragging in two ExoPlayer copies.
|
||||
def media3Version = '1.9.2'
|
||||
implementation "androidx.media3:media3-exoplayer:$media3Version"
|
||||
implementation "androidx.media3:media3-common:$media3Version"
|
||||
}
|
||||
|
||||
@@ -4,7 +4,9 @@ import io.flutter.embedding.engine.plugins.FlutterPlugin
|
||||
import io.flutter.embedding.engine.plugins.activity.ActivityAware
|
||||
import io.flutter.embedding.engine.plugins.activity.ActivityPluginBinding
|
||||
import io.swipelab.ux.camera.CameraPlugin
|
||||
import io.swipelab.ux.video.VideoPlayerPlugin
|
||||
|
||||
@androidx.annotation.OptIn(androidx.media3.common.util.UnstableApi::class)
|
||||
class XPlugin : FlutterPlugin, ActivityAware {
|
||||
private val plugins: List<NativePlugin> = listOf(
|
||||
KeyboardPlugin(),
|
||||
@@ -14,6 +16,7 @@ class XPlugin : FlutterPlugin, ActivityAware {
|
||||
ClipboardPlugin(),
|
||||
GalleryPlugin(),
|
||||
CameraPlugin(),
|
||||
VideoPlayerPlugin(),
|
||||
CrashPlugin(),
|
||||
UrlPlugin(),
|
||||
)
|
||||
|
||||
40
android/src/main/kotlin/io/swipelab/ux/video/Renderers.kt
Normal file
40
android/src/main/kotlin/io/swipelab/ux/video/Renderers.kt
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
433
android/src/main/kotlin/io/swipelab/ux/video/VideoCompositor.kt
Normal file
433
android/src/main/kotlin/io/swipelab/ux/video/VideoCompositor.kt
Normal 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()
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
55
darwin/Video/VideoPixelBufferSink.swift
Normal file
55
darwin/Video/VideoPixelBufferSink.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
431
darwin/Video/VideoPlayerInstance.swift
Normal file
431
darwin/Video/VideoPlayerInstance.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
|
||||
205
darwin/Video/VideoPlayerPlugin.swift
Normal file
205
darwin/Video/VideoPlayerPlugin.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
55
ios/Classes/Video-shared/VideoPixelBufferSink.swift
Normal file
55
ios/Classes/Video-shared/VideoPixelBufferSink.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
431
ios/Classes/Video-shared/VideoPlayerInstance.swift
Normal file
431
ios/Classes/Video-shared/VideoPlayerInstance.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
|
||||
205
ios/Classes/Video-shared/VideoPlayerPlugin.swift
Normal file
205
ios/Classes/Video-shared/VideoPlayerPlugin.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -14,6 +14,7 @@ public class XPlugin: NSObject, FlutterPlugin {
|
||||
GalleryPlugin(),
|
||||
CrashPlugin(),
|
||||
CameraPlugin(),
|
||||
UxVideoPlayerPlugin(),
|
||||
UrlPlugin(),
|
||||
]
|
||||
for plugin in plugins {
|
||||
|
||||
@@ -16,17 +16,22 @@ Pod::Spec.new do |s|
|
||||
# binary — the mirror would be stale. The build script phase
|
||||
# below re-runs the mirror before each compile to keep contents
|
||||
# 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 = [{
|
||||
:name => 'Mirror darwin/Camera',
|
||||
:name => 'Mirror darwin/{Camera,Video}',
|
||||
:execution_position => :before_compile,
|
||||
:script => <<-CMD
|
||||
set -e
|
||||
SRC="${PODS_TARGET_SRCROOT}/../darwin/Camera"
|
||||
DEST="${PODS_TARGET_SRCROOT}/Classes/Camera-shared"
|
||||
[ -d "$SRC" ] || exit 0
|
||||
mkdir -p "$DEST"
|
||||
rsync -a --delete "$SRC/" "$DEST/"
|
||||
for MOD in Camera Video; do
|
||||
SRC="${PODS_TARGET_SRCROOT}/../darwin/${MOD}"
|
||||
DEST="${PODS_TARGET_SRCROOT}/Classes/${MOD}-shared"
|
||||
[ -d "$SRC" ] || continue
|
||||
mkdir -p "$DEST"
|
||||
rsync -a --delete "$SRC/" "$DEST/"
|
||||
done
|
||||
CMD
|
||||
}]
|
||||
s.source_files = 'Classes/**/*.{swift,m}'
|
||||
|
||||
155
lib/src/testing/fake_video_player.dart
Normal file
155
lib/src/testing/fake_video_player.dart
Normal 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));
|
||||
}
|
||||
}
|
||||
325
lib/src/video/x_video_player.dart
Normal file
325
lib/src/video/x_video_player.dart
Normal 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',
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
132
lib/src/video/x_video_player_backend.dart
Normal file
132
lib/src/video/x_video_player_backend.dart
Normal 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;
|
||||
}
|
||||
155
lib/src/video/x_video_player_channel.dart
Normal file
155
lib/src/video/x_video_player_channel.dart
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
57
lib/src/video/x_video_player_view.dart
Normal file
57
lib/src/video/x_video_player_view.dart
Normal 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
132
lib/src/view_padding.dart
Normal 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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -7,4 +7,5 @@ library;
|
||||
|
||||
export 'src/testing/fake_camera.dart';
|
||||
export 'src/testing/fake_gallery.dart';
|
||||
export 'src/testing/fake_video_player.dart';
|
||||
export 'src/testing/text_golden.dart';
|
||||
|
||||
@@ -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_channel.dart' show MethodChannelXCameraBackend;
|
||||
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/file.dart';
|
||||
export 'src/gallery.dart';
|
||||
export 'src/keyboard.dart';
|
||||
export 'src/view_padding.dart';
|
||||
export 'src/auto_map.dart';
|
||||
export 'src/scanner.dart';
|
||||
export 'src/url.dart';
|
||||
|
||||
55
macos/Classes/Video-shared/VideoPixelBufferSink.swift
Normal file
55
macos/Classes/Video-shared/VideoPixelBufferSink.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
431
macos/Classes/Video-shared/VideoPlayerInstance.swift
Normal file
431
macos/Classes/Video-shared/VideoPlayerInstance.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
|
||||
205
macos/Classes/Video-shared/VideoPlayerPlugin.swift
Normal file
205
macos/Classes/Video-shared/VideoPlayerPlugin.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,7 @@ public class XPlugin: NSObject, FlutterPlugin {
|
||||
ClipboardPlugin(),
|
||||
GalleryPlugin(),
|
||||
CameraPlugin(),
|
||||
UxVideoPlayerPlugin(),
|
||||
UrlPlugin(),
|
||||
]
|
||||
for plugin in plugins {
|
||||
|
||||
@@ -14,17 +14,22 @@ Pod::Spec.new do |s|
|
||||
# always picked up. The two are belt-and-suspenders — pod install
|
||||
# primes the file set so CocoaPods globs it; the build phase keeps
|
||||
# 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 = [{
|
||||
:name => 'Mirror darwin/Camera',
|
||||
:name => 'Mirror darwin/{Camera,Video}',
|
||||
:execution_position => :before_compile,
|
||||
:script => <<-CMD
|
||||
set -e
|
||||
SRC="${PODS_TARGET_SRCROOT}/../darwin/Camera"
|
||||
DEST="${PODS_TARGET_SRCROOT}/Classes/Camera-shared"
|
||||
[ -d "$SRC" ] || exit 0
|
||||
mkdir -p "$DEST"
|
||||
rsync -a --delete "$SRC/" "$DEST/"
|
||||
for MOD in Camera Video; do
|
||||
SRC="${PODS_TARGET_SRCROOT}/../darwin/${MOD}"
|
||||
DEST="${PODS_TARGET_SRCROOT}/Classes/${MOD}-shared"
|
||||
[ -d "$SRC" ] || continue
|
||||
mkdir -p "$DEST"
|
||||
rsync -a --delete "$SRC/" "$DEST/"
|
||||
done
|
||||
CMD
|
||||
}]
|
||||
s.source_files = 'Classes/**/*.{swift,m}'
|
||||
|
||||
199
test/video/x_video_player_test.dart
Normal file
199
test/video/x_video_player_test.dart
Normal 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);
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user