From de4925adf9a50756e9f304dcf65556b5bc0061cc Mon Sep 17 00:00:00 2001 From: agra Date: Sat, 23 May 2026 15:57:15 +0300 Subject: [PATCH] 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. --- android/build.gradle | 6 + .../src/main/kotlin/io/swipelab/ux/XPlugin.kt | 3 + .../kotlin/io/swipelab/ux/video/Renderers.kt | 40 ++ .../io/swipelab/ux/video/VideoCompositor.kt | 433 ++++++++++++++++++ .../swipelab/ux/video/VideoPlayerInstance.kt | 294 ++++++++++++ .../io/swipelab/ux/video/VideoPlayerPlugin.kt | 207 +++++++++ darwin/Video/VideoPixelBufferSink.swift | 55 +++ darwin/Video/VideoPlayerInstance.swift | 431 +++++++++++++++++ darwin/Video/VideoPlayerPlugin.swift | 205 +++++++++ .../Video-shared/VideoPixelBufferSink.swift | 55 +++ .../Video-shared/VideoPlayerInstance.swift | 431 +++++++++++++++++ .../Video-shared/VideoPlayerPlugin.swift | 205 +++++++++ ios/Classes/XPlugin.swift | 1 + ios/ux.podspec | 19 +- lib/src/testing/fake_video_player.dart | 155 +++++++ lib/src/video/x_video_player.dart | 325 +++++++++++++ lib/src/video/x_video_player_backend.dart | 132 ++++++ lib/src/video/x_video_player_channel.dart | 155 +++++++ lib/src/video/x_video_player_view.dart | 57 +++ lib/src/view_padding.dart | 132 ++++++ lib/testing.dart | 1 + lib/ux.dart | 5 + .../Video-shared/VideoPixelBufferSink.swift | 55 +++ .../Video-shared/VideoPlayerInstance.swift | 431 +++++++++++++++++ .../Video-shared/VideoPlayerPlugin.swift | 205 +++++++++ macos/Classes/XPlugin.swift | 1 + macos/ux.podspec | 19 +- test/video/x_video_player_test.dart | 199 ++++++++ 28 files changed, 4243 insertions(+), 14 deletions(-) create mode 100644 android/src/main/kotlin/io/swipelab/ux/video/Renderers.kt create mode 100644 android/src/main/kotlin/io/swipelab/ux/video/VideoCompositor.kt create mode 100644 android/src/main/kotlin/io/swipelab/ux/video/VideoPlayerInstance.kt create mode 100644 android/src/main/kotlin/io/swipelab/ux/video/VideoPlayerPlugin.kt create mode 100644 darwin/Video/VideoPixelBufferSink.swift create mode 100644 darwin/Video/VideoPlayerInstance.swift create mode 100644 darwin/Video/VideoPlayerPlugin.swift create mode 100644 ios/Classes/Video-shared/VideoPixelBufferSink.swift create mode 100644 ios/Classes/Video-shared/VideoPlayerInstance.swift create mode 100644 ios/Classes/Video-shared/VideoPlayerPlugin.swift create mode 100644 lib/src/testing/fake_video_player.dart create mode 100644 lib/src/video/x_video_player.dart create mode 100644 lib/src/video/x_video_player_backend.dart create mode 100644 lib/src/video/x_video_player_channel.dart create mode 100644 lib/src/video/x_video_player_view.dart create mode 100644 lib/src/view_padding.dart create mode 100644 macos/Classes/Video-shared/VideoPixelBufferSink.swift create mode 100644 macos/Classes/Video-shared/VideoPlayerInstance.swift create mode 100644 macos/Classes/Video-shared/VideoPlayerPlugin.swift create mode 100644 test/video/x_video_player_test.dart diff --git a/android/build.gradle b/android/build.gradle index 1a57d7a..e86bca0 100644 --- a/android/build.gradle +++ b/android/build.gradle @@ -70,4 +70,10 @@ dependencies { implementation 'androidx.lifecycle:lifecycle-process:2.7.0' // Pure-Kotlin/Java QR decoder. ~470 KB jar, no Play Services dep. implementation 'com.google.zxing:core:3.5.3' + // Media3 for ux.video_player. Same version line video_player_android + // 2.9.5 pulls in (1.9.x) so the spike fork can coexist during the + // Phase 2/3 migration without dragging in two ExoPlayer copies. + def media3Version = '1.9.2' + implementation "androidx.media3:media3-exoplayer:$media3Version" + implementation "androidx.media3:media3-common:$media3Version" } diff --git a/android/src/main/kotlin/io/swipelab/ux/XPlugin.kt b/android/src/main/kotlin/io/swipelab/ux/XPlugin.kt index cba89b0..ee57f23 100644 --- a/android/src/main/kotlin/io/swipelab/ux/XPlugin.kt +++ b/android/src/main/kotlin/io/swipelab/ux/XPlugin.kt @@ -4,7 +4,9 @@ import io.flutter.embedding.engine.plugins.FlutterPlugin import io.flutter.embedding.engine.plugins.activity.ActivityAware import io.flutter.embedding.engine.plugins.activity.ActivityPluginBinding import io.swipelab.ux.camera.CameraPlugin +import io.swipelab.ux.video.VideoPlayerPlugin +@androidx.annotation.OptIn(androidx.media3.common.util.UnstableApi::class) class XPlugin : FlutterPlugin, ActivityAware { private val plugins: List = listOf( KeyboardPlugin(), @@ -14,6 +16,7 @@ class XPlugin : FlutterPlugin, ActivityAware { ClipboardPlugin(), GalleryPlugin(), CameraPlugin(), + VideoPlayerPlugin(), CrashPlugin(), UrlPlugin(), ) diff --git a/android/src/main/kotlin/io/swipelab/ux/video/Renderers.kt b/android/src/main/kotlin/io/swipelab/ux/video/Renderers.kt new file mode 100644 index 0000000..6f3c9ed --- /dev/null +++ b/android/src/main/kotlin/io/swipelab/ux/video/Renderers.kt @@ -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(infos.size) + val broken = ArrayList() + for (info in infos) { + if (info.name.startsWith("OMX.hisi.")) broken.add(info) else ok.add(info) + } + ok.addAll(broken) + ok + } + } +} diff --git a/android/src/main/kotlin/io/swipelab/ux/video/VideoCompositor.kt b/android/src/main/kotlin/io/swipelab/ux/video/VideoCompositor.kt new file mode 100644 index 0000000..e22a8b0 --- /dev/null +++ b/android/src/main/kotlin/io/swipelab/ux/video/VideoCompositor.kt @@ -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(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() + } +} diff --git a/android/src/main/kotlin/io/swipelab/ux/video/VideoPlayerInstance.kt b/android/src/main/kotlin/io/swipelab/ux/video/VideoPlayerInstance.kt new file mode 100644 index 0000000..fd241e1 --- /dev/null +++ b/android/src/main/kotlin/io/swipelab/ux/video/VideoPlayerInstance.kt @@ -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) -> 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() + 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) { + val payload = HashMap(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 + } +} diff --git a/android/src/main/kotlin/io/swipelab/ux/video/VideoPlayerPlugin.kt b/android/src/main/kotlin/io/swipelab/ux/video/VideoPlayerPlugin.kt new file mode 100644 index 0000000..45ce70f --- /dev/null +++ b/android/src/main/kotlin/io/swipelab/ux/video/VideoPlayerPlugin.kt @@ -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() + 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) + } +} diff --git a/darwin/Video/VideoPixelBufferSink.swift b/darwin/Video/VideoPixelBufferSink.swift new file mode 100644 index 0000000..b154b78 --- /dev/null +++ b/darwin/Video/VideoPixelBufferSink.swift @@ -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? { + 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) + } + } +} diff --git a/darwin/Video/VideoPlayerInstance.swift b/darwin/Video/VideoPlayerInstance.swift new file mode 100644 index 0000000..a513ee2 --- /dev/null +++ b/darwin/Video/VideoPlayerInstance.swift @@ -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) + } +} + diff --git a/darwin/Video/VideoPlayerPlugin.swift b/darwin/Video/VideoPlayerPlugin.swift new file mode 100644 index 0000000..cc99316 --- /dev/null +++ b/darwin/Video/VideoPlayerPlugin.swift @@ -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) + } +} diff --git a/ios/Classes/Video-shared/VideoPixelBufferSink.swift b/ios/Classes/Video-shared/VideoPixelBufferSink.swift new file mode 100644 index 0000000..b154b78 --- /dev/null +++ b/ios/Classes/Video-shared/VideoPixelBufferSink.swift @@ -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? { + 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) + } + } +} diff --git a/ios/Classes/Video-shared/VideoPlayerInstance.swift b/ios/Classes/Video-shared/VideoPlayerInstance.swift new file mode 100644 index 0000000..a513ee2 --- /dev/null +++ b/ios/Classes/Video-shared/VideoPlayerInstance.swift @@ -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) + } +} + diff --git a/ios/Classes/Video-shared/VideoPlayerPlugin.swift b/ios/Classes/Video-shared/VideoPlayerPlugin.swift new file mode 100644 index 0000000..cc99316 --- /dev/null +++ b/ios/Classes/Video-shared/VideoPlayerPlugin.swift @@ -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) + } +} diff --git a/ios/Classes/XPlugin.swift b/ios/Classes/XPlugin.swift index b53418b..4ff2541 100644 --- a/ios/Classes/XPlugin.swift +++ b/ios/Classes/XPlugin.swift @@ -14,6 +14,7 @@ public class XPlugin: NSObject, FlutterPlugin { GalleryPlugin(), CrashPlugin(), CameraPlugin(), + UxVideoPlayerPlugin(), UrlPlugin(), ] for plugin in plugins { diff --git a/ios/ux.podspec b/ios/ux.podspec index 086573d..7d79385 100644 --- a/ios/ux.podspec +++ b/ios/ux.podspec @@ -16,17 +16,22 @@ Pod::Spec.new do |s| # binary — the mirror would be stale. The build script phase # below re-runs the mirror before each compile to keep contents # fresh. - s.prepare_command = 'rm -rf Classes/Camera-shared && cp -R ../darwin/Camera Classes/Camera-shared' + s.prepare_command = <<-CMD +rm -rf Classes/Camera-shared && cp -R ../darwin/Camera Classes/Camera-shared +rm -rf Classes/Video-shared && cp -R ../darwin/Video Classes/Video-shared +CMD s.script_phases = [{ - :name => 'Mirror darwin/Camera', + :name => 'Mirror darwin/{Camera,Video}', :execution_position => :before_compile, :script => <<-CMD set -e -SRC="${PODS_TARGET_SRCROOT}/../darwin/Camera" -DEST="${PODS_TARGET_SRCROOT}/Classes/Camera-shared" -[ -d "$SRC" ] || exit 0 -mkdir -p "$DEST" -rsync -a --delete "$SRC/" "$DEST/" +for MOD in Camera Video; do + SRC="${PODS_TARGET_SRCROOT}/../darwin/${MOD}" + DEST="${PODS_TARGET_SRCROOT}/Classes/${MOD}-shared" + [ -d "$SRC" ] || continue + mkdir -p "$DEST" + rsync -a --delete "$SRC/" "$DEST/" +done CMD }] s.source_files = 'Classes/**/*.{swift,m}' diff --git a/lib/src/testing/fake_video_player.dart b/lib/src/testing/fake_video_player.dart new file mode 100644 index 0000000..6fcde4a --- /dev/null +++ b/lib/src/testing/fake_video_player.dart @@ -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 createCalls = []; + final List initializeCalls = []; + final List disposeCalls = []; + final List playCalls = []; + final List 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> _events = {}; + + StreamController _controllerFor(int handle) { + return _events.putIfAbsent( + handle, + () => StreamController.broadcast(), + ); + } + + // ---- XVideoPlayerBackend ---------------------------------------- + + @override + Future 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 initialize(int handle) async { + initializeCalls.add(handle); + if (initializeError != null) throw initializeError!; + return XVideoPlayerMetadata( + size: size, + duration: duration, + rotationQuarterTurns: rotationQuarterTurns, + ); + } + + @override + Future disposeInstance(int handle) async { + disposeCalls.add(handle); + final controller = _events.remove(handle); + await controller?.close(); + } + + @override + Future play(int handle) async { + playCalls.add(handle); + emitState(handle, isPlaying: true); + } + + @override + Future pause(int handle) async { + pauseCalls.add(handle); + emitState(handle, isPlaying: false); + } + + @override + Future seekTo(int handle, Duration position) async { + seekToCalls.add((handle: handle, position: position)); + emitState(handle, position: position); + } + + @override + Future setLooping(int handle, bool loop) async { + setLoopingCalls.add((handle: handle, loop: loop)); + } + + @override + Future setVolume(int handle, double volume) async { + setVolumeCalls.add((handle: handle, volume: volume)); + } + + @override + Future setPlaybackSpeed(int handle, double rate) async { + setPlaybackSpeedCalls.add((handle: handle, rate: rate)); + } + + @override + Stream events(int handle) => + _controllerFor(handle).stream; + + // ---- test helpers ------------------------------------------------ + + void emitState( + int handle, { + bool? isPlaying, + bool? isBuffering, + Duration? position, + List? 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)); + } +} diff --git a/lib/src/video/x_video_player.dart b/lib/src/video/x_video_player.dart new file mode 100644 index 0000000..f31cb67 --- /dev/null +++ b/lib/src/video/x_video_player.dart @@ -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 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? 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 { + 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? _eventsSub; + bool _disposed = false; + Completer? _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 initialize() { + _throwIfDisposed('initialize'); + final existing = _initCompleter; + if (existing != null) return existing.future; + final completer = Completer(); + _initCompleter = completer; + _initInternal().then( + (_) => completer.complete(), + onError: completer.completeError, + ); + return completer.future; + } + + Future _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 play() async { + final handle = _requireHandle('play'); + await XVideoPlayerBackend.instance.play(handle); + _setValue = _value.copyWith(isPlaying: true); + } + + Future pause() async { + final handle = _requireHandle('pause'); + await XVideoPlayerBackend.instance.pause(handle); + _setValue = _value.copyWith(isPlaying: false); + } + + Future 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 setLooping(bool loop) async { + final handle = _requireHandle('setLooping'); + await XVideoPlayerBackend.instance.setLooping(handle, loop); + _setValue = _value.copyWith(isLooping: loop); + } + + Future 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 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 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', + ); + } + } +} diff --git a/lib/src/video/x_video_player_backend.dart b/lib/src/video/x_video_player_backend.dart new file mode 100644 index 0000000..d129a31 --- /dev/null +++ b/lib/src/video/x_video_player_backend.dart @@ -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 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 initialize(int handle); + + /// Tear down the session. Releases the codec and any held surface / + /// texture. Safe to call repeatedly; no-op once disposed. + Future disposeInstance(int handle); + + Future play(int handle); + Future pause(int handle); + Future seekTo(int handle, Duration position); + Future setLooping(int handle, bool loop); + Future setVolume(int handle, double volume); + Future setPlaybackSpeed(int handle, double rate); + + /// Live event stream for [handle]. The controller subscribes during + /// [create] / [initialize] and unsubscribes on [disposeInstance]. + Stream 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? 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; +} diff --git a/lib/src/video/x_video_player_channel.dart b/lib/src/video/x_video_player_channel.dart new file mode 100644 index 0000000..b43d61d --- /dev/null +++ b/lib/src/video/x_video_player_channel.dart @@ -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 _rawEvents = + _eventsChannel.receiveBroadcastStream(); + + @override + Future 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 initialize(int handle) async { + final m = await _invokeMap('initialize', {'handle': handle}); + final s = (m['size'] as Map).cast(); + 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 disposeInstance(int handle) => + _invokeVoid('dispose', {'handle': handle}); + + @override + Future play(int handle) => + _invokeVoid('play', {'handle': handle}); + + @override + Future pause(int handle) => + _invokeVoid('pause', {'handle': handle}); + + @override + Future seekTo(int handle, Duration position) => + _invokeVoid('seekTo', { + 'handle': handle, + 'positionMs': position.inMilliseconds, + }); + + @override + Future setLooping(int handle, bool loop) => + _invokeVoid('setLooping', {'handle': handle, 'loop': loop}); + + @override + Future setVolume(int handle, double volume) => + _invokeVoid('setVolume', {'handle': handle, 'volume': volume}); + + @override + Future setPlaybackSpeed(int handle, double rate) => + _invokeVoid('setPlaybackSpeed', {'handle': handle, 'rate': rate}); + + @override + Stream events(int handle) { + return _rawEvents + .map((e) => (e as Map).cast()) + .where((m) => (m['handle'] as num).toInt() == handle) + .map(_decodeEvent); + } + + // ---- parsers / arg encoders ------------------------------------- + + static XVideoPlayerEvent _decodeEvent(Map 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(); + 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? _parseBuffered(Object? raw) { + if (raw == null) return null; + return [ + for (final r in (raw as List).cast>()) + XDurationRange( + Duration(milliseconds: (r['startMs'] as num).toInt()), + Duration(milliseconds: (r['endMs'] as num).toInt()), + ), + ]; + } + + // ---- channel adapter -------------------------------------------- + + Future> _invokeMap( + String method, [ + Map? args, + ]) async { + try { + final result = await _channel.invokeMethod(method, args); + return (result as Map).cast(); + } on PlatformException catch (e) { + throw XVideoPlayerException(e.code, e.message); + } + } + + Future _invokeVoid(String method, [Map? args]) async { + try { + await _channel.invokeMethod(method, args); + } on PlatformException catch (e) { + throw XVideoPlayerException(e.code, e.message); + } + } +} diff --git a/lib/src/video/x_video_player_view.dart b/lib/src/video/x_video_player_view.dart new file mode 100644 index 0000000..e75d4bc --- /dev/null +++ b/lib/src/video/x_video_player_view.dart @@ -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( + 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; + }, + ); + } +} diff --git a/lib/src/view_padding.dart b/lib/src/view_padding.dart new file mode 100644 index 0000000..3f18baa --- /dev/null +++ b/lib/src/view_padding.dart @@ -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 createState() => _XAnimatedInsetsState(); +} + +class _XAnimatedInsetsState extends State { + @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, + ); + } +} diff --git a/lib/testing.dart b/lib/testing.dart index 9a3b38f..ecf5a49 100644 --- a/lib/testing.dart +++ b/lib/testing.dart @@ -7,4 +7,5 @@ library; export 'src/testing/fake_camera.dart'; export 'src/testing/fake_gallery.dart'; +export 'src/testing/fake_video_player.dart'; export 'src/testing/text_golden.dart'; diff --git a/lib/ux.dart b/lib/ux.dart index 8de1e4f..3a7b13b 100644 --- a/lib/ux.dart +++ b/lib/ux.dart @@ -14,10 +14,15 @@ export 'src/camera/camera.dart'; export 'src/camera/camera_backend.dart' show XCameraBackend, XCameraCreateResult, XCameraEvent, XCameraDeviceOrientationChanged, XCameraSessionError, XCameraSessionInterrupted, XCameraSessionResumed, XCameraDiagnostic, XCameraPreviewSizeChanged; export 'src/camera/camera_channel.dart' show MethodChannelXCameraBackend; export 'src/camera/camera_preview.dart'; +export 'src/video/x_video_player.dart'; +export 'src/video/x_video_player_backend.dart' show XVideoPlayerBackend, XVideoPlayerCreateResult, XVideoPlayerMetadata, XVideoPlayerEvent, XVideoPlayerStateChanged, XVideoPlayerSizeChanged, XVideoPlayerCompleted, XVideoPlayerError; +export 'src/video/x_video_player_channel.dart' show MethodChannelXVideoPlayerBackend; +export 'src/video/x_video_player_view.dart' show XVideoPlayerView; export 'src/clipboard.dart'; export 'src/file.dart'; export 'src/gallery.dart'; export 'src/keyboard.dart'; +export 'src/view_padding.dart'; export 'src/auto_map.dart'; export 'src/scanner.dart'; export 'src/url.dart'; diff --git a/macos/Classes/Video-shared/VideoPixelBufferSink.swift b/macos/Classes/Video-shared/VideoPixelBufferSink.swift new file mode 100644 index 0000000..b154b78 --- /dev/null +++ b/macos/Classes/Video-shared/VideoPixelBufferSink.swift @@ -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? { + 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) + } + } +} diff --git a/macos/Classes/Video-shared/VideoPlayerInstance.swift b/macos/Classes/Video-shared/VideoPlayerInstance.swift new file mode 100644 index 0000000..a513ee2 --- /dev/null +++ b/macos/Classes/Video-shared/VideoPlayerInstance.swift @@ -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) + } +} + diff --git a/macos/Classes/Video-shared/VideoPlayerPlugin.swift b/macos/Classes/Video-shared/VideoPlayerPlugin.swift new file mode 100644 index 0000000..cc99316 --- /dev/null +++ b/macos/Classes/Video-shared/VideoPlayerPlugin.swift @@ -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) + } +} diff --git a/macos/Classes/XPlugin.swift b/macos/Classes/XPlugin.swift index 085f187..eada77b 100644 --- a/macos/Classes/XPlugin.swift +++ b/macos/Classes/XPlugin.swift @@ -10,6 +10,7 @@ public class XPlugin: NSObject, FlutterPlugin { ClipboardPlugin(), GalleryPlugin(), CameraPlugin(), + UxVideoPlayerPlugin(), UrlPlugin(), ] for plugin in plugins { diff --git a/macos/ux.podspec b/macos/ux.podspec index 620151b..cebea1d 100644 --- a/macos/ux.podspec +++ b/macos/ux.podspec @@ -14,17 +14,22 @@ Pod::Spec.new do |s| # always picked up. The two are belt-and-suspenders — pod install # primes the file set so CocoaPods globs it; the build phase keeps # contents fresh on every subsequent Swift change. - s.prepare_command = 'rm -rf Classes/Camera-shared && cp -R ../darwin/Camera Classes/Camera-shared' + s.prepare_command = <<-CMD +rm -rf Classes/Camera-shared && cp -R ../darwin/Camera Classes/Camera-shared +rm -rf Classes/Video-shared && cp -R ../darwin/Video Classes/Video-shared +CMD s.script_phases = [{ - :name => 'Mirror darwin/Camera', + :name => 'Mirror darwin/{Camera,Video}', :execution_position => :before_compile, :script => <<-CMD set -e -SRC="${PODS_TARGET_SRCROOT}/../darwin/Camera" -DEST="${PODS_TARGET_SRCROOT}/Classes/Camera-shared" -[ -d "$SRC" ] || exit 0 -mkdir -p "$DEST" -rsync -a --delete "$SRC/" "$DEST/" +for MOD in Camera Video; do + SRC="${PODS_TARGET_SRCROOT}/../darwin/${MOD}" + DEST="${PODS_TARGET_SRCROOT}/Classes/${MOD}-shared" + [ -d "$SRC" ] || continue + mkdir -p "$DEST" + rsync -a --delete "$SRC/" "$DEST/" +done CMD }] s.source_files = 'Classes/**/*.{swift,m}' diff --git a/test/video/x_video_player_test.dart b/test/video/x_video_player_test.dart new file mode 100644 index 0000000..360aa42 --- /dev/null +++ b/test/video/x_video_player_test.dart @@ -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.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.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.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.delayed(Duration.zero); + expect(ctrl.value.isPlaying, isFalse); + + await ctrl.play(); + await ctrl.setLooping(true); + fake.emitCompleted(1); + await Future.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()), + ); + }); + + 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()), + ); + + // The controller has a handle from `create` even though `initialize` + // threw; dispose must have been called to release it. + expect(fake.disposeCalls.single, 1); + }); +}