video_player + insets: native playback backend + animated viewPadding
- video_player: ExoPlayer (Android) / AVPlayer (iOS/macOS) backend with PixelBufferSink, method-channel adapter, Dart-side XVideoPlayer + testing fake. - insets: XInsets singleton + XAnimatedInsets widget lerp the system viewPadding over 220ms so OS bar visibility toggles (immersiveSticky <-> edgeToEdge) slide bottom-/top-anchored UI into place instead of snapping by the nav-bar / status-bar height.
This commit is contained in:
@@ -70,4 +70,10 @@ dependencies {
|
||||
implementation 'androidx.lifecycle:lifecycle-process:2.7.0'
|
||||
// Pure-Kotlin/Java QR decoder. ~470 KB jar, no Play Services dep.
|
||||
implementation 'com.google.zxing:core:3.5.3'
|
||||
// Media3 for ux.video_player. Same version line video_player_android
|
||||
// 2.9.5 pulls in (1.9.x) so the spike fork can coexist during the
|
||||
// Phase 2/3 migration without dragging in two ExoPlayer copies.
|
||||
def media3Version = '1.9.2'
|
||||
implementation "androidx.media3:media3-exoplayer:$media3Version"
|
||||
implementation "androidx.media3:media3-common:$media3Version"
|
||||
}
|
||||
|
||||
@@ -4,7 +4,9 @@ import io.flutter.embedding.engine.plugins.FlutterPlugin
|
||||
import io.flutter.embedding.engine.plugins.activity.ActivityAware
|
||||
import io.flutter.embedding.engine.plugins.activity.ActivityPluginBinding
|
||||
import io.swipelab.ux.camera.CameraPlugin
|
||||
import io.swipelab.ux.video.VideoPlayerPlugin
|
||||
|
||||
@androidx.annotation.OptIn(androidx.media3.common.util.UnstableApi::class)
|
||||
class XPlugin : FlutterPlugin, ActivityAware {
|
||||
private val plugins: List<NativePlugin> = listOf(
|
||||
KeyboardPlugin(),
|
||||
@@ -14,6 +16,7 @@ class XPlugin : FlutterPlugin, ActivityAware {
|
||||
ClipboardPlugin(),
|
||||
GalleryPlugin(),
|
||||
CameraPlugin(),
|
||||
VideoPlayerPlugin(),
|
||||
CrashPlugin(),
|
||||
UrlPlugin(),
|
||||
)
|
||||
|
||||
40
android/src/main/kotlin/io/swipelab/ux/video/Renderers.kt
Normal file
40
android/src/main/kotlin/io/swipelab/ux/video/Renderers.kt
Normal file
@@ -0,0 +1,40 @@
|
||||
package io.swipelab.ux.video
|
||||
|
||||
import android.content.Context
|
||||
import androidx.media3.common.util.UnstableApi
|
||||
import androidx.media3.exoplayer.DefaultRenderersFactory
|
||||
import androidx.media3.exoplayer.RenderersFactory
|
||||
import androidx.media3.exoplayer.mediacodec.MediaCodecInfo
|
||||
import androidx.media3.exoplayer.mediacodec.MediaCodecSelector
|
||||
|
||||
/// Renderer factory configured for Banlu. Two non-default tweaks:
|
||||
///
|
||||
/// - `setEnableDecoderFallback(true)` — Media3's default refuses to
|
||||
/// fall back if the primary decoder fails to start. On Huawei
|
||||
/// EMUI (Mate 20 Pro / LYA-L29 / API 29) the hardware AVC decoder
|
||||
/// `OMX.hisi.video.decoder.avc` fails codec start; without
|
||||
/// fallback the surface stays black.
|
||||
/// - `MediaCodecSelector` deprioritises `OMX.hisi.*` so Media3
|
||||
/// picks the working software decoder first
|
||||
/// (`c2.android.avc.decoder` on the affected device). The
|
||||
/// hardware decoder stays as a last-resort option for devices
|
||||
/// where it works correctly.
|
||||
@UnstableApi
|
||||
internal object Renderers {
|
||||
fun build(context: Context): RenderersFactory {
|
||||
return DefaultRenderersFactory(context)
|
||||
.setEnableDecoderFallback(true)
|
||||
.setMediaCodecSelector { mimeType, requiresSecure, requiresTunneling ->
|
||||
val infos = MediaCodecSelector.DEFAULT.getDecoderInfos(
|
||||
mimeType, requiresSecure, requiresTunneling,
|
||||
)
|
||||
val ok = ArrayList<MediaCodecInfo>(infos.size)
|
||||
val broken = ArrayList<MediaCodecInfo>()
|
||||
for (info in infos) {
|
||||
if (info.name.startsWith("OMX.hisi.")) broken.add(info) else ok.add(info)
|
||||
}
|
||||
ok.addAll(broken)
|
||||
ok
|
||||
}
|
||||
}
|
||||
}
|
||||
433
android/src/main/kotlin/io/swipelab/ux/video/VideoCompositor.kt
Normal file
433
android/src/main/kotlin/io/swipelab/ux/video/VideoCompositor.kt
Normal file
@@ -0,0 +1,433 @@
|
||||
package io.swipelab.ux.video
|
||||
|
||||
import android.graphics.SurfaceTexture
|
||||
import android.opengl.EGL14
|
||||
import android.opengl.EGLConfig
|
||||
import android.opengl.EGLContext
|
||||
import android.opengl.EGLDisplay
|
||||
import android.opengl.EGLSurface
|
||||
import android.opengl.GLES11Ext
|
||||
import android.opengl.GLES20
|
||||
import android.opengl.Matrix
|
||||
import android.os.Handler
|
||||
import android.os.HandlerThread
|
||||
import android.util.Log
|
||||
import android.view.Surface
|
||||
import androidx.media3.common.util.UnstableApi
|
||||
import java.nio.ByteBuffer
|
||||
import java.nio.ByteOrder
|
||||
import java.nio.FloatBuffer
|
||||
|
||||
/// GLES-based blit between an internal `SurfaceTexture` (codec input)
|
||||
/// and a Flutter `SurfaceTextureEntry`-backed `Surface` (output).
|
||||
///
|
||||
/// Why this exists: on Huawei EMUI 10 the software AVC decoder
|
||||
/// (`c2.android.avc.decoder`) writes YUV frames into a stride-padded
|
||||
/// buffer and sets a SurfaceTexture transform matrix that doesn't
|
||||
/// fully describe the crop — Flutter's `Texture` widget then samples
|
||||
/// the padding bytes and shows a green strip on the right edge.
|
||||
/// Platform-view paths sidestep this but lose smooth gallery
|
||||
/// animations; Media3's `DefaultVideoFrameProcessor` won't render
|
||||
/// into a SurfaceTexture-backed Surface (it drops every frame with
|
||||
/// "FinalShaderWrapper: Output surface and size not set"). So we run
|
||||
/// our own one-shader blit downstream of the codec.
|
||||
///
|
||||
/// Lifecycle:
|
||||
/// 1. [start] — EGL display + context + a tiny pbuffer surface so
|
||||
/// the context can be current before any output exists. Creates
|
||||
/// the input `SurfaceTexture` + Surface and hands the Surface to
|
||||
/// the caller via [onInputSurfaceReady].
|
||||
/// 2. [setDisplaySize] — first call (typically from
|
||||
/// `Player.Listener.onVideoSizeChanged`) sizes the Flutter
|
||||
/// output `SurfaceTexture` to match the codec exactly and
|
||||
/// creates the EGL window surface. Until then `drawFrame`
|
||||
/// consumes input frames (so the codec doesn't stall on a full
|
||||
/// queue) but does not paint — there is no output to paint to
|
||||
/// and no dimensions to invent.
|
||||
/// 3. [dispose] — tears down the render thread, GL, both EGL
|
||||
/// surfaces, and the context.
|
||||
@UnstableApi
|
||||
internal class VideoCompositor(
|
||||
private val outputSurface: Surface,
|
||||
/// Called once the input `SurfaceTexture` is ready and wrapped in a
|
||||
/// Surface. The caller (typically [VideoPlayerInstance]) hands this
|
||||
/// Surface to ExoPlayer via `setVideoSurface`. Fires on the render
|
||||
/// thread.
|
||||
private val onInputSurfaceReady: (Surface) -> Unit,
|
||||
/// Fires exactly once, on the render thread, after the first
|
||||
/// `eglSwapBuffers` lands a real frame on [outputSurface]. The
|
||||
/// player instance uses this to gate `initialize()` resolution so
|
||||
/// the gallery's thumb→texture swap happens against a populated
|
||||
/// texture, not an empty one (avoids the black flash on hero
|
||||
/// landing).
|
||||
private val onFirstFrame: (() -> Unit)? = null,
|
||||
/// Called when the codec's display dimensions land, with the same
|
||||
/// width / height [setDisplaySize] was called with. Lets the owning
|
||||
/// instance resize the Flutter `SurfaceTexture`'s `defaultBufferSize`
|
||||
/// in sync with the EGL window surface. Fires on the render thread.
|
||||
private val onOutputSized: ((Int, Int) -> Unit)? = null,
|
||||
) {
|
||||
private val thread = HandlerThread("ux.video.compositor", Thread.NORM_PRIORITY)
|
||||
private lateinit var handler: Handler
|
||||
|
||||
// GL state lives on the render thread.
|
||||
private var eglDisplay: EGLDisplay = EGL14.EGL_NO_DISPLAY
|
||||
private var eglConfig: EGLConfig? = null
|
||||
private var eglContext: EGLContext = EGL14.EGL_NO_CONTEXT
|
||||
private var eglPbufferSurface: EGLSurface = EGL14.EGL_NO_SURFACE
|
||||
private var eglWindowSurface: EGLSurface = EGL14.EGL_NO_SURFACE
|
||||
private var inputTextureId: Int = 0
|
||||
private var inputSurfaceTexture: SurfaceTexture? = null
|
||||
private var inputSurface: Surface? = null
|
||||
|
||||
private var program: Int = 0
|
||||
private var aPositionLoc: Int = -1
|
||||
private var aTexCoordLoc: Int = -1
|
||||
private var uStMatrixLoc: Int = -1
|
||||
private var uCropMatrixLoc: Int = -1
|
||||
|
||||
private val stMatrix = FloatArray(16).also { Matrix.setIdentityM(it, 0) }
|
||||
private val cropMatrix = FloatArray(16).also { Matrix.setIdentityM(it, 0) }
|
||||
|
||||
/// Codec-reported display dimensions. Zero until [setDisplaySize]
|
||||
/// has been called by the player listener.
|
||||
private var displayWidth: Int = 0
|
||||
private var displayHeight: Int = 0
|
||||
|
||||
private val quadVertices: FloatBuffer = ByteBuffer
|
||||
.allocateDirect(QUAD_COORDS.size * 4)
|
||||
.order(ByteOrder.nativeOrder())
|
||||
.asFloatBuffer()
|
||||
.apply { put(QUAD_COORDS).position(0) }
|
||||
|
||||
private val quadTexCoords: FloatBuffer = ByteBuffer
|
||||
.allocateDirect(QUAD_TEXCOORDS.size * 4)
|
||||
.order(ByteOrder.nativeOrder())
|
||||
.asFloatBuffer()
|
||||
.apply { put(QUAD_TEXCOORDS).position(0) }
|
||||
|
||||
private var disposed = false
|
||||
private var firstFrameDelivered = false
|
||||
|
||||
fun start() {
|
||||
thread.start()
|
||||
handler = Handler(thread.looper)
|
||||
handler.post {
|
||||
try {
|
||||
setupEgl()
|
||||
Log.i(TAG, "egl ready ctx=$eglContext pbuf=$eglPbufferSurface")
|
||||
setupProgram()
|
||||
Log.i(TAG, "program ready id=$program")
|
||||
setupInputSurfaceTexture()
|
||||
Log.i(TAG, "input ready tex=$inputTextureId surface=$inputSurface")
|
||||
onInputSurfaceReady(inputSurface!!)
|
||||
} catch (t: Throwable) {
|
||||
Log.e(TAG, "compositor.start failed", t)
|
||||
teardown()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun setDisplaySize(width: Int, height: Int) {
|
||||
if (disposed) return
|
||||
if (width <= 0 || height <= 0) return
|
||||
handler.post {
|
||||
val changed = width != displayWidth || height != displayHeight
|
||||
if (!changed) return@post
|
||||
displayWidth = width
|
||||
displayHeight = height
|
||||
onOutputSized?.invoke(width, height)
|
||||
Log.i(TAG, "setDisplaySize ${width}x$height window=$eglWindowSurface")
|
||||
if (eglWindowSurface == EGL14.EGL_NO_SURFACE) {
|
||||
try {
|
||||
createWindowSurface()
|
||||
Log.i(TAG, "window surface created $eglWindowSurface")
|
||||
// ExoPlayer in default `playWhenReady = false` decodes
|
||||
// exactly one preview frame and then pauses waiting for
|
||||
// `play()` — that frame is already sitting in the input
|
||||
// SurfaceTexture (we called `updateTexImage` on the
|
||||
// pre-window draw attempt). Without a follow-up draw the
|
||||
// first-frame signal never fires, `initialize()` never
|
||||
// resolves on the Dart side, the gallery never transitions
|
||||
// to ready and autoplay never triggers. Paint that cached
|
||||
// frame to the Flutter output now.
|
||||
drawFrame()
|
||||
} catch (t: Throwable) {
|
||||
Log.e(TAG, "createWindowSurface failed", t)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun dispose() {
|
||||
if (disposed) return
|
||||
disposed = true
|
||||
handler.post {
|
||||
teardown()
|
||||
thread.quitSafely()
|
||||
}
|
||||
}
|
||||
|
||||
// ---- render thread ---------------------------------------------------
|
||||
|
||||
private fun setupEgl() {
|
||||
eglDisplay = EGL14.eglGetDisplay(EGL14.EGL_DEFAULT_DISPLAY)
|
||||
if (eglDisplay == EGL14.EGL_NO_DISPLAY) error("eglGetDisplay failed")
|
||||
val version = IntArray(2)
|
||||
if (!EGL14.eglInitialize(eglDisplay, version, 0, version, 1)) {
|
||||
error("eglInitialize failed")
|
||||
}
|
||||
val configAttribs = intArrayOf(
|
||||
EGL14.EGL_RED_SIZE, 8,
|
||||
EGL14.EGL_GREEN_SIZE, 8,
|
||||
EGL14.EGL_BLUE_SIZE, 8,
|
||||
EGL14.EGL_ALPHA_SIZE, 8,
|
||||
EGL14.EGL_RENDERABLE_TYPE, EGL14.EGL_OPENGL_ES2_BIT,
|
||||
// Surface type covers both window (output) and pbuffer
|
||||
// (bootstrap) — same config serves both.
|
||||
EGL14.EGL_SURFACE_TYPE, EGL14.EGL_WINDOW_BIT or EGL14.EGL_PBUFFER_BIT,
|
||||
EGL14.EGL_NONE,
|
||||
)
|
||||
val configs = arrayOfNulls<EGLConfig>(1)
|
||||
val numConfigs = IntArray(1)
|
||||
if (!EGL14.eglChooseConfig(
|
||||
eglDisplay, configAttribs, 0, configs, 0, configs.size, numConfigs, 0,
|
||||
) || numConfigs[0] == 0
|
||||
) {
|
||||
error("eglChooseConfig failed")
|
||||
}
|
||||
eglConfig = configs[0]
|
||||
val contextAttribs = intArrayOf(
|
||||
EGL14.EGL_CONTEXT_CLIENT_VERSION, 2,
|
||||
EGL14.EGL_NONE,
|
||||
)
|
||||
eglContext = EGL14.eglCreateContext(
|
||||
eglDisplay, eglConfig, EGL14.EGL_NO_CONTEXT, contextAttribs, 0,
|
||||
)
|
||||
if (eglContext == EGL14.EGL_NO_CONTEXT) error("eglCreateContext failed")
|
||||
val pbufferAttribs = intArrayOf(
|
||||
EGL14.EGL_WIDTH, 1,
|
||||
EGL14.EGL_HEIGHT, 1,
|
||||
EGL14.EGL_NONE,
|
||||
)
|
||||
eglPbufferSurface = EGL14.eglCreatePbufferSurface(
|
||||
eglDisplay, eglConfig, pbufferAttribs, 0,
|
||||
)
|
||||
if (eglPbufferSurface == EGL14.EGL_NO_SURFACE) error("eglCreatePbufferSurface failed")
|
||||
if (!EGL14.eglMakeCurrent(
|
||||
eglDisplay, eglPbufferSurface, eglPbufferSurface, eglContext,
|
||||
)
|
||||
) {
|
||||
error("eglMakeCurrent(pbuffer) failed")
|
||||
}
|
||||
}
|
||||
|
||||
private fun createWindowSurface() {
|
||||
val config = eglConfig ?: error("createWindowSurface before setupEgl")
|
||||
val attribs = intArrayOf(EGL14.EGL_NONE)
|
||||
val surface = EGL14.eglCreateWindowSurface(
|
||||
eglDisplay, config, outputSurface, attribs, 0,
|
||||
)
|
||||
if (surface == EGL14.EGL_NO_SURFACE) error("eglCreateWindowSurface failed")
|
||||
eglWindowSurface = surface
|
||||
if (!EGL14.eglMakeCurrent(eglDisplay, surface, surface, eglContext)) {
|
||||
error("eglMakeCurrent(window) failed")
|
||||
}
|
||||
}
|
||||
|
||||
private fun setupProgram() {
|
||||
val vsh = compileShader(GLES20.GL_VERTEX_SHADER, VERTEX_SHADER)
|
||||
val fsh = compileShader(GLES20.GL_FRAGMENT_SHADER, FRAGMENT_SHADER)
|
||||
program = GLES20.glCreateProgram()
|
||||
GLES20.glAttachShader(program, vsh)
|
||||
GLES20.glAttachShader(program, fsh)
|
||||
GLES20.glLinkProgram(program)
|
||||
val linked = IntArray(1)
|
||||
GLES20.glGetProgramiv(program, GLES20.GL_LINK_STATUS, linked, 0)
|
||||
if (linked[0] == 0) {
|
||||
val info = GLES20.glGetProgramInfoLog(program)
|
||||
GLES20.glDeleteProgram(program)
|
||||
program = 0
|
||||
error("Program link failed: $info")
|
||||
}
|
||||
GLES20.glDeleteShader(vsh)
|
||||
GLES20.glDeleteShader(fsh)
|
||||
aPositionLoc = GLES20.glGetAttribLocation(program, "aPosition")
|
||||
aTexCoordLoc = GLES20.glGetAttribLocation(program, "aTexCoord")
|
||||
uStMatrixLoc = GLES20.glGetUniformLocation(program, "uStMatrix")
|
||||
uCropMatrixLoc = GLES20.glGetUniformLocation(program, "uCropMatrix")
|
||||
}
|
||||
|
||||
private fun setupInputSurfaceTexture() {
|
||||
val ids = IntArray(1)
|
||||
GLES20.glGenTextures(1, ids, 0)
|
||||
inputTextureId = ids[0]
|
||||
GLES20.glBindTexture(GLES11Ext.GL_TEXTURE_EXTERNAL_OES, inputTextureId)
|
||||
GLES20.glTexParameteri(
|
||||
GLES11Ext.GL_TEXTURE_EXTERNAL_OES,
|
||||
GLES20.GL_TEXTURE_MIN_FILTER, GLES20.GL_LINEAR,
|
||||
)
|
||||
GLES20.glTexParameteri(
|
||||
GLES11Ext.GL_TEXTURE_EXTERNAL_OES,
|
||||
GLES20.GL_TEXTURE_MAG_FILTER, GLES20.GL_LINEAR,
|
||||
)
|
||||
GLES20.glTexParameteri(
|
||||
GLES11Ext.GL_TEXTURE_EXTERNAL_OES,
|
||||
GLES20.GL_TEXTURE_WRAP_S, GLES20.GL_CLAMP_TO_EDGE,
|
||||
)
|
||||
GLES20.glTexParameteri(
|
||||
GLES11Ext.GL_TEXTURE_EXTERNAL_OES,
|
||||
GLES20.GL_TEXTURE_WRAP_T, GLES20.GL_CLAMP_TO_EDGE,
|
||||
)
|
||||
val st = SurfaceTexture(inputTextureId)
|
||||
st.setOnFrameAvailableListener({ _ ->
|
||||
if (disposed) return@setOnFrameAvailableListener
|
||||
handler.post { drawFrame() }
|
||||
}, handler)
|
||||
inputSurfaceTexture = st
|
||||
inputSurface = Surface(st)
|
||||
}
|
||||
|
||||
private fun drawFrame() {
|
||||
if (disposed) return
|
||||
val st = inputSurfaceTexture ?: return
|
||||
try {
|
||||
// Always consume the input frame so the codec's surface queue
|
||||
// doesn't fill up and stall, even before the EGL window
|
||||
// surface exists.
|
||||
st.updateTexImage()
|
||||
st.getTransformMatrix(stMatrix)
|
||||
} catch (t: Throwable) {
|
||||
Log.e(TAG, "updateTexImage failed", t)
|
||||
return
|
||||
}
|
||||
if (eglWindowSurface == EGL14.EGL_NO_SURFACE) {
|
||||
Log.i(TAG, "drawFrame skipped: no window surface yet")
|
||||
return
|
||||
}
|
||||
if (displayWidth <= 0 || displayHeight <= 0) return
|
||||
|
||||
GLES20.glViewport(0, 0, displayWidth, displayHeight)
|
||||
GLES20.glClearColor(0f, 0f, 0f, 1f)
|
||||
GLES20.glClear(GLES20.GL_COLOR_BUFFER_BIT)
|
||||
GLES20.glUseProgram(program)
|
||||
|
||||
GLES20.glActiveTexture(GLES20.GL_TEXTURE0)
|
||||
GLES20.glBindTexture(GLES11Ext.GL_TEXTURE_EXTERNAL_OES, inputTextureId)
|
||||
|
||||
GLES20.glUniformMatrix4fv(uStMatrixLoc, 1, false, stMatrix, 0)
|
||||
GLES20.glUniformMatrix4fv(uCropMatrixLoc, 1, false, cropMatrix, 0)
|
||||
|
||||
quadVertices.position(0)
|
||||
GLES20.glEnableVertexAttribArray(aPositionLoc)
|
||||
GLES20.glVertexAttribPointer(aPositionLoc, 2, GLES20.GL_FLOAT, false, 0, quadVertices)
|
||||
quadTexCoords.position(0)
|
||||
GLES20.glEnableVertexAttribArray(aTexCoordLoc)
|
||||
GLES20.glVertexAttribPointer(aTexCoordLoc, 2, GLES20.GL_FLOAT, false, 0, quadTexCoords)
|
||||
|
||||
GLES20.glDrawArrays(GLES20.GL_TRIANGLE_STRIP, 0, 4)
|
||||
|
||||
GLES20.glDisableVertexAttribArray(aPositionLoc)
|
||||
GLES20.glDisableVertexAttribArray(aTexCoordLoc)
|
||||
|
||||
EGL14.eglSwapBuffers(eglDisplay, eglWindowSurface)
|
||||
|
||||
if (!firstFrameDelivered) {
|
||||
firstFrameDelivered = true
|
||||
Log.i(TAG, "first frame delivered to Flutter texture")
|
||||
onFirstFrame?.invoke()
|
||||
}
|
||||
}
|
||||
|
||||
private fun teardown() {
|
||||
inputSurface?.release()
|
||||
inputSurface = null
|
||||
inputSurfaceTexture?.release()
|
||||
inputSurfaceTexture = null
|
||||
if (inputTextureId != 0) {
|
||||
GLES20.glDeleteTextures(1, intArrayOf(inputTextureId), 0)
|
||||
inputTextureId = 0
|
||||
}
|
||||
if (program != 0) {
|
||||
GLES20.glDeleteProgram(program)
|
||||
program = 0
|
||||
}
|
||||
if (eglDisplay != EGL14.EGL_NO_DISPLAY) {
|
||||
EGL14.eglMakeCurrent(
|
||||
eglDisplay,
|
||||
EGL14.EGL_NO_SURFACE, EGL14.EGL_NO_SURFACE,
|
||||
EGL14.EGL_NO_CONTEXT,
|
||||
)
|
||||
if (eglWindowSurface != EGL14.EGL_NO_SURFACE) {
|
||||
EGL14.eglDestroySurface(eglDisplay, eglWindowSurface)
|
||||
eglWindowSurface = EGL14.EGL_NO_SURFACE
|
||||
}
|
||||
if (eglPbufferSurface != EGL14.EGL_NO_SURFACE) {
|
||||
EGL14.eglDestroySurface(eglDisplay, eglPbufferSurface)
|
||||
eglPbufferSurface = EGL14.EGL_NO_SURFACE
|
||||
}
|
||||
if (eglContext != EGL14.EGL_NO_CONTEXT) {
|
||||
EGL14.eglDestroyContext(eglDisplay, eglContext)
|
||||
eglContext = EGL14.EGL_NO_CONTEXT
|
||||
}
|
||||
EGL14.eglTerminate(eglDisplay)
|
||||
eglDisplay = EGL14.EGL_NO_DISPLAY
|
||||
}
|
||||
}
|
||||
|
||||
private fun compileShader(type: Int, source: String): Int {
|
||||
val shader = GLES20.glCreateShader(type)
|
||||
GLES20.glShaderSource(shader, source)
|
||||
GLES20.glCompileShader(shader)
|
||||
val status = IntArray(1)
|
||||
GLES20.glGetShaderiv(shader, GLES20.GL_COMPILE_STATUS, status, 0)
|
||||
if (status[0] == 0) {
|
||||
val info = GLES20.glGetShaderInfoLog(shader)
|
||||
GLES20.glDeleteShader(shader)
|
||||
error("Shader compile failed: $info")
|
||||
}
|
||||
return shader
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val TAG = "UxVideoCompositor"
|
||||
|
||||
/// Full-screen triangle strip in clip space (4 verts, 2 triangles).
|
||||
private val QUAD_COORDS = floatArrayOf(
|
||||
-1f, -1f,
|
||||
1f, -1f,
|
||||
-1f, 1f,
|
||||
1f, 1f,
|
||||
)
|
||||
|
||||
private val QUAD_TEXCOORDS = floatArrayOf(
|
||||
0f, 0f,
|
||||
1f, 0f,
|
||||
0f, 1f,
|
||||
1f, 1f,
|
||||
)
|
||||
|
||||
private val VERTEX_SHADER = """
|
||||
attribute vec4 aPosition;
|
||||
attribute vec2 aTexCoord;
|
||||
uniform mat4 uStMatrix;
|
||||
uniform mat4 uCropMatrix;
|
||||
varying vec2 vTexCoord;
|
||||
void main() {
|
||||
gl_Position = aPosition;
|
||||
vec4 tc = uStMatrix * uCropMatrix * vec4(aTexCoord, 0.0, 1.0);
|
||||
vTexCoord = tc.xy;
|
||||
}
|
||||
""".trimIndent()
|
||||
|
||||
private val FRAGMENT_SHADER = """
|
||||
#extension GL_OES_EGL_image_external : require
|
||||
precision mediump float;
|
||||
uniform samplerExternalOES uTexture;
|
||||
varying vec2 vTexCoord;
|
||||
void main() {
|
||||
gl_FragColor = texture2D(uTexture, vTexCoord);
|
||||
}
|
||||
""".trimIndent()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,294 @@
|
||||
package io.swipelab.ux.video
|
||||
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.util.Log
|
||||
import android.view.Surface
|
||||
import androidx.media3.common.MediaItem
|
||||
import androidx.media3.common.PlaybackException
|
||||
import androidx.media3.common.Player
|
||||
import androidx.media3.common.VideoSize
|
||||
import androidx.media3.common.util.UnstableApi
|
||||
import androidx.media3.exoplayer.ExoPlayer
|
||||
import io.flutter.view.TextureRegistry
|
||||
|
||||
/// One per Dart-side `XVideoPlayerController`. Owns:
|
||||
/// - the [ExoPlayer] built with our renderer-factory (decoder
|
||||
/// fallback + `OMX.hisi.*` deprioritise)
|
||||
/// - a [VideoCompositor] that runs a one-shader GLES blit between
|
||||
/// the codec's output surface and the Flutter
|
||||
/// [TextureRegistry.SurfaceTextureEntry] the Dart-side
|
||||
/// `Texture(textureId:)` widget samples
|
||||
///
|
||||
/// Methods run on the main thread.
|
||||
@UnstableApi
|
||||
internal class VideoPlayerInstance(
|
||||
val handle: Int,
|
||||
private val context: Context,
|
||||
private val textureEntry: TextureRegistry.SurfaceTextureEntry,
|
||||
) {
|
||||
/// Pushed to Dart as `{event, handle, …}` payloads. Set by the plugin.
|
||||
var onEvent: ((Map<String, Any?>) -> Unit)? = null
|
||||
|
||||
val textureId: Long get() = textureEntry.id()
|
||||
|
||||
private val mainHandler = Handler(Looper.getMainLooper())
|
||||
private val player: ExoPlayer = ExoPlayer.Builder(context, Renderers.build(context)).build()
|
||||
|
||||
/// Wrapped around the Flutter `SurfaceTextureEntry`'s `SurfaceTexture`.
|
||||
/// We deliberately do NOT call `setDefaultBufferSize` here — that
|
||||
/// would lock the Flutter texture to an arbitrary aspect ratio
|
||||
/// before the codec reports the real one, causing a visible flicker
|
||||
/// at the moment the gallery flips from thumbnail to live texture.
|
||||
/// The compositor delays creating its EGL window surface (and thus
|
||||
/// allocating any backing buffer) until the codec's dimensions
|
||||
/// arrive via [onOutputSized] below — at which point we size the
|
||||
/// SurfaceTexture in the same render-thread tick.
|
||||
private val outputSurface: Surface = Surface(textureEntry.surfaceTexture())
|
||||
private val compositor = VideoCompositor(
|
||||
outputSurface = outputSurface,
|
||||
onInputSurfaceReady = { inputSurface ->
|
||||
mainHandler.post {
|
||||
if (disposed) {
|
||||
Log.i(TAG, "h$handle setVideoSurface skipped (disposed)")
|
||||
return@post
|
||||
}
|
||||
Log.i(TAG, "h$handle setVideoSurface=$inputSurface")
|
||||
player.setVideoSurface(inputSurface)
|
||||
}
|
||||
},
|
||||
onFirstFrame = {
|
||||
// Fires on the compositor's render thread.
|
||||
mainHandler.post {
|
||||
if (disposed) return@post
|
||||
Log.i(TAG, "h$handle firstFrameRendered")
|
||||
firstFrameRendered = true
|
||||
maybeResolvePrepare()
|
||||
}
|
||||
},
|
||||
onOutputSized = { width, height ->
|
||||
// Render thread — safe to touch the SurfaceTexture here.
|
||||
// Setting `defaultBufferSize` immediately before the
|
||||
// compositor calls `eglCreateWindowSurface` keeps the EGL
|
||||
// surface's natural dimensions matched to the codec.
|
||||
textureEntry.surfaceTexture().setDefaultBufferSize(width, height)
|
||||
},
|
||||
)
|
||||
|
||||
private var disposed = false
|
||||
private var firstFrameRendered = false
|
||||
private var stateReady = false
|
||||
private var pendingPrepare: ((Throwable?, VideoSize, Long) -> Unit)? = null
|
||||
private var lastReportedWidth: Int = -1
|
||||
private var lastReportedHeight: Int = -1
|
||||
private var lastReportedBufferedMs: Long = -1L
|
||||
private var lastIsPlaying: Boolean = false
|
||||
private var lastIsBuffering: Boolean = false
|
||||
|
||||
private val tickRunnable = object : Runnable {
|
||||
override fun run() {
|
||||
if (disposed) return
|
||||
emitPositionAndBuffer()
|
||||
mainHandler.postDelayed(this, POSITION_TICK_MS)
|
||||
}
|
||||
}
|
||||
|
||||
private val listener = object : Player.Listener {
|
||||
override fun onPlaybackStateChanged(state: Int) {
|
||||
val buffering = state == Player.STATE_BUFFERING
|
||||
if (buffering != lastIsBuffering) {
|
||||
lastIsBuffering = buffering
|
||||
emitStateChange(isBuffering = buffering)
|
||||
}
|
||||
if (state == Player.STATE_ENDED &&
|
||||
player.repeatMode != Player.REPEAT_MODE_ONE &&
|
||||
player.repeatMode != Player.REPEAT_MODE_ALL
|
||||
) {
|
||||
emit(mapOf("event" to "completed"))
|
||||
}
|
||||
}
|
||||
|
||||
override fun onIsPlayingChanged(isPlaying: Boolean) {
|
||||
if (isPlaying != lastIsPlaying) {
|
||||
lastIsPlaying = isPlaying
|
||||
emitStateChange(isPlaying = isPlaying)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onVideoSizeChanged(videoSize: VideoSize) {
|
||||
if (videoSize.width != lastReportedWidth || videoSize.height != lastReportedHeight) {
|
||||
lastReportedWidth = videoSize.width
|
||||
lastReportedHeight = videoSize.height
|
||||
Log.i(TAG, "h$handle onVideoSizeChanged ${videoSize.width}x${videoSize.height}")
|
||||
if (videoSize.width > 0 && videoSize.height > 0) {
|
||||
// Compositor sizes the Flutter SurfaceTexture and creates
|
||||
// its EGL window surface in one render-thread step (see
|
||||
// [VideoCompositor.setDisplaySize] + the `onOutputSized`
|
||||
// wiring above), so the Flutter `Texture` widget's first
|
||||
// visible frame already has correct natural dimensions.
|
||||
compositor.setDisplaySize(videoSize.width, videoSize.height)
|
||||
}
|
||||
emit(
|
||||
mapOf(
|
||||
"event" to "sizeChanged",
|
||||
"size" to mapOf(
|
||||
"width" to videoSize.width.toDouble(),
|
||||
"height" to videoSize.height.toDouble(),
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onPlayerError(error: PlaybackException) {
|
||||
emit(
|
||||
mapOf(
|
||||
"event" to "error",
|
||||
"code" to error.errorCodeName,
|
||||
"description" to (error.message ?: error.errorCodeName),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
init {
|
||||
player.addListener(listener)
|
||||
// Compositor allocates EGL + GLES on its own thread, then hands
|
||||
// back the input Surface for the codec via onInputSurfaceReady.
|
||||
compositor.start()
|
||||
}
|
||||
|
||||
fun setSource(uri: Uri) {
|
||||
check(!disposed)
|
||||
player.setMediaItem(MediaItem.fromUri(uri))
|
||||
}
|
||||
|
||||
fun prepare(onReady: (Throwable?, VideoSize, Long) -> Unit) {
|
||||
check(!disposed)
|
||||
pendingPrepare = onReady
|
||||
val prepListener = object : Player.Listener {
|
||||
override fun onPlaybackStateChanged(state: Int) {
|
||||
if (disposed) return
|
||||
if (state == Player.STATE_READY) {
|
||||
player.removeListener(this)
|
||||
stateReady = true
|
||||
maybeResolvePrepare()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onPlayerError(error: PlaybackException) {
|
||||
player.removeListener(this)
|
||||
val cb = pendingPrepare ?: return
|
||||
pendingPrepare = null
|
||||
cb(error, VideoSize.UNKNOWN, 0L)
|
||||
}
|
||||
}
|
||||
player.addListener(prepListener)
|
||||
player.prepare()
|
||||
}
|
||||
|
||||
/// Resolves the pending prepare future once *both* the ExoPlayer
|
||||
/// has reached `STATE_READY` AND the compositor has landed the
|
||||
/// first blitted frame on the Flutter output surface. The gallery
|
||||
/// uses this future to swap from the thumbnail to the live
|
||||
/// `Texture`; resolving early causes a black-flash on the hero
|
||||
/// landing.
|
||||
private fun maybeResolvePrepare() {
|
||||
val cb = pendingPrepare ?: return
|
||||
if (!stateReady || !firstFrameRendered) return
|
||||
pendingPrepare = null
|
||||
cb(null, player.videoSize, player.duration.coerceAtLeast(0L))
|
||||
mainHandler.post(tickRunnable)
|
||||
}
|
||||
|
||||
fun play() {
|
||||
if (disposed) return
|
||||
player.play()
|
||||
}
|
||||
|
||||
fun pause() {
|
||||
if (disposed) return
|
||||
player.pause()
|
||||
}
|
||||
|
||||
fun seekTo(positionMs: Long) {
|
||||
if (disposed) return
|
||||
player.seekTo(positionMs)
|
||||
emitPositionAndBuffer(forcePosition = positionMs)
|
||||
}
|
||||
|
||||
fun setLooping(loop: Boolean) {
|
||||
if (disposed) return
|
||||
player.repeatMode = if (loop) Player.REPEAT_MODE_ONE else Player.REPEAT_MODE_OFF
|
||||
}
|
||||
|
||||
fun setVolume(volume: Float) {
|
||||
if (disposed) return
|
||||
player.volume = volume.coerceIn(0f, 1f)
|
||||
}
|
||||
|
||||
fun setPlaybackSpeed(rate: Float) {
|
||||
if (disposed) return
|
||||
player.setPlaybackSpeed(rate.coerceIn(0.25f, 4f))
|
||||
}
|
||||
|
||||
fun dispose() {
|
||||
if (disposed) return
|
||||
disposed = true
|
||||
mainHandler.removeCallbacks(tickRunnable)
|
||||
player.removeListener(listener)
|
||||
player.release()
|
||||
// Compositor disposal posts to its own thread; safe to call after
|
||||
// player.release because the compositor stops drawing once the
|
||||
// input SurfaceTexture stops receiving frames.
|
||||
compositor.dispose()
|
||||
outputSurface.release()
|
||||
textureEntry.release()
|
||||
onEvent = null
|
||||
}
|
||||
|
||||
private fun emitPositionAndBuffer(forcePosition: Long? = null) {
|
||||
if (disposed) return
|
||||
val position = forcePosition ?: player.currentPosition.coerceAtLeast(0L)
|
||||
val bufferedMs = player.bufferedPosition.coerceAtLeast(0L)
|
||||
if (bufferedMs == lastReportedBufferedMs && forcePosition == null && !lastIsPlaying) {
|
||||
return
|
||||
}
|
||||
lastReportedBufferedMs = bufferedMs
|
||||
emit(
|
||||
mapOf(
|
||||
"event" to "stateChanged",
|
||||
"positionMs" to position,
|
||||
"buffered" to listOf(
|
||||
mapOf(
|
||||
"startMs" to 0L,
|
||||
"endMs" to bufferedMs,
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
private fun emitStateChange(isPlaying: Boolean? = null, isBuffering: Boolean? = null) {
|
||||
val payload = HashMap<String, Any?>()
|
||||
payload["event"] = "stateChanged"
|
||||
payload["positionMs"] = player.currentPosition.coerceAtLeast(0L)
|
||||
if (isPlaying != null) payload["isPlaying"] = isPlaying
|
||||
if (isBuffering != null) payload["isBuffering"] = isBuffering
|
||||
emit(payload)
|
||||
}
|
||||
|
||||
private fun emit(extras: Map<String, Any?>) {
|
||||
val payload = HashMap<String, Any?>(extras.size + 1)
|
||||
payload["handle"] = handle
|
||||
payload.putAll(extras)
|
||||
onEvent?.invoke(payload)
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val TAG = "UxVideoPlayer"
|
||||
private const val POSITION_TICK_MS = 100L
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,207 @@
|
||||
package io.swipelab.ux.video
|
||||
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import androidx.media3.common.util.UnstableApi
|
||||
import io.flutter.embedding.engine.plugins.FlutterPlugin
|
||||
import io.flutter.plugin.common.EventChannel
|
||||
import io.flutter.plugin.common.MethodCall
|
||||
import io.flutter.plugin.common.MethodChannel
|
||||
import io.flutter.view.TextureRegistry
|
||||
import io.swipelab.ux.NativePlugin
|
||||
|
||||
/// `ux/video` + `ux/video/events` registrar. Routes channel calls to
|
||||
/// per-handle [VideoPlayerInstance]s. Mirrors the shape of
|
||||
/// [io.swipelab.ux.camera.CameraPlugin].
|
||||
@UnstableApi
|
||||
class VideoPlayerPlugin :
|
||||
NativePlugin,
|
||||
MethodChannel.MethodCallHandler,
|
||||
EventChannel.StreamHandler {
|
||||
|
||||
private val main = Handler(Looper.getMainLooper())
|
||||
|
||||
private var context: Context? = null
|
||||
private var textureRegistry: TextureRegistry? = null
|
||||
private var methodChannel: MethodChannel? = null
|
||||
private var eventChannel: EventChannel? = null
|
||||
private var eventSink: EventChannel.EventSink? = null
|
||||
|
||||
private val instances = mutableMapOf<Int, VideoPlayerInstance>()
|
||||
private var nextHandle = 1
|
||||
|
||||
override fun onAttachedToEngine(binding: FlutterPlugin.FlutterPluginBinding) {
|
||||
context = binding.applicationContext
|
||||
textureRegistry = binding.textureRegistry
|
||||
|
||||
val mc = MethodChannel(binding.binaryMessenger, "ux/video")
|
||||
mc.setMethodCallHandler(this)
|
||||
methodChannel = mc
|
||||
|
||||
val ec = EventChannel(binding.binaryMessenger, "ux/video/events")
|
||||
ec.setStreamHandler(this)
|
||||
eventChannel = ec
|
||||
}
|
||||
|
||||
override fun onDetachedFromEngine(binding: FlutterPlugin.FlutterPluginBinding) {
|
||||
instances.values.forEach { it.dispose() }
|
||||
instances.clear()
|
||||
|
||||
methodChannel?.setMethodCallHandler(null)
|
||||
methodChannel = null
|
||||
eventChannel?.setStreamHandler(null)
|
||||
eventChannel = null
|
||||
eventSink = null
|
||||
context = null
|
||||
textureRegistry = null
|
||||
}
|
||||
|
||||
// MARK: - EventChannel
|
||||
|
||||
override fun onListen(arguments: Any?, events: EventChannel.EventSink?) {
|
||||
eventSink = events
|
||||
}
|
||||
|
||||
override fun onCancel(arguments: Any?) {
|
||||
eventSink = null
|
||||
}
|
||||
|
||||
// MARK: - MethodChannel
|
||||
|
||||
override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) {
|
||||
when (call.method) {
|
||||
"create" -> handleCreate(call, result)
|
||||
"initialize" -> handleInitialize(call, result)
|
||||
"dispose" -> handleDispose(call, result)
|
||||
"play" -> handlePlay(call, result)
|
||||
"pause" -> handlePause(call, result)
|
||||
"seekTo" -> handleSeekTo(call, result)
|
||||
"setLooping" -> handleSetLooping(call, result)
|
||||
"setVolume" -> handleSetVolume(call, result)
|
||||
"setPlaybackSpeed" -> handleSetPlaybackSpeed(call, result)
|
||||
else -> result.notImplemented()
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleCreate(call: MethodCall, result: MethodChannel.Result) {
|
||||
val args = call.arguments as? Map<*, *>
|
||||
?: return result.error("bad_args", "create", null)
|
||||
val uriRaw = args["uri"] as? String
|
||||
?: return result.error("bad_args", "create", null)
|
||||
val ctx = context ?: return result.error("no_context", "engine detached", null)
|
||||
val registry = textureRegistry
|
||||
?: return result.error("no_context", "no texture registry", null)
|
||||
|
||||
val textureEntry = registry.createSurfaceTexture()
|
||||
val handle = nextHandle++
|
||||
val instance = VideoPlayerInstance(handle, ctx, textureEntry)
|
||||
instance.onEvent = { payload ->
|
||||
main.post { eventSink?.success(payload) }
|
||||
}
|
||||
try {
|
||||
instance.setSource(Uri.parse(uriRaw))
|
||||
} catch (t: Throwable) {
|
||||
instance.dispose()
|
||||
return result.error("bad_args", t.localizedMessage, null)
|
||||
}
|
||||
instances[handle] = instance
|
||||
result.success(
|
||||
mapOf(
|
||||
"handle" to handle,
|
||||
"textureId" to instance.textureId,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
private fun handleInitialize(call: MethodCall, result: MethodChannel.Result) {
|
||||
withInstance(call, result) { instance ->
|
||||
instance.prepare { error, size, durationMs ->
|
||||
if (error != null) {
|
||||
result.error(
|
||||
"decode_failed",
|
||||
error.message ?: "ExoPlayer prepare failed",
|
||||
null,
|
||||
)
|
||||
return@prepare
|
||||
}
|
||||
result.success(
|
||||
mapOf(
|
||||
"size" to mapOf(
|
||||
"width" to size.width.toDouble(),
|
||||
"height" to size.height.toDouble(),
|
||||
),
|
||||
"durationMs" to durationMs,
|
||||
// Android rotation is applied by ExoPlayer's MediaCodec
|
||||
// configuration + the SurfaceTexture transform matrix our
|
||||
// compositor's GLES blit already honours, so the Flutter
|
||||
// `Texture` widget needs no additional rotation.
|
||||
"rotationQuarterTurns" to 0,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleDispose(call: MethodCall, result: MethodChannel.Result) {
|
||||
withInstance(call, result) { instance ->
|
||||
instances.remove(instance.handle)
|
||||
instance.dispose()
|
||||
result.success(null)
|
||||
}
|
||||
}
|
||||
|
||||
private fun handlePlay(call: MethodCall, result: MethodChannel.Result) {
|
||||
withInstance(call, result) { it.play(); result.success(null) }
|
||||
}
|
||||
|
||||
private fun handlePause(call: MethodCall, result: MethodChannel.Result) {
|
||||
withInstance(call, result) { it.pause(); result.success(null) }
|
||||
}
|
||||
|
||||
private fun handleSeekTo(call: MethodCall, result: MethodChannel.Result) {
|
||||
val args = call.arguments as? Map<*, *>
|
||||
?: return result.error("bad_args", "seekTo", null)
|
||||
val pos = (args["positionMs"] as? Number)?.toLong()
|
||||
?: return result.error("bad_args", "seekTo positionMs", null)
|
||||
withInstance(call, result) { it.seekTo(pos); result.success(null) }
|
||||
}
|
||||
|
||||
private fun handleSetLooping(call: MethodCall, result: MethodChannel.Result) {
|
||||
val args = call.arguments as? Map<*, *>
|
||||
?: return result.error("bad_args", "setLooping", null)
|
||||
val loop = args["loop"] as? Boolean ?: false
|
||||
withInstance(call, result) { it.setLooping(loop); result.success(null) }
|
||||
}
|
||||
|
||||
private fun handleSetVolume(call: MethodCall, result: MethodChannel.Result) {
|
||||
val args = call.arguments as? Map<*, *>
|
||||
?: return result.error("bad_args", "setVolume", null)
|
||||
val volume = (args["volume"] as? Number)?.toFloat()
|
||||
?: return result.error("bad_args", "setVolume volume", null)
|
||||
withInstance(call, result) { it.setVolume(volume); result.success(null) }
|
||||
}
|
||||
|
||||
private fun handleSetPlaybackSpeed(call: MethodCall, result: MethodChannel.Result) {
|
||||
val args = call.arguments as? Map<*, *>
|
||||
?: return result.error("bad_args", "setPlaybackSpeed", null)
|
||||
val rate = (args["rate"] as? Number)?.toFloat()
|
||||
?: return result.error("bad_args", "setPlaybackSpeed rate", null)
|
||||
withInstance(call, result) { it.setPlaybackSpeed(rate); result.success(null) }
|
||||
}
|
||||
|
||||
private inline fun withInstance(
|
||||
call: MethodCall,
|
||||
result: MethodChannel.Result,
|
||||
body: (VideoPlayerInstance) -> Unit,
|
||||
) {
|
||||
val args = call.arguments as? Map<*, *>
|
||||
?: return result.error("bad_args", "missing handle", null)
|
||||
val handle = (args["handle"] as? Number)?.toInt()
|
||||
?: return result.error("bad_args", "missing handle", null)
|
||||
val instance = instances[handle]
|
||||
?: return result.error("disposed", "no player for handle $handle", null)
|
||||
body(instance)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user