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'
|
implementation 'androidx.lifecycle:lifecycle-process:2.7.0'
|
||||||
// Pure-Kotlin/Java QR decoder. ~470 KB jar, no Play Services dep.
|
// Pure-Kotlin/Java QR decoder. ~470 KB jar, no Play Services dep.
|
||||||
implementation 'com.google.zxing:core:3.5.3'
|
implementation 'com.google.zxing:core:3.5.3'
|
||||||
|
// Media3 for ux.video_player. Same version line video_player_android
|
||||||
|
// 2.9.5 pulls in (1.9.x) so the spike fork can coexist during the
|
||||||
|
// Phase 2/3 migration without dragging in two ExoPlayer copies.
|
||||||
|
def media3Version = '1.9.2'
|
||||||
|
implementation "androidx.media3:media3-exoplayer:$media3Version"
|
||||||
|
implementation "androidx.media3:media3-common:$media3Version"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,9 @@ import io.flutter.embedding.engine.plugins.FlutterPlugin
|
|||||||
import io.flutter.embedding.engine.plugins.activity.ActivityAware
|
import io.flutter.embedding.engine.plugins.activity.ActivityAware
|
||||||
import io.flutter.embedding.engine.plugins.activity.ActivityPluginBinding
|
import io.flutter.embedding.engine.plugins.activity.ActivityPluginBinding
|
||||||
import io.swipelab.ux.camera.CameraPlugin
|
import io.swipelab.ux.camera.CameraPlugin
|
||||||
|
import io.swipelab.ux.video.VideoPlayerPlugin
|
||||||
|
|
||||||
|
@androidx.annotation.OptIn(androidx.media3.common.util.UnstableApi::class)
|
||||||
class XPlugin : FlutterPlugin, ActivityAware {
|
class XPlugin : FlutterPlugin, ActivityAware {
|
||||||
private val plugins: List<NativePlugin> = listOf(
|
private val plugins: List<NativePlugin> = listOf(
|
||||||
KeyboardPlugin(),
|
KeyboardPlugin(),
|
||||||
@@ -14,6 +16,7 @@ class XPlugin : FlutterPlugin, ActivityAware {
|
|||||||
ClipboardPlugin(),
|
ClipboardPlugin(),
|
||||||
GalleryPlugin(),
|
GalleryPlugin(),
|
||||||
CameraPlugin(),
|
CameraPlugin(),
|
||||||
|
VideoPlayerPlugin(),
|
||||||
CrashPlugin(),
|
CrashPlugin(),
|
||||||
UrlPlugin(),
|
UrlPlugin(),
|
||||||
)
|
)
|
||||||
|
|||||||
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(),
|
GalleryPlugin(),
|
||||||
CrashPlugin(),
|
CrashPlugin(),
|
||||||
CameraPlugin(),
|
CameraPlugin(),
|
||||||
|
UxVideoPlayerPlugin(),
|
||||||
UrlPlugin(),
|
UrlPlugin(),
|
||||||
]
|
]
|
||||||
for plugin in plugins {
|
for plugin in plugins {
|
||||||
|
|||||||
@@ -16,17 +16,22 @@ Pod::Spec.new do |s|
|
|||||||
# binary — the mirror would be stale. The build script phase
|
# binary — the mirror would be stale. The build script phase
|
||||||
# below re-runs the mirror before each compile to keep contents
|
# below re-runs the mirror before each compile to keep contents
|
||||||
# fresh.
|
# fresh.
|
||||||
s.prepare_command = 'rm -rf Classes/Camera-shared && cp -R ../darwin/Camera Classes/Camera-shared'
|
s.prepare_command = <<-CMD
|
||||||
|
rm -rf Classes/Camera-shared && cp -R ../darwin/Camera Classes/Camera-shared
|
||||||
|
rm -rf Classes/Video-shared && cp -R ../darwin/Video Classes/Video-shared
|
||||||
|
CMD
|
||||||
s.script_phases = [{
|
s.script_phases = [{
|
||||||
:name => 'Mirror darwin/Camera',
|
:name => 'Mirror darwin/{Camera,Video}',
|
||||||
:execution_position => :before_compile,
|
:execution_position => :before_compile,
|
||||||
:script => <<-CMD
|
:script => <<-CMD
|
||||||
set -e
|
set -e
|
||||||
SRC="${PODS_TARGET_SRCROOT}/../darwin/Camera"
|
for MOD in Camera Video; do
|
||||||
DEST="${PODS_TARGET_SRCROOT}/Classes/Camera-shared"
|
SRC="${PODS_TARGET_SRCROOT}/../darwin/${MOD}"
|
||||||
[ -d "$SRC" ] || exit 0
|
DEST="${PODS_TARGET_SRCROOT}/Classes/${MOD}-shared"
|
||||||
mkdir -p "$DEST"
|
[ -d "$SRC" ] || continue
|
||||||
rsync -a --delete "$SRC/" "$DEST/"
|
mkdir -p "$DEST"
|
||||||
|
rsync -a --delete "$SRC/" "$DEST/"
|
||||||
|
done
|
||||||
CMD
|
CMD
|
||||||
}]
|
}]
|
||||||
s.source_files = 'Classes/**/*.{swift,m}'
|
s.source_files = 'Classes/**/*.{swift,m}'
|
||||||
|
|||||||
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_camera.dart';
|
||||||
export 'src/testing/fake_gallery.dart';
|
export 'src/testing/fake_gallery.dart';
|
||||||
|
export 'src/testing/fake_video_player.dart';
|
||||||
export 'src/testing/text_golden.dart';
|
export 'src/testing/text_golden.dart';
|
||||||
|
|||||||
@@ -14,10 +14,15 @@ export 'src/camera/camera.dart';
|
|||||||
export 'src/camera/camera_backend.dart' show XCameraBackend, XCameraCreateResult, XCameraEvent, XCameraDeviceOrientationChanged, XCameraSessionError, XCameraSessionInterrupted, XCameraSessionResumed, XCameraDiagnostic, XCameraPreviewSizeChanged;
|
export 'src/camera/camera_backend.dart' show XCameraBackend, XCameraCreateResult, XCameraEvent, XCameraDeviceOrientationChanged, XCameraSessionError, XCameraSessionInterrupted, XCameraSessionResumed, XCameraDiagnostic, XCameraPreviewSizeChanged;
|
||||||
export 'src/camera/camera_channel.dart' show MethodChannelXCameraBackend;
|
export 'src/camera/camera_channel.dart' show MethodChannelXCameraBackend;
|
||||||
export 'src/camera/camera_preview.dart';
|
export 'src/camera/camera_preview.dart';
|
||||||
|
export 'src/video/x_video_player.dart';
|
||||||
|
export 'src/video/x_video_player_backend.dart' show XVideoPlayerBackend, XVideoPlayerCreateResult, XVideoPlayerMetadata, XVideoPlayerEvent, XVideoPlayerStateChanged, XVideoPlayerSizeChanged, XVideoPlayerCompleted, XVideoPlayerError;
|
||||||
|
export 'src/video/x_video_player_channel.dart' show MethodChannelXVideoPlayerBackend;
|
||||||
|
export 'src/video/x_video_player_view.dart' show XVideoPlayerView;
|
||||||
export 'src/clipboard.dart';
|
export 'src/clipboard.dart';
|
||||||
export 'src/file.dart';
|
export 'src/file.dart';
|
||||||
export 'src/gallery.dart';
|
export 'src/gallery.dart';
|
||||||
export 'src/keyboard.dart';
|
export 'src/keyboard.dart';
|
||||||
|
export 'src/view_padding.dart';
|
||||||
export 'src/auto_map.dart';
|
export 'src/auto_map.dart';
|
||||||
export 'src/scanner.dart';
|
export 'src/scanner.dart';
|
||||||
export 'src/url.dart';
|
export 'src/url.dart';
|
||||||
|
|||||||
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(),
|
ClipboardPlugin(),
|
||||||
GalleryPlugin(),
|
GalleryPlugin(),
|
||||||
CameraPlugin(),
|
CameraPlugin(),
|
||||||
|
UxVideoPlayerPlugin(),
|
||||||
UrlPlugin(),
|
UrlPlugin(),
|
||||||
]
|
]
|
||||||
for plugin in plugins {
|
for plugin in plugins {
|
||||||
|
|||||||
@@ -14,17 +14,22 @@ Pod::Spec.new do |s|
|
|||||||
# always picked up. The two are belt-and-suspenders — pod install
|
# always picked up. The two are belt-and-suspenders — pod install
|
||||||
# primes the file set so CocoaPods globs it; the build phase keeps
|
# primes the file set so CocoaPods globs it; the build phase keeps
|
||||||
# contents fresh on every subsequent Swift change.
|
# contents fresh on every subsequent Swift change.
|
||||||
s.prepare_command = 'rm -rf Classes/Camera-shared && cp -R ../darwin/Camera Classes/Camera-shared'
|
s.prepare_command = <<-CMD
|
||||||
|
rm -rf Classes/Camera-shared && cp -R ../darwin/Camera Classes/Camera-shared
|
||||||
|
rm -rf Classes/Video-shared && cp -R ../darwin/Video Classes/Video-shared
|
||||||
|
CMD
|
||||||
s.script_phases = [{
|
s.script_phases = [{
|
||||||
:name => 'Mirror darwin/Camera',
|
:name => 'Mirror darwin/{Camera,Video}',
|
||||||
:execution_position => :before_compile,
|
:execution_position => :before_compile,
|
||||||
:script => <<-CMD
|
:script => <<-CMD
|
||||||
set -e
|
set -e
|
||||||
SRC="${PODS_TARGET_SRCROOT}/../darwin/Camera"
|
for MOD in Camera Video; do
|
||||||
DEST="${PODS_TARGET_SRCROOT}/Classes/Camera-shared"
|
SRC="${PODS_TARGET_SRCROOT}/../darwin/${MOD}"
|
||||||
[ -d "$SRC" ] || exit 0
|
DEST="${PODS_TARGET_SRCROOT}/Classes/${MOD}-shared"
|
||||||
mkdir -p "$DEST"
|
[ -d "$SRC" ] || continue
|
||||||
rsync -a --delete "$SRC/" "$DEST/"
|
mkdir -p "$DEST"
|
||||||
|
rsync -a --delete "$SRC/" "$DEST/"
|
||||||
|
done
|
||||||
CMD
|
CMD
|
||||||
}]
|
}]
|
||||||
s.source_files = 'Classes/**/*.{swift,m}'
|
s.source_files = 'Classes/**/*.{swift,m}'
|
||||||
|
|||||||
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