增加mp4的解析展示
This commit is contained in:
parent
f03b6c605f
commit
071ab9ffed
@ -88,6 +88,7 @@ android {
|
||||
dependencies {
|
||||
implementation("androidx.multidex:multidex:2.0.1")
|
||||
implementation("com.android.installreferrer:installreferrer:2.2")
|
||||
implementation("androidx.media3:media3-exoplayer:1.9.2")
|
||||
// implementation(platform("com.google.firebase:firebase-bom:34.0.0"))
|
||||
// implementation("com.google.firebase:firebase-auth")
|
||||
// implementation("com.google.firebase:firebase-core:16.0.8")
|
||||
|
||||
@ -0,0 +1,341 @@
|
||||
package com.org.yumiparty
|
||||
|
||||
import android.content.Context
|
||||
import android.graphics.PixelFormat
|
||||
import android.graphics.SurfaceTexture
|
||||
import android.opengl.GLES11Ext
|
||||
import android.opengl.GLES20
|
||||
import android.opengl.GLSurfaceView
|
||||
import android.opengl.Matrix
|
||||
import android.view.Surface
|
||||
import java.nio.ByteBuffer
|
||||
import java.nio.ByteOrder
|
||||
import java.nio.FloatBuffer
|
||||
import javax.microedition.khronos.egl.EGLConfig
|
||||
import javax.microedition.khronos.opengles.GL10
|
||||
|
||||
class GiftMp4GlVideoView(context: Context) :
|
||||
GLSurfaceView(context),
|
||||
GLSurfaceView.Renderer,
|
||||
SurfaceTexture.OnFrameAvailableListener {
|
||||
var onSurfaceReady: ((Surface) -> Unit)? = null
|
||||
|
||||
private val texMatrix = FloatArray(16)
|
||||
private val vertexBuffer = floatBufferOf(VERTICES)
|
||||
private val positionScale = floatArrayOf(1f, 1f)
|
||||
private var program = 0
|
||||
private var oesTexture = 0
|
||||
private var surfaceTexture: SurfaceTexture? = null
|
||||
private var surface: Surface? = null
|
||||
private var positionHandle = 0
|
||||
private var texCoordHandle = 0
|
||||
private var texMatrixHandle = 0
|
||||
private var textureHandle = 0
|
||||
private var hasAlphaMaskHandle = 0
|
||||
private var useTextureMatrixHandle = 0
|
||||
private var flipYHandle = 0
|
||||
private var positionScaleHandle = 0
|
||||
private var colorRectHandle = 0
|
||||
private var alphaRectHandle = 0
|
||||
private var viewWidth = 1
|
||||
private var viewHeight = 1
|
||||
private var contentAspectRatio = 1f
|
||||
private var hasAlphaMask = true
|
||||
private var useTextureMatrix = true
|
||||
private var flipY = true
|
||||
private val colorRect = floatArrayOf(0.5f, 0f, 0.5f, 1f)
|
||||
private val alphaRect = floatArrayOf(0f, 0f, 0.5f, 1f)
|
||||
|
||||
init {
|
||||
setEGLContextClientVersion(2)
|
||||
setEGLConfigChooser(8, 8, 8, 8, 16, 0)
|
||||
holder.setFormat(PixelFormat.TRANSLUCENT)
|
||||
setZOrderMediaOverlay(true)
|
||||
preserveEGLContextOnPause = true
|
||||
setRenderer(this)
|
||||
renderMode = RENDERMODE_WHEN_DIRTY
|
||||
Matrix.setIdentityM(texMatrix, 0)
|
||||
}
|
||||
|
||||
override fun onSurfaceCreated(gl: GL10?, config: EGLConfig?) {
|
||||
releaseSurfaceObjects()
|
||||
program = createProgram(VERTEX_SHADER, FRAGMENT_SHADER)
|
||||
positionHandle = GLES20.glGetAttribLocation(program, "aPosition")
|
||||
texCoordHandle = GLES20.glGetAttribLocation(program, "aTexCoord")
|
||||
texMatrixHandle = GLES20.glGetUniformLocation(program, "uTexMatrix")
|
||||
textureHandle = GLES20.glGetUniformLocation(program, "uTexture")
|
||||
hasAlphaMaskHandle = GLES20.glGetUniformLocation(program, "uHasAlphaMask")
|
||||
useTextureMatrixHandle = GLES20.glGetUniformLocation(program, "uUseTextureMatrix")
|
||||
flipYHandle = GLES20.glGetUniformLocation(program, "uFlipY")
|
||||
positionScaleHandle = GLES20.glGetUniformLocation(program, "uPositionScale")
|
||||
colorRectHandle = GLES20.glGetUniformLocation(program, "uColorRect")
|
||||
alphaRectHandle = GLES20.glGetUniformLocation(program, "uAlphaRect")
|
||||
oesTexture = createExternalTexture()
|
||||
surfaceTexture =
|
||||
SurfaceTexture(oesTexture).also {
|
||||
it.setOnFrameAvailableListener(this)
|
||||
}
|
||||
surface =
|
||||
Surface(surfaceTexture).also {
|
||||
post { onSurfaceReady?.invoke(it) }
|
||||
}
|
||||
GLES20.glClearColor(0f, 0f, 0f, 0f)
|
||||
GLES20.glEnable(GLES20.GL_BLEND)
|
||||
GLES20.glBlendFunc(GLES20.GL_ONE, GLES20.GL_ONE_MINUS_SRC_ALPHA)
|
||||
}
|
||||
|
||||
override fun onSurfaceChanged(gl: GL10?, width: Int, height: Int) {
|
||||
viewWidth = maxOf(1, width)
|
||||
viewHeight = maxOf(1, height)
|
||||
updatePositionScale()
|
||||
GLES20.glViewport(0, 0, width, height)
|
||||
}
|
||||
|
||||
override fun onDrawFrame(gl: GL10?) {
|
||||
GLES20.glClear(GLES20.GL_COLOR_BUFFER_BIT)
|
||||
val texture = surfaceTexture ?: return
|
||||
|
||||
texture.updateTexImage()
|
||||
texture.getTransformMatrix(texMatrix)
|
||||
|
||||
GLES20.glUseProgram(program)
|
||||
GLES20.glActiveTexture(GLES20.GL_TEXTURE0)
|
||||
GLES20.glBindTexture(GLES11Ext.GL_TEXTURE_EXTERNAL_OES, oesTexture)
|
||||
GLES20.glUniform1i(textureHandle, 0)
|
||||
GLES20.glUniform1i(hasAlphaMaskHandle, if (hasAlphaMask) 1 else 0)
|
||||
GLES20.glUniform1i(useTextureMatrixHandle, if (useTextureMatrix) 1 else 0)
|
||||
GLES20.glUniform1i(flipYHandle, if (flipY) 1 else 0)
|
||||
GLES20.glUniform2fv(positionScaleHandle, 1, positionScale, 0)
|
||||
GLES20.glUniform4fv(colorRectHandle, 1, colorRect, 0)
|
||||
GLES20.glUniform4fv(alphaRectHandle, 1, alphaRect, 0)
|
||||
GLES20.glUniformMatrix4fv(texMatrixHandle, 1, false, texMatrix, 0)
|
||||
|
||||
vertexBuffer.position(0)
|
||||
GLES20.glEnableVertexAttribArray(positionHandle)
|
||||
GLES20.glVertexAttribPointer(
|
||||
positionHandle,
|
||||
3,
|
||||
GLES20.GL_FLOAT,
|
||||
false,
|
||||
STRIDE_BYTES,
|
||||
vertexBuffer,
|
||||
)
|
||||
|
||||
vertexBuffer.position(3)
|
||||
GLES20.glEnableVertexAttribArray(texCoordHandle)
|
||||
GLES20.glVertexAttribPointer(
|
||||
texCoordHandle,
|
||||
2,
|
||||
GLES20.GL_FLOAT,
|
||||
false,
|
||||
STRIDE_BYTES,
|
||||
vertexBuffer,
|
||||
)
|
||||
|
||||
GLES20.glDrawArrays(GLES20.GL_TRIANGLE_STRIP, 0, 4)
|
||||
GLES20.glDisableVertexAttribArray(positionHandle)
|
||||
GLES20.glDisableVertexAttribArray(texCoordHandle)
|
||||
GLES20.glBindTexture(GLES11Ext.GL_TEXTURE_EXTERNAL_OES, 0)
|
||||
}
|
||||
|
||||
override fun onFrameAvailable(texture: SurfaceTexture?) {
|
||||
requestRender()
|
||||
}
|
||||
|
||||
fun setSourceLayout(
|
||||
alphaEnabled: Boolean,
|
||||
textureMatrixEnabled: Boolean,
|
||||
flipYEnabled: Boolean,
|
||||
aspectRatio: Float,
|
||||
colorSourceRect: FloatArray,
|
||||
alphaSourceRect: FloatArray,
|
||||
) {
|
||||
queueEvent {
|
||||
hasAlphaMask = alphaEnabled
|
||||
useTextureMatrix = textureMatrixEnabled
|
||||
flipY = flipYEnabled
|
||||
contentAspectRatio = aspectRatio.coerceAtLeast(0.01f)
|
||||
colorSourceRect.copyInto(colorRect, endIndex = minOf(colorSourceRect.size, colorRect.size))
|
||||
alphaSourceRect.copyInto(alphaRect, endIndex = minOf(alphaSourceRect.size, alphaRect.size))
|
||||
updatePositionScale()
|
||||
requestRender()
|
||||
}
|
||||
}
|
||||
|
||||
fun release() {
|
||||
queueEvent {
|
||||
releaseSurfaceObjects()
|
||||
if (program != 0) {
|
||||
GLES20.glDeleteProgram(program)
|
||||
program = 0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun updatePositionScale() {
|
||||
val viewAspect = viewWidth.toFloat() / viewHeight.toFloat()
|
||||
if (viewAspect > contentAspectRatio) {
|
||||
positionScale[0] = contentAspectRatio / viewAspect
|
||||
positionScale[1] = 1f
|
||||
} else {
|
||||
positionScale[0] = 1f
|
||||
positionScale[1] = viewAspect / contentAspectRatio
|
||||
}
|
||||
}
|
||||
|
||||
private fun releaseSurfaceObjects() {
|
||||
surface?.release()
|
||||
surface = null
|
||||
surfaceTexture?.release()
|
||||
surfaceTexture = null
|
||||
if (oesTexture != 0) {
|
||||
val textures = intArrayOf(oesTexture)
|
||||
GLES20.glDeleteTextures(1, textures, 0)
|
||||
oesTexture = 0
|
||||
}
|
||||
}
|
||||
|
||||
private fun createExternalTexture(): Int {
|
||||
val textures = IntArray(1)
|
||||
GLES20.glGenTextures(1, textures, 0)
|
||||
GLES20.glBindTexture(GLES11Ext.GL_TEXTURE_EXTERNAL_OES, textures[0])
|
||||
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,
|
||||
)
|
||||
return textures[0]
|
||||
}
|
||||
|
||||
private fun createProgram(vertexSource: String, fragmentSource: String): Int {
|
||||
val vertexShader = compileShader(GLES20.GL_VERTEX_SHADER, vertexSource)
|
||||
val fragmentShader = compileShader(GLES20.GL_FRAGMENT_SHADER, fragmentSource)
|
||||
val glProgram = GLES20.glCreateProgram()
|
||||
GLES20.glAttachShader(glProgram, vertexShader)
|
||||
GLES20.glAttachShader(glProgram, fragmentShader)
|
||||
GLES20.glLinkProgram(glProgram)
|
||||
val status = IntArray(1)
|
||||
GLES20.glGetProgramiv(glProgram, GLES20.GL_LINK_STATUS, status, 0)
|
||||
if (status[0] == 0) {
|
||||
val log = GLES20.glGetProgramInfoLog(glProgram)
|
||||
GLES20.glDeleteProgram(glProgram)
|
||||
throw IllegalStateException("OpenGL program link failed: $log")
|
||||
}
|
||||
GLES20.glDeleteShader(vertexShader)
|
||||
GLES20.glDeleteShader(fragmentShader)
|
||||
return glProgram
|
||||
}
|
||||
|
||||
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 log = GLES20.glGetShaderInfoLog(shader)
|
||||
GLES20.glDeleteShader(shader)
|
||||
throw IllegalStateException("OpenGL shader compile failed: $log")
|
||||
}
|
||||
return shader
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val FLOAT_BYTES = 4
|
||||
private const val STRIDE_BYTES = 5 * FLOAT_BYTES
|
||||
|
||||
private val VERTICES =
|
||||
floatArrayOf(
|
||||
-1f, -1f, 0f, 0f, 1f,
|
||||
1f, -1f, 0f, 1f, 1f,
|
||||
-1f, 1f, 0f, 0f, 0f,
|
||||
1f, 1f, 0f, 1f, 0f,
|
||||
)
|
||||
|
||||
private val VERTEX_SHADER =
|
||||
"""
|
||||
attribute vec4 aPosition;
|
||||
attribute vec2 aTexCoord;
|
||||
uniform vec2 uPositionScale;
|
||||
varying vec2 vTexCoord;
|
||||
|
||||
void main() {
|
||||
gl_Position = vec4(aPosition.x * uPositionScale.x, aPosition.y * uPositionScale.y, 0.0, 1.0);
|
||||
vTexCoord = aTexCoord;
|
||||
}
|
||||
""".trimIndent()
|
||||
|
||||
private val FRAGMENT_SHADER =
|
||||
"""
|
||||
#extension GL_OES_EGL_image_external : require
|
||||
precision mediump float;
|
||||
|
||||
uniform samplerExternalOES uTexture;
|
||||
uniform mat4 uTexMatrix;
|
||||
uniform int uHasAlphaMask;
|
||||
uniform int uUseTextureMatrix;
|
||||
uniform int uFlipY;
|
||||
uniform vec4 uColorRect;
|
||||
uniform vec4 uAlphaRect;
|
||||
varying vec2 vTexCoord;
|
||||
|
||||
vec2 mapTexture(vec2 uv) {
|
||||
if (uUseTextureMatrix == 0) {
|
||||
return uv;
|
||||
}
|
||||
return (uTexMatrix * vec4(uv, 0.0, 1.0)).xy;
|
||||
}
|
||||
|
||||
void main() {
|
||||
float sourceY = uFlipY == 1 ? 1.0 - vTexCoord.y : vTexCoord.y;
|
||||
vec2 colorUv = vec2(
|
||||
uColorRect.x + vTexCoord.x * uColorRect.z,
|
||||
uColorRect.y + sourceY * uColorRect.w
|
||||
);
|
||||
vec2 mappedColor = mapTexture(colorUv);
|
||||
vec4 color = texture2D(uTexture, mappedColor);
|
||||
|
||||
if (uHasAlphaMask == 0) {
|
||||
float alpha = clamp(color.a, 0.0, 1.0);
|
||||
gl_FragColor = vec4(color.rgb * alpha, alpha);
|
||||
return;
|
||||
}
|
||||
|
||||
vec2 alphaUv = vec2(
|
||||
uAlphaRect.x + vTexCoord.x * uAlphaRect.z,
|
||||
uAlphaRect.y + sourceY * uAlphaRect.w
|
||||
);
|
||||
vec2 mappedAlpha = mapTexture(alphaUv);
|
||||
vec3 mask = texture2D(uTexture, mappedAlpha).rgb;
|
||||
float alpha = clamp(dot(mask, vec3(0.299, 0.587, 0.114)), 0.0, 1.0);
|
||||
gl_FragColor = vec4(color.rgb * alpha, alpha);
|
||||
}
|
||||
""".trimIndent()
|
||||
|
||||
private fun floatBufferOf(values: FloatArray): FloatBuffer {
|
||||
return ByteBuffer
|
||||
.allocateDirect(values.size * FLOAT_BYTES)
|
||||
.order(ByteOrder.nativeOrder())
|
||||
.asFloatBuffer()
|
||||
.apply {
|
||||
put(values)
|
||||
position(0)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,815 @@
|
||||
package com.org.yumiparty
|
||||
|
||||
import android.content.Context
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.Color
|
||||
import android.media.MediaMetadataRetriever
|
||||
import android.net.Uri
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.os.SystemClock
|
||||
import android.util.Log
|
||||
import android.view.Surface
|
||||
import android.view.View
|
||||
import androidx.media3.common.MediaItem
|
||||
import androidx.media3.common.PlaybackException
|
||||
import androidx.media3.common.Player
|
||||
import androidx.media3.exoplayer.ExoPlayer
|
||||
import io.flutter.FlutterInjector
|
||||
import io.flutter.plugin.common.MethodCall
|
||||
import io.flutter.plugin.common.MethodChannel
|
||||
import io.flutter.plugin.common.MethodChannel.Result
|
||||
import io.flutter.plugin.platform.PlatformView
|
||||
import java.io.File
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
|
||||
class GiftMp4VideoPlatformView(
|
||||
context: Context,
|
||||
private val viewId: Int,
|
||||
params: Map<*, *>,
|
||||
messenger: io.flutter.plugin.common.BinaryMessenger,
|
||||
) : PlatformView {
|
||||
private val mainHandler = Handler(Looper.getMainLooper())
|
||||
private val events = MethodChannel(messenger, CHANNEL_NAME)
|
||||
private val glView = GiftMp4GlVideoView(context)
|
||||
private val player = ExoPlayer.Builder(context).build()
|
||||
private val token = (params["token"] as? Number)?.toInt() ?: 0
|
||||
private var released = false
|
||||
private var prepared = false
|
||||
private var started = false
|
||||
private var playerSurface: Surface? = null
|
||||
|
||||
init {
|
||||
val path = params["path"] as? String ?: ""
|
||||
val sourceType = params["sourceType"] as? String ?: "file"
|
||||
val muted = params["muted"] as? Boolean ?: false
|
||||
val source = GiftMp4Source(context, path, sourceType)
|
||||
Log.d(
|
||||
"GiftMp4Video",
|
||||
"create viewId=$viewId token=$token sourceType=$sourceType path=$path",
|
||||
)
|
||||
val sourceLayout = resolveSourceLayout(source)
|
||||
|
||||
registry[viewId] = this
|
||||
glView.setSourceLayout(
|
||||
sourceLayout.hasAlphaMask,
|
||||
sourceLayout.useTextureMatrix,
|
||||
sourceLayout.flipY,
|
||||
sourceLayout.contentAspectRatio,
|
||||
sourceLayout.colorRect.toFloatArray(),
|
||||
sourceLayout.alphaRect.toFloatArray(),
|
||||
)
|
||||
player.volume = if (muted) 0f else 1f
|
||||
player.repeatMode = Player.REPEAT_MODE_OFF
|
||||
player.addListener(
|
||||
object : Player.Listener {
|
||||
override fun onPlaybackStateChanged(playbackState: Int) {
|
||||
when (playbackState) {
|
||||
Player.STATE_READY -> {
|
||||
if (!started) {
|
||||
started = true
|
||||
Log.d(
|
||||
"GiftMp4Video",
|
||||
"started viewId=$viewId token=$token",
|
||||
)
|
||||
postEvent("started")
|
||||
}
|
||||
}
|
||||
Player.STATE_ENDED -> {
|
||||
Log.d(
|
||||
"GiftMp4Video",
|
||||
"completed viewId=$viewId token=$token",
|
||||
)
|
||||
postEvent("completed")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onPlayerError(error: PlaybackException) {
|
||||
Log.e(
|
||||
"GiftMp4Video",
|
||||
"failed viewId=$viewId token=$token code=${error.errorCode}",
|
||||
error,
|
||||
)
|
||||
postEvent(
|
||||
"failed",
|
||||
mapOf(
|
||||
"code" to error.errorCode,
|
||||
"message" to error.message,
|
||||
),
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
glView.onSurfaceReady = onSurfaceReady@{ surface ->
|
||||
if (released) {
|
||||
surface.release()
|
||||
return@onSurfaceReady
|
||||
}
|
||||
playerSurface?.release()
|
||||
playerSurface = surface
|
||||
player.setVideoSurface(surface)
|
||||
if (!prepared) {
|
||||
prepared = true
|
||||
player.setMediaItem(source.toMediaItem())
|
||||
player.prepare()
|
||||
player.seekTo(0)
|
||||
player.play()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun getView(): View = glView
|
||||
|
||||
fun setMute(muted: Boolean) {
|
||||
player.volume = if (muted) 0f else 1f
|
||||
}
|
||||
|
||||
fun stop() {
|
||||
player.stop()
|
||||
}
|
||||
|
||||
override fun dispose() {
|
||||
released = true
|
||||
registry.remove(viewId)
|
||||
glView.onSurfaceReady = null
|
||||
player.clearVideoSurface()
|
||||
player.release()
|
||||
playerSurface?.release()
|
||||
playerSurface = null
|
||||
glView.release()
|
||||
}
|
||||
|
||||
private fun postEvent(
|
||||
method: String,
|
||||
extra: Map<String, Any?> = emptyMap(),
|
||||
) {
|
||||
mainHandler.post {
|
||||
events.invokeMethod(
|
||||
method,
|
||||
mapOf("viewId" to viewId, "token" to token) + extra,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private class GiftMp4Source(
|
||||
private val context: Context,
|
||||
val path: String,
|
||||
sourceType: String,
|
||||
) {
|
||||
val type: SourceType =
|
||||
if (sourceType == "asset") SourceType.ASSET else SourceType.FILE
|
||||
val assetKey: String? =
|
||||
if (type == SourceType.ASSET) {
|
||||
FlutterInjector
|
||||
.instance()
|
||||
.flutterLoader()
|
||||
.getLookupKeyForAsset(path)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
val cacheKey: String = "${type.name}:${assetKey ?: path}"
|
||||
|
||||
fun toMediaItem(): MediaItem {
|
||||
return if (type == SourceType.ASSET) {
|
||||
MediaItem.fromUri(Uri.parse("asset:///$assetKey"))
|
||||
} else {
|
||||
MediaItem.fromUri(Uri.fromFile(File(path)))
|
||||
}
|
||||
}
|
||||
|
||||
fun readBytes(): ByteArray {
|
||||
return if (type == SourceType.ASSET) {
|
||||
context.assets.open(assetKey ?: path).use { it.readBytes() }
|
||||
} else {
|
||||
File(path).readBytes()
|
||||
}
|
||||
}
|
||||
|
||||
fun newMetadataRetriever(): MediaMetadataRetriever {
|
||||
val retriever = MediaMetadataRetriever()
|
||||
if (type == SourceType.ASSET) {
|
||||
context.assets.openFd(assetKey ?: path).use { descriptor ->
|
||||
retriever.setDataSource(
|
||||
descriptor.fileDescriptor,
|
||||
descriptor.startOffset,
|
||||
descriptor.length,
|
||||
)
|
||||
}
|
||||
} else {
|
||||
retriever.setDataSource(path)
|
||||
}
|
||||
return retriever
|
||||
}
|
||||
}
|
||||
|
||||
private enum class SourceType { ASSET, FILE }
|
||||
|
||||
private data class VideoSize(
|
||||
val width: Int,
|
||||
val height: Int,
|
||||
) {
|
||||
val aspectRatio: Float
|
||||
get() = if (height <= 0) 1f else width.toFloat() / height.toFloat()
|
||||
}
|
||||
|
||||
private data class SourceRect(
|
||||
val x: Float,
|
||||
val y: Float,
|
||||
val width: Float,
|
||||
val height: Float,
|
||||
) {
|
||||
fun toFloatArray(): FloatArray = floatArrayOf(x, y, width, height)
|
||||
}
|
||||
|
||||
private data class SourceLayout(
|
||||
val kind: String,
|
||||
val hasAlphaMask: Boolean,
|
||||
val useTextureMatrix: Boolean,
|
||||
val flipY: Boolean,
|
||||
val contentAspectRatio: Float,
|
||||
val colorRect: SourceRect,
|
||||
val alphaRect: SourceRect,
|
||||
)
|
||||
|
||||
private data class HalfStats(
|
||||
val saturation: Double,
|
||||
val grayError: Double,
|
||||
)
|
||||
|
||||
private enum class SplitOrientation { HORIZONTAL, VERTICAL }
|
||||
|
||||
private data class SplitAlphaCandidate(
|
||||
val orientation: SplitOrientation,
|
||||
val alphaFirst: Boolean,
|
||||
)
|
||||
|
||||
private data class SplitScore(
|
||||
var direction: Double = 0.0,
|
||||
var saturationGap: Double = 0.0,
|
||||
var maskSaturation: Double = 0.0,
|
||||
var maskGrayError: Double = 0.0,
|
||||
var frames: Int = 0,
|
||||
)
|
||||
|
||||
private data class PackedAlphaCandidate(
|
||||
val frameWidth: Int,
|
||||
val frameHeight: Int,
|
||||
val alphaStartX: Int,
|
||||
val alphaEndX: Int,
|
||||
)
|
||||
|
||||
companion object {
|
||||
const val VIEW_TYPE = "gift_mp4_video_view"
|
||||
const val CHANNEL_NAME = "com.org.yumiparty/gift_mp4_events"
|
||||
const val CONTROL_CHANNEL_NAME = "com.org.yumiparty/gift_mp4_control"
|
||||
|
||||
private val registry = mutableMapOf<Int, GiftMp4VideoPlatformView>()
|
||||
private val layoutCache = mutableMapOf<String, SourceLayout>()
|
||||
|
||||
fun handleControlMethod(call: MethodCall, result: Result) {
|
||||
val viewId = call.argument<Int>("viewId")
|
||||
val view = viewId?.let { registry[it] }
|
||||
if (view == null) {
|
||||
result.success(null)
|
||||
return
|
||||
}
|
||||
when (call.method) {
|
||||
"setMute" -> {
|
||||
view.setMute(call.argument<Boolean>("muted") ?: false)
|
||||
result.success(null)
|
||||
}
|
||||
"stop" -> {
|
||||
view.stop()
|
||||
result.success(null)
|
||||
}
|
||||
else -> result.notImplemented()
|
||||
}
|
||||
}
|
||||
|
||||
private fun resolveSourceLayout(source: GiftMp4Source): SourceLayout {
|
||||
layoutCache[source.cacheKey]?.let { return it }
|
||||
val startedAt = SystemClock.elapsedRealtime()
|
||||
val videoSize = probeVideoSize(source)
|
||||
val layout =
|
||||
parseVapcSourceLayout(source)
|
||||
// Top-right packed alpha can look like a gray half-frame,
|
||||
// so classify it before the looser split-alpha heuristic.
|
||||
?: detectPackedAlphaTopRightSourceLayout(source)
|
||||
?: detectSplitSourceLayout(source, videoSize)
|
||||
?: normalSourceLayout(videoSize)
|
||||
val elapsedMs = SystemClock.elapsedRealtime() - startedAt
|
||||
Log.d(
|
||||
"GiftMp4Video",
|
||||
"classified path=${source.path} mode=${layout.kind} elapsedMs=$elapsedMs",
|
||||
)
|
||||
layoutCache[source.cacheKey] = layout
|
||||
trimLayoutCache()
|
||||
return layout
|
||||
}
|
||||
|
||||
private fun trimLayoutCache() {
|
||||
while (layoutCache.size > 64) {
|
||||
layoutCache.remove(layoutCache.keys.first())
|
||||
}
|
||||
}
|
||||
|
||||
private fun probeVideoSize(source: GiftMp4Source): VideoSize {
|
||||
val retriever = source.newMetadataRetriever()
|
||||
return try {
|
||||
val width =
|
||||
retriever
|
||||
.extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_WIDTH)
|
||||
?.toIntOrNull()
|
||||
?: 1
|
||||
val height =
|
||||
retriever
|
||||
.extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_HEIGHT)
|
||||
?.toIntOrNull()
|
||||
?: 1
|
||||
val rotation =
|
||||
retriever
|
||||
.extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_ROTATION)
|
||||
?.toIntOrNull()
|
||||
?: 0
|
||||
if (rotation == 90 || rotation == 270) {
|
||||
VideoSize(height, width)
|
||||
} else {
|
||||
VideoSize(width, height)
|
||||
}
|
||||
} finally {
|
||||
retriever.release()
|
||||
}
|
||||
}
|
||||
|
||||
private fun normalSourceLayout(videoSize: VideoSize): SourceLayout {
|
||||
val fullFrame = SourceRect(0f, 0f, 1f, 1f)
|
||||
return SourceLayout(
|
||||
kind = "normal",
|
||||
hasAlphaMask = false,
|
||||
useTextureMatrix = true,
|
||||
flipY = true,
|
||||
contentAspectRatio = videoSize.aspectRatio,
|
||||
colorRect = fullFrame,
|
||||
alphaRect = fullFrame,
|
||||
)
|
||||
}
|
||||
|
||||
private fun splitSourceLayout(
|
||||
candidate: SplitAlphaCandidate,
|
||||
videoSize: VideoSize,
|
||||
): SourceLayout {
|
||||
val left = SourceRect(0f, 0f, 0.5f, 1f)
|
||||
val right = SourceRect(0.5f, 0f, 0.5f, 1f)
|
||||
val top = SourceRect(0f, 0.5f, 1f, 0.5f)
|
||||
val bottom = SourceRect(0f, 0f, 1f, 0.5f)
|
||||
val isHorizontal = candidate.orientation == SplitOrientation.HORIZONTAL
|
||||
val first = if (isHorizontal) left else top
|
||||
val second = if (isHorizontal) right else bottom
|
||||
val outputAspectRatio =
|
||||
if (isHorizontal) {
|
||||
videoSize.width.toFloat() / 2f / videoSize.height.toFloat()
|
||||
} else {
|
||||
videoSize.width.toFloat() / (videoSize.height.toFloat() / 2f)
|
||||
}
|
||||
return SourceLayout(
|
||||
kind = if (isHorizontal) "splitAlphaHorizontal" else "splitAlphaVertical",
|
||||
hasAlphaMask = true,
|
||||
useTextureMatrix = true,
|
||||
flipY = true,
|
||||
contentAspectRatio = outputAspectRatio,
|
||||
colorRect = if (candidate.alphaFirst) second else first,
|
||||
alphaRect = if (candidate.alphaFirst) first else second,
|
||||
)
|
||||
}
|
||||
|
||||
private fun parseVapcSourceLayout(source: GiftMp4Source): SourceLayout? {
|
||||
return try {
|
||||
val json = extractVapcJson(source.readBytes()) ?: return null
|
||||
val info = JSONObject(json).getJSONObject("info")
|
||||
val videoWidth = info.optDouble("videoW", 0.0).toFloat()
|
||||
val videoHeight = info.optDouble("videoH", 0.0).toFloat()
|
||||
val outputWidth = info.optDouble("w", 0.0).toFloat()
|
||||
val outputHeight = info.optDouble("h", 0.0).toFloat()
|
||||
if (videoWidth <= 0f || videoHeight <= 0f || outputWidth <= 0f || outputHeight <= 0f) {
|
||||
return null
|
||||
}
|
||||
|
||||
SourceLayout(
|
||||
kind = "vapc",
|
||||
hasAlphaMask = true,
|
||||
useTextureMatrix = false,
|
||||
flipY = false,
|
||||
contentAspectRatio = outputWidth / outputHeight,
|
||||
colorRect = parseNormalizedRect(info.getJSONArray("rgbFrame"), videoWidth, videoHeight),
|
||||
alphaRect = parseNormalizedRect(info.getJSONArray("aFrame"), videoWidth, videoHeight),
|
||||
)
|
||||
} catch (_: Exception) {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
private fun parseNormalizedRect(
|
||||
values: JSONArray,
|
||||
videoWidth: Float,
|
||||
videoHeight: Float,
|
||||
): SourceRect {
|
||||
return SourceRect(
|
||||
x = (values.getDouble(0).toFloat() / videoWidth).coerceIn(0f, 1f),
|
||||
y = (values.getDouble(1).toFloat() / videoHeight).coerceIn(0f, 1f),
|
||||
width = (values.getDouble(2).toFloat() / videoWidth).coerceIn(0f, 1f),
|
||||
height = (values.getDouble(3).toFloat() / videoHeight).coerceIn(0f, 1f),
|
||||
)
|
||||
}
|
||||
|
||||
private fun extractVapcJson(bytes: ByteArray): String? {
|
||||
val marker = byteArrayOf('v'.code.toByte(), 'a'.code.toByte(), 'p'.code.toByte(), 'c'.code.toByte())
|
||||
var searchStart = 0
|
||||
while (searchStart < bytes.size) {
|
||||
val markerIndex = indexOf(bytes, marker, searchStart)
|
||||
if (markerIndex < 0) return null
|
||||
val scanEnd = minOf(bytes.size, markerIndex + 128)
|
||||
val jsonStart =
|
||||
(markerIndex + marker.size until scanEnd)
|
||||
.firstOrNull { bytes[it] == '{'.code.toByte() }
|
||||
if (jsonStart != null) {
|
||||
val jsonEnd = findJsonEnd(bytes, jsonStart)
|
||||
if (jsonEnd != null) {
|
||||
return String(bytes, jsonStart, jsonEnd - jsonStart + 1, Charsets.UTF_8)
|
||||
}
|
||||
}
|
||||
searchStart = markerIndex + marker.size
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
private fun indexOf(
|
||||
bytes: ByteArray,
|
||||
marker: ByteArray,
|
||||
start: Int,
|
||||
): Int {
|
||||
var index = start
|
||||
while (index <= bytes.size - marker.size) {
|
||||
var matched = true
|
||||
for (offset in marker.indices) {
|
||||
if (bytes[index + offset] != marker[offset]) {
|
||||
matched = false
|
||||
break
|
||||
}
|
||||
}
|
||||
if (matched) return index
|
||||
index++
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
private fun findJsonEnd(
|
||||
bytes: ByteArray,
|
||||
start: Int,
|
||||
): Int? {
|
||||
var depth = 0
|
||||
var inString = false
|
||||
var escaped = false
|
||||
var index = start
|
||||
while (index < bytes.size) {
|
||||
val char = bytes[index].toInt().toChar()
|
||||
if (inString) {
|
||||
when {
|
||||
escaped -> escaped = false
|
||||
char == '\\' -> escaped = true
|
||||
char == '"' -> inString = false
|
||||
}
|
||||
} else {
|
||||
when (char) {
|
||||
'"' -> inString = true
|
||||
'{' -> depth++
|
||||
'}' -> {
|
||||
depth--
|
||||
if (depth == 0) return index
|
||||
}
|
||||
}
|
||||
}
|
||||
index++
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
private fun detectSplitSourceLayout(
|
||||
source: GiftMp4Source,
|
||||
videoSize: VideoSize,
|
||||
): SourceLayout? {
|
||||
val candidate = detectSplitAlpha(source) ?: return null
|
||||
return splitSourceLayout(candidate, videoSize)
|
||||
}
|
||||
|
||||
private fun detectSplitAlpha(source: GiftMp4Source): SplitAlphaCandidate? {
|
||||
val retriever = source.newMetadataRetriever()
|
||||
return try {
|
||||
val durationMs =
|
||||
retriever
|
||||
.extractMetadata(MediaMetadataRetriever.METADATA_KEY_DURATION)
|
||||
?.toLongOrNull()
|
||||
?: 0L
|
||||
val timesUs =
|
||||
listOf(0L, 500_000L, 1_000_000L, 2_000_000L)
|
||||
.filter { durationMs == 0L || it <= durationMs * 1000L }
|
||||
.ifEmpty { listOf(0L) }
|
||||
|
||||
val horizontalScore = SplitScore()
|
||||
val verticalScore = SplitScore()
|
||||
for (timeUs in timesUs) {
|
||||
val frame =
|
||||
retriever.getFrameAtTime(
|
||||
timeUs,
|
||||
MediaMetadataRetriever.OPTION_CLOSEST_SYNC,
|
||||
) ?: continue
|
||||
val horizontalStats = statsByHorizontalHalves(frame)
|
||||
val verticalStats = statsByVerticalHalves(frame)
|
||||
frame.recycle()
|
||||
addSplitScore(horizontalScore, horizontalStats.first, horizontalStats.second)
|
||||
addSplitScore(verticalScore, verticalStats.first, verticalStats.second)
|
||||
}
|
||||
val horizontalCandidate =
|
||||
splitCandidateFromScore(SplitOrientation.HORIZONTAL, horizontalScore)
|
||||
val verticalCandidate =
|
||||
splitCandidateFromScore(SplitOrientation.VERTICAL, verticalScore)
|
||||
listOfNotNull(horizontalCandidate, verticalCandidate)
|
||||
.maxByOrNull { it.second }
|
||||
?.first
|
||||
} catch (_: RuntimeException) {
|
||||
null
|
||||
} finally {
|
||||
retriever.release()
|
||||
}
|
||||
}
|
||||
|
||||
private fun detectPackedAlphaTopRightSourceLayout(
|
||||
source: GiftMp4Source,
|
||||
): SourceLayout? {
|
||||
val candidate = detectPackedAlphaTopRight(source) ?: return null
|
||||
val frameWidth = candidate.frameWidth.coerceAtLeast(1)
|
||||
val frameHeight = candidate.frameHeight.coerceAtLeast(1)
|
||||
val alphaStartX = candidate.alphaStartX.coerceIn(1, frameWidth - 1)
|
||||
val alphaEndX = candidate.alphaEndX.coerceIn(alphaStartX, frameWidth - 1)
|
||||
val colorWidth = alphaStartX
|
||||
val alphaWidth = (alphaEndX - alphaStartX + 1).coerceAtLeast(1)
|
||||
val alphaHeight =
|
||||
(frameHeight.toFloat() * alphaWidth.toFloat() / colorWidth.toFloat() + 0.5f)
|
||||
.toInt()
|
||||
.coerceIn(1, frameHeight)
|
||||
val alphaHeightRatio = alphaHeight.toFloat() / frameHeight.toFloat()
|
||||
|
||||
return SourceLayout(
|
||||
kind = "packedAlphaTopRight",
|
||||
hasAlphaMask = true,
|
||||
useTextureMatrix = true,
|
||||
flipY = true,
|
||||
contentAspectRatio = colorWidth.toFloat() / frameHeight.toFloat(),
|
||||
colorRect =
|
||||
SourceRect(
|
||||
x = 0f,
|
||||
y = 0f,
|
||||
width = colorWidth.toFloat() / frameWidth.toFloat(),
|
||||
height = 1f,
|
||||
),
|
||||
alphaRect =
|
||||
SourceRect(
|
||||
x = alphaStartX.toFloat() / frameWidth.toFloat(),
|
||||
y = 1f - alphaHeightRatio,
|
||||
width = alphaWidth.toFloat() / frameWidth.toFloat(),
|
||||
height = alphaHeightRatio,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
private fun detectPackedAlphaTopRight(source: GiftMp4Source): PackedAlphaCandidate? {
|
||||
val retriever = source.newMetadataRetriever()
|
||||
return try {
|
||||
val durationMs =
|
||||
retriever
|
||||
.extractMetadata(MediaMetadataRetriever.METADATA_KEY_DURATION)
|
||||
?.toLongOrNull()
|
||||
?: 0L
|
||||
val timesUs =
|
||||
listOf(0L, 500_000L, 1_000_000L, 2_000_000L, 3_000_000L)
|
||||
.filter { durationMs == 0L || it <= durationMs * 1000L }
|
||||
.ifEmpty { listOf(0L) }
|
||||
|
||||
val candidates = mutableListOf<PackedAlphaCandidate>()
|
||||
for (timeUs in timesUs) {
|
||||
val frame =
|
||||
retriever.getFrameAtTime(
|
||||
timeUs,
|
||||
MediaMetadataRetriever.OPTION_CLOSEST_SYNC,
|
||||
) ?: continue
|
||||
detectPackedAlphaTopRightInFrame(frame)?.let { candidates.add(it) }
|
||||
frame.recycle()
|
||||
}
|
||||
if (candidates.isEmpty()) {
|
||||
return null
|
||||
}
|
||||
|
||||
val frameWidth = medianInt(candidates.map { it.frameWidth })
|
||||
val frameHeight = medianInt(candidates.map { it.frameHeight })
|
||||
val alphaStartX = medianInt(candidates.map { it.alphaStartX })
|
||||
val alphaEndX = medianInt(candidates.map { it.alphaEndX })
|
||||
val alphaWidth = alphaEndX - alphaStartX + 1
|
||||
val alphaStartRatio = alphaStartX.toDouble() / frameWidth.toDouble()
|
||||
val alphaWidthRatio = alphaWidth.toDouble() / frameWidth.toDouble()
|
||||
val colorToAlphaRatio = alphaStartX.toDouble() / alphaWidth.toDouble()
|
||||
|
||||
if (alphaStartRatio !in 0.58..0.78 ||
|
||||
alphaWidthRatio !in 0.18..0.42 ||
|
||||
colorToAlphaRatio !in 1.45..2.65 ||
|
||||
alphaEndX < frameWidth * 0.82
|
||||
) {
|
||||
return null
|
||||
}
|
||||
PackedAlphaCandidate(
|
||||
frameWidth = frameWidth,
|
||||
frameHeight = frameHeight,
|
||||
alphaStartX = alphaStartX,
|
||||
alphaEndX = alphaEndX,
|
||||
)
|
||||
} catch (_: RuntimeException) {
|
||||
null
|
||||
} finally {
|
||||
retriever.release()
|
||||
}
|
||||
}
|
||||
|
||||
private fun detectPackedAlphaTopRightInFrame(bitmap: Bitmap): PackedAlphaCandidate? {
|
||||
val width = bitmap.width
|
||||
val height = bitmap.height
|
||||
if (width < 12 || height < 12) {
|
||||
return null
|
||||
}
|
||||
|
||||
val searchStart = (width * 0.54f).toInt().coerceIn(0, width - 1)
|
||||
val searchEnd = width
|
||||
val sampleHeight = (height * 0.58f).toInt().coerceIn(1, height)
|
||||
val stepY = maxOf(1, sampleHeight / 220)
|
||||
val rowSamples = ((sampleHeight + stepY - 1) / stepY).coerceAtLeast(1)
|
||||
val threshold = maxOf(8, (rowSamples * 0.32f).toInt())
|
||||
val grayColumns = BooleanArray(width)
|
||||
|
||||
var x = searchStart
|
||||
while (x < searchEnd) {
|
||||
var grayCount = 0
|
||||
var y = 0
|
||||
while (y < sampleHeight) {
|
||||
if (isLikelyAlphaMaskPixel(bitmap.getPixel(x, y))) {
|
||||
grayCount++
|
||||
}
|
||||
y += stepY
|
||||
}
|
||||
grayColumns[x] = grayCount >= threshold
|
||||
x++
|
||||
}
|
||||
|
||||
val minRangeWidth = maxOf(24, (width * 0.12f).toInt())
|
||||
var bestStart = -1
|
||||
var bestEnd = -1
|
||||
var index = searchStart
|
||||
while (index < searchEnd) {
|
||||
while (index < searchEnd && !grayColumns[index]) {
|
||||
index++
|
||||
}
|
||||
if (index >= searchEnd) {
|
||||
break
|
||||
}
|
||||
val start = index
|
||||
while (index < searchEnd && grayColumns[index]) {
|
||||
index++
|
||||
}
|
||||
val end = index - 1
|
||||
if (end - start + 1 >= minRangeWidth && end > bestEnd) {
|
||||
bestStart = start
|
||||
bestEnd = end
|
||||
}
|
||||
}
|
||||
|
||||
if (bestStart < 0 || bestEnd < 0) {
|
||||
return null
|
||||
}
|
||||
return PackedAlphaCandidate(
|
||||
frameWidth = width,
|
||||
frameHeight = height,
|
||||
alphaStartX = bestStart,
|
||||
alphaEndX = bestEnd,
|
||||
)
|
||||
}
|
||||
|
||||
private fun isLikelyAlphaMaskPixel(pixel: Int): Boolean {
|
||||
val red = Color.red(pixel)
|
||||
val green = Color.green(pixel)
|
||||
val blue = Color.blue(pixel)
|
||||
val maxChannel = maxOf(red, green, blue)
|
||||
if (maxChannel < 22) {
|
||||
return false
|
||||
}
|
||||
val minChannel = minOf(red, green, blue)
|
||||
val saturation =
|
||||
if (maxChannel == 0) 0.0 else (maxChannel - minChannel).toDouble() / maxChannel
|
||||
val grayError =
|
||||
(kotlin.math.abs(red - green) +
|
||||
kotlin.math.abs(green - blue) +
|
||||
kotlin.math.abs(blue - red)).toDouble() / (3.0 * 255.0)
|
||||
return saturation < 0.16 && grayError < 0.055
|
||||
}
|
||||
|
||||
private fun medianInt(values: List<Int>): Int {
|
||||
if (values.isEmpty()) {
|
||||
return 0
|
||||
}
|
||||
return values.sorted()[values.size / 2]
|
||||
}
|
||||
|
||||
private fun addSplitScore(
|
||||
score: SplitScore,
|
||||
first: HalfStats,
|
||||
second: HalfStats,
|
||||
) {
|
||||
val alphaStats = if (first.saturation < second.saturation) first else second
|
||||
score.direction += second.saturation - first.saturation
|
||||
score.saturationGap += kotlin.math.abs(second.saturation - first.saturation)
|
||||
score.maskSaturation += alphaStats.saturation
|
||||
score.maskGrayError += alphaStats.grayError
|
||||
score.frames++
|
||||
}
|
||||
|
||||
private fun splitCandidateFromScore(
|
||||
orientation: SplitOrientation,
|
||||
score: SplitScore,
|
||||
): Pair<SplitAlphaCandidate, Double>? {
|
||||
if (score.frames == 0) return null
|
||||
val averageSaturationGap = score.saturationGap / score.frames
|
||||
val averageMaskSaturation = score.maskSaturation / score.frames
|
||||
val averageMaskGrayError = score.maskGrayError / score.frames
|
||||
if (averageSaturationGap < 0.08 ||
|
||||
averageMaskSaturation > 0.28 ||
|
||||
averageMaskGrayError > 0.06
|
||||
) {
|
||||
return null
|
||||
}
|
||||
return SplitAlphaCandidate(
|
||||
orientation = orientation,
|
||||
alphaFirst = score.direction > 0.0,
|
||||
) to averageSaturationGap
|
||||
}
|
||||
|
||||
private fun statsByHorizontalHalves(bitmap: Bitmap): Pair<HalfStats, HalfStats> {
|
||||
val half = bitmap.width / 2
|
||||
return statsForRect(bitmap, 0, 0, half, bitmap.height) to
|
||||
statsForRect(bitmap, half, 0, bitmap.width, bitmap.height)
|
||||
}
|
||||
|
||||
private fun statsByVerticalHalves(bitmap: Bitmap): Pair<HalfStats, HalfStats> {
|
||||
val half = bitmap.height / 2
|
||||
return statsForRect(bitmap, 0, 0, bitmap.width, half) to
|
||||
statsForRect(bitmap, 0, half, bitmap.width, bitmap.height)
|
||||
}
|
||||
|
||||
private fun statsForRect(
|
||||
bitmap: Bitmap,
|
||||
startX: Int,
|
||||
startY: Int,
|
||||
endX: Int,
|
||||
endY: Int,
|
||||
): HalfStats {
|
||||
val stepX = maxOf(1, (endX - startX) / 80)
|
||||
val stepY = maxOf(1, (endY - startY) / 120)
|
||||
var saturationSum = 0.0
|
||||
var grayErrorSum = 0.0
|
||||
var count = 0
|
||||
var y = startY
|
||||
while (y < endY) {
|
||||
var x = startX
|
||||
while (x < endX) {
|
||||
val pixel = bitmap.getPixel(x, y)
|
||||
val red = Color.red(pixel) / 255.0
|
||||
val green = Color.green(pixel) / 255.0
|
||||
val blue = Color.blue(pixel) / 255.0
|
||||
val maxChannel = maxOf(red, green, blue)
|
||||
val minChannel = minOf(red, green, blue)
|
||||
saturationSum +=
|
||||
if (maxChannel == 0.0) 0.0 else (maxChannel - minChannel) / maxChannel
|
||||
grayErrorSum +=
|
||||
(kotlin.math.abs(red - green) +
|
||||
kotlin.math.abs(green - blue) +
|
||||
kotlin.math.abs(blue - red)) / 3.0
|
||||
count++
|
||||
x += stepX
|
||||
}
|
||||
y += stepY
|
||||
}
|
||||
if (count == 0) return HalfStats(saturation = 0.0, grayError = 0.0)
|
||||
return HalfStats(
|
||||
saturation = saturationSum / count,
|
||||
grayError = grayErrorSum / count,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,16 @@
|
||||
package com.org.yumiparty
|
||||
|
||||
import android.content.Context
|
||||
import io.flutter.plugin.common.BinaryMessenger
|
||||
import io.flutter.plugin.common.StandardMessageCodec
|
||||
import io.flutter.plugin.platform.PlatformView
|
||||
import io.flutter.plugin.platform.PlatformViewFactory
|
||||
|
||||
class GiftMp4VideoViewFactory(
|
||||
private val messenger: BinaryMessenger,
|
||||
) : PlatformViewFactory(StandardMessageCodec.INSTANCE) {
|
||||
override fun create(context: Context, viewId: Int, args: Any?): PlatformView {
|
||||
val params = args as? Map<*, *> ?: emptyMap<Any, Any>()
|
||||
return GiftMp4VideoPlatformView(context, viewId, params, messenger)
|
||||
}
|
||||
}
|
||||
@ -58,6 +58,19 @@ class MainActivity : FlutterActivity() {
|
||||
else -> result.notImplemented()
|
||||
}
|
||||
}
|
||||
MethodChannel(
|
||||
flutterEngine.dartExecutor.binaryMessenger,
|
||||
GiftMp4VideoPlatformView.CONTROL_CHANNEL_NAME
|
||||
).setMethodCallHandler { call, result ->
|
||||
GiftMp4VideoPlatformView.handleControlMethod(call, result)
|
||||
}
|
||||
flutterEngine
|
||||
.platformViewsController
|
||||
.registry
|
||||
.registerViewFactory(
|
||||
GiftMp4VideoPlatformView.VIEW_TYPE,
|
||||
GiftMp4VideoViewFactory(flutterEngine.dartExecutor.binaryMessenger)
|
||||
)
|
||||
}
|
||||
|
||||
override fun onNewIntent(intent: Intent) {
|
||||
|
||||
BIN
assets/debug/manager-packed-alpha.mp4
Normal file
BIN
assets/debug/manager-packed-alpha.mp4
Normal file
Binary file not shown.
@ -16,8 +16,8 @@ class SCVariant1Config implements AppConfig {
|
||||
@override
|
||||
String get apiHost => const String.fromEnvironment(
|
||||
'API_HOST',
|
||||
defaultValue: 'http://192.168.110.64:1100/',
|
||||
); // 默认连线上环境,本地调试可通过 --dart-define=API_HOST 覆盖 本地“http://192.168.110.64:1100/”,线上“https://jvapi.haiyihy.com/”
|
||||
defaultValue: 'http://192.168.110.66:1100/',
|
||||
); // 默认连线上环境,本地调试可通过 --dart-define=API_HOST 覆盖 本地“http://192.168.110.66:1100/”,线上“https://jvapi.haiyihy.com/”
|
||||
|
||||
@override
|
||||
String get imgHost => 'https://img.atuchat.com/'; // 测试图片服务器,上架前需替换为正式域名
|
||||
|
||||
83
lib/debug/gift_mp4_demo_main.dart
Normal file
83
lib/debug/gift_mp4_demo_main.dart
Normal file
@ -0,0 +1,83 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:yumi/ui_kit/widgets/room/effect/gift_mp4_effect_view.dart';
|
||||
|
||||
const _demoVideoPath = 'assets/debug/manager-packed-alpha.mp4';
|
||||
const _demoBackgroundColor = Color(0xFF7C3AED);
|
||||
|
||||
void main() {
|
||||
runApp(const GiftMp4DemoApp());
|
||||
}
|
||||
|
||||
class GiftMp4DemoApp extends StatelessWidget {
|
||||
const GiftMp4DemoApp({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return MaterialApp(
|
||||
debugShowCheckedModeBanner: false,
|
||||
theme: ThemeData.dark(useMaterial3: true),
|
||||
home: const GiftMp4DemoPage(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class GiftMp4DemoPage extends StatefulWidget {
|
||||
const GiftMp4DemoPage({super.key});
|
||||
|
||||
@override
|
||||
State<GiftMp4DemoPage> createState() => _GiftMp4DemoPageState();
|
||||
}
|
||||
|
||||
class _GiftMp4DemoPageState extends State<GiftMp4DemoPage> {
|
||||
late final SCGiftMp4Controller _controller;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_controller = SCGiftMp4Controller();
|
||||
_controller.setPlaybackListener(
|
||||
onComplete: () => unawaited(_replay()),
|
||||
onFailed: (error) => debugPrint('[GiftMp4Demo] failed: $error'),
|
||||
);
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) => _replay());
|
||||
}
|
||||
|
||||
Future<void> _replay() async {
|
||||
await _controller.playAsset(_demoVideoPath);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_controller.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
backgroundColor: _demoBackgroundColor,
|
||||
body: Stack(
|
||||
children: [
|
||||
Positioned.fill(child: GiftMp4EffectView(controller: _controller)),
|
||||
Positioned(
|
||||
left: 16,
|
||||
right: 16,
|
||||
bottom: 32,
|
||||
child: Row(
|
||||
children: [
|
||||
FilledButton(onPressed: _replay, child: const Text('Replay')),
|
||||
const SizedBox(width: 12),
|
||||
FilledButton.tonal(
|
||||
onPressed: _controller.stop,
|
||||
child: const Text('Stop'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -108,7 +108,9 @@ class _GiftPageState extends State<GiftPage> with TickerProviderStateMixin {
|
||||
Timer? _comboSendBatchTimer;
|
||||
bool _isComboSendBatchInFlight = false;
|
||||
|
||||
void _giftFxLog(String message) {}
|
||||
void _giftFxLog(String message) {
|
||||
debugPrint('[GiftFx][gift] $message');
|
||||
}
|
||||
|
||||
String _describeGiftSendError(Object error) {
|
||||
if (error is DioException) {
|
||||
|
||||
@ -63,7 +63,7 @@ class _BagsChatboxPageState
|
||||
Expanded(
|
||||
child:
|
||||
items.isEmpty && isLoading
|
||||
? const SCBagGridSkeleton()
|
||||
? const SCBagGridSkeleton(showTitle: false)
|
||||
: buildList(context),
|
||||
),
|
||||
selecteChatbox != null ? SizedBox(height: 10.w) : Container(),
|
||||
@ -234,19 +234,14 @@ class _BagsChatboxPageState
|
||||
alignment: Alignment.center,
|
||||
children: [
|
||||
Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
SizedBox(height: 25.w),
|
||||
netImage(
|
||||
url: res.propsResources?.cover ?? "",
|
||||
height: 35.w,
|
||||
width: 96.w,
|
||||
height: 52.w,
|
||||
fit: BoxFit.contain,
|
||||
),
|
||||
SizedBox(height: 8.w),
|
||||
buildStoreBagItemTitle(
|
||||
res.propsResources?.name ?? "",
|
||||
textColor: Colors.black,
|
||||
fontSize: 12.sp,
|
||||
),
|
||||
],
|
||||
),
|
||||
PositionedDirectional(
|
||||
@ -308,6 +303,11 @@ class _BagsChatboxPageState
|
||||
onTap: () {
|
||||
selecteChatbox = res;
|
||||
setState(() {});
|
||||
showStoreBagFullScreenResourcePreview(
|
||||
context,
|
||||
resource: res.propsResources,
|
||||
fit: BoxFit.contain,
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@ -69,7 +69,7 @@ class BagsDataCardPageState
|
||||
Expanded(
|
||||
child:
|
||||
items.isEmpty && isLoading
|
||||
? const SCBagGridSkeleton()
|
||||
? const SCBagGridSkeleton(showTitle: false)
|
||||
: buildList(context),
|
||||
),
|
||||
hasSelection ? SizedBox(height: 10.w) : const SizedBox.shrink(),
|
||||
@ -243,23 +243,17 @@ class BagsDataCardPageState
|
||||
alignment: Alignment.center,
|
||||
children: [
|
||||
Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
SizedBox(height: 25.w),
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(6.w),
|
||||
borderRadius: BorderRadius.circular(8.w),
|
||||
child: netImage(
|
||||
url: res.propsResources?.cover ?? "",
|
||||
width: 72.w,
|
||||
height: 50.w,
|
||||
width: 108.w,
|
||||
height: 75.w,
|
||||
fit: BoxFit.cover,
|
||||
),
|
||||
),
|
||||
SizedBox(height: 8.w),
|
||||
buildStoreBagItemTitle(
|
||||
res.propsResources?.name ?? "",
|
||||
textColor: Colors.white,
|
||||
fontSize: 12.sp,
|
||||
),
|
||||
],
|
||||
),
|
||||
PositionedDirectional(
|
||||
@ -321,6 +315,12 @@ class BagsDataCardPageState
|
||||
onTap: () {
|
||||
selectedDataCard = res;
|
||||
setState(() {});
|
||||
showStoreBagFullScreenResourcePreview(
|
||||
context,
|
||||
resource: res.propsResources,
|
||||
previewKind: SCStoreItemPreviewKind.dataCard,
|
||||
fit: BoxFit.contain,
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@ -47,7 +47,7 @@ class _BagsGiftPageState
|
||||
backgroundColor: Colors.transparent,
|
||||
body:
|
||||
items.isEmpty && isLoading
|
||||
? const SCBagGridSkeleton()
|
||||
? const SCBagGridSkeleton(showTitle: false)
|
||||
: buildList(context),
|
||||
);
|
||||
}
|
||||
@ -62,6 +62,12 @@ class _BagsGiftPageState
|
||||
onTap: () {
|
||||
selectedGift = res;
|
||||
setState(() {});
|
||||
showStoreBagFullScreenResourcePreview(
|
||||
context,
|
||||
sourceUrl: gift?.giftSourceUrl,
|
||||
coverUrl: gift?.giftPhoto,
|
||||
fit: BoxFit.contain,
|
||||
);
|
||||
},
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
@ -77,21 +83,15 @@ class _BagsGiftPageState
|
||||
alignment: Alignment.center,
|
||||
children: [
|
||||
Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
SizedBox(height: 25.w),
|
||||
netImage(
|
||||
url: gift?.giftPhoto ?? "",
|
||||
width: 55.w,
|
||||
height: 55.w,
|
||||
width: 82.w,
|
||||
height: 82.w,
|
||||
fit: BoxFit.contain,
|
||||
),
|
||||
SizedBox(height: 8.w),
|
||||
buildStoreBagItemTitle(
|
||||
gift?.giftName ?? "",
|
||||
textColor: Colors.white,
|
||||
fontSize: 12,
|
||||
),
|
||||
SizedBox(height: 6.w),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
|
||||
@ -63,7 +63,7 @@ class _BagsHeaddressPageState
|
||||
Expanded(
|
||||
child:
|
||||
items.isEmpty && isLoading
|
||||
? const SCBagGridSkeleton()
|
||||
? const SCBagGridSkeleton(showTitle: false)
|
||||
: buildList(context),
|
||||
),
|
||||
selecteHeaddress != null ? SizedBox(height: 10.w) : Container(),
|
||||
@ -234,18 +234,13 @@ class _BagsHeaddressPageState
|
||||
alignment: Alignment.center,
|
||||
children: [
|
||||
Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
SizedBox(height: 25.w),
|
||||
netImage(
|
||||
url: res.propsResources?.cover ?? "",
|
||||
width: 55.w,
|
||||
height: 55.w,
|
||||
),
|
||||
SizedBox(height: 8.w),
|
||||
buildStoreBagItemTitle(
|
||||
res.propsResources?.name ?? "",
|
||||
textColor: Colors.white,
|
||||
fontSize: 12.sp,
|
||||
width: 82.w,
|
||||
height: 82.w,
|
||||
fit: BoxFit.contain,
|
||||
),
|
||||
],
|
||||
),
|
||||
@ -308,6 +303,12 @@ class _BagsHeaddressPageState
|
||||
onTap: () {
|
||||
selecteHeaddress = res;
|
||||
setState(() {});
|
||||
showStoreBagFullScreenResourcePreview(
|
||||
context,
|
||||
resource: res.propsResources,
|
||||
previewKind: SCStoreItemPreviewKind.avatarFrame,
|
||||
fit: BoxFit.contain,
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@ -69,7 +69,7 @@ class _BagsMountainsPageState
|
||||
Expanded(
|
||||
child:
|
||||
items.isEmpty && isLoading
|
||||
? const SCBagGridSkeleton()
|
||||
? const SCBagGridSkeleton(showTitle: false)
|
||||
: buildList(context),
|
||||
),
|
||||
selecteMountains != null ? SizedBox(height: 10.w) : Container(),
|
||||
@ -240,18 +240,13 @@ class _BagsMountainsPageState
|
||||
alignment: Alignment.center,
|
||||
children: [
|
||||
Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
SizedBox(height: 25.w),
|
||||
netImage(
|
||||
url: res.propsResources?.cover ?? "",
|
||||
width: 55.w,
|
||||
height: 55.w,
|
||||
),
|
||||
SizedBox(height: 8.w),
|
||||
buildStoreBagItemTitle(
|
||||
res.propsResources?.name ?? "",
|
||||
textColor: Colors.white,
|
||||
fontSize: 12.sp,
|
||||
width: 82.w,
|
||||
height: 82.w,
|
||||
fit: BoxFit.contain,
|
||||
),
|
||||
],
|
||||
),
|
||||
@ -293,6 +288,11 @@ class _BagsMountainsPageState
|
||||
onTap: () {
|
||||
selecteMountains = res;
|
||||
setState(() {});
|
||||
showStoreBagFullScreenResourcePreview(
|
||||
context,
|
||||
resource: res.propsResources,
|
||||
fit: BoxFit.contain,
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@ -227,15 +227,14 @@ class _BagsThemePageState
|
||||
alignment: Alignment.center,
|
||||
children: [
|
||||
Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
SizedBox(height: 25.w),
|
||||
netImage(
|
||||
url: res.themeBack ?? "",
|
||||
width: 55.w,
|
||||
height: 55.w,
|
||||
width: 82.w,
|
||||
height: 82.w,
|
||||
borderRadius: BorderRadius.all(Radius.circular(8.w)),
|
||||
),
|
||||
SizedBox(height: 8.w),
|
||||
],
|
||||
),
|
||||
PositionedDirectional(
|
||||
@ -276,6 +275,12 @@ class _BagsThemePageState
|
||||
onTap: () {
|
||||
selecteTheme = res;
|
||||
setState(() {});
|
||||
showStoreBagFullScreenResourcePreview(
|
||||
context,
|
||||
sourceUrl: res.themeBack,
|
||||
coverUrl: res.themeBack,
|
||||
fit: BoxFit.contain,
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@ -156,7 +156,9 @@ class RealTimeMessagingManager extends ChangeNotifier {
|
||||
|
||||
BuildContext? context;
|
||||
|
||||
void _giftFxLog(String message) {}
|
||||
void _giftFxLog(String message) {
|
||||
debugPrint('[GiftFx][rtm] $message');
|
||||
}
|
||||
|
||||
void _enqueueGiftFloatingMessage(
|
||||
SCFloatingMessage message, {
|
||||
|
||||
@ -1,71 +1,84 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:device_info_plus/device_info_plus.dart';
|
||||
import 'package:yumi/app/constants/sc_global_config.dart';
|
||||
import 'package:yumi/shared/data_sources/sources/local/data_persistence.dart';
|
||||
import 'package:package_info_plus/package_info_plus.dart';
|
||||
import 'package:uuid/uuid.dart';
|
||||
|
||||
class SCDeviceIdUtils {
|
||||
static Future<void> initDeviceinfo() async {
|
||||
final info = await PackageInfo.fromPlatform();
|
||||
SCGlobalConfig.version = info.version;
|
||||
SCGlobalConfig.build = info.buildNumber;
|
||||
|
||||
final processorCount = Platform.numberOfProcessors;
|
||||
|
||||
if (Platform.isAndroid) {
|
||||
SCGlobalConfig.channel = "Google";
|
||||
final dev = await DeviceInfoPlugin().androidInfo;
|
||||
SCGlobalConfig.model = dev.model;
|
||||
SCGlobalConfig.sysVersion = dev.version.release;
|
||||
SCGlobalConfig.sdkInt = dev.version.sdkInt;
|
||||
SCGlobalConfig.applyDevicePerformanceProfile(
|
||||
isLowRamDevice: dev.isLowRamDevice,
|
||||
processorCount: processorCount,
|
||||
);
|
||||
} else if (Platform.isIOS) {
|
||||
SCGlobalConfig.channel = "AppStore";
|
||||
final dev = await DeviceInfoPlugin().iosInfo;
|
||||
SCGlobalConfig.model = dev.model;
|
||||
SCGlobalConfig.sysVersion = dev.systemVersion;
|
||||
SCGlobalConfig.applyDevicePerformanceProfile(
|
||||
isLowRamDevice: false,
|
||||
processorCount: processorCount,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// 获取持久化的唯一设备ID
|
||||
static Future<String> getDeviceId({bool forceRefresh = false}) async {
|
||||
// 如果强制刷新或配置中不存在,则重新生成
|
||||
if (forceRefresh || SCGlobalConfig.imei.isEmpty) {
|
||||
final newId = await _g();
|
||||
DataPersistence.setUniqueId(newId);
|
||||
SCGlobalConfig.imei = newId;
|
||||
return newId;
|
||||
}
|
||||
|
||||
// 检查存储中的ID
|
||||
String storedId = DataPersistence.getUniqueId();
|
||||
if (storedId.isNotEmpty) {
|
||||
SCGlobalConfig.imei = storedId;
|
||||
return storedId;
|
||||
}
|
||||
|
||||
// 生成新ID并存储
|
||||
final newId = await _g();
|
||||
DataPersistence.setUniqueId(newId);
|
||||
SCGlobalConfig.imei = newId;
|
||||
return newId;
|
||||
}
|
||||
|
||||
// 生成组合唯一ID
|
||||
static Future<String> _g() async {
|
||||
final packageInfo = await PackageInfo.fromPlatform();
|
||||
// 生成新 UUID 并存储
|
||||
final newId = '${const Uuid().v4()}-${packageInfo.packageName}';
|
||||
DataPersistence.setUniqueId(newId);
|
||||
return newId;
|
||||
}
|
||||
}
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:device_info_plus/device_info_plus.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:yumi/app/constants/sc_global_config.dart';
|
||||
import 'package:yumi/shared/data_sources/sources/local/data_persistence.dart';
|
||||
import 'package:package_info_plus/package_info_plus.dart';
|
||||
import 'package:uuid/uuid.dart';
|
||||
|
||||
class SCDeviceIdUtils {
|
||||
static Future<void> initDeviceinfo() async {
|
||||
final info = await PackageInfo.fromPlatform();
|
||||
SCGlobalConfig.version = info.version;
|
||||
SCGlobalConfig.build = info.buildNumber;
|
||||
|
||||
final processorCount = Platform.numberOfProcessors;
|
||||
|
||||
if (Platform.isAndroid) {
|
||||
SCGlobalConfig.channel = "Google";
|
||||
final dev = await DeviceInfoPlugin().androidInfo;
|
||||
SCGlobalConfig.model = dev.model;
|
||||
SCGlobalConfig.sysVersion = dev.version.release;
|
||||
SCGlobalConfig.sdkInt = dev.version.sdkInt;
|
||||
SCGlobalConfig.applyDevicePerformanceProfile(
|
||||
isLowRamDevice: dev.isLowRamDevice,
|
||||
processorCount: processorCount,
|
||||
);
|
||||
_logPerformanceProfile();
|
||||
} else if (Platform.isIOS) {
|
||||
SCGlobalConfig.channel = "AppStore";
|
||||
final dev = await DeviceInfoPlugin().iosInfo;
|
||||
SCGlobalConfig.model = dev.model;
|
||||
SCGlobalConfig.sysVersion = dev.systemVersion;
|
||||
SCGlobalConfig.applyDevicePerformanceProfile(
|
||||
isLowRamDevice: false,
|
||||
processorCount: processorCount,
|
||||
);
|
||||
_logPerformanceProfile();
|
||||
}
|
||||
}
|
||||
|
||||
static void _logPerformanceProfile() {
|
||||
debugPrint(
|
||||
'[GiftFx][device] model=${SCGlobalConfig.model} '
|
||||
'sdk=${SCGlobalConfig.sdkInt} processors=${SCGlobalConfig.processorCount} '
|
||||
'lowRam=${SCGlobalConfig.isLowRamDevice} '
|
||||
'lowPerformance=${SCGlobalConfig.isLowPerformanceDevice} '
|
||||
'allowsHighCost=${SCGlobalConfig.allowsHighCostAnimations}',
|
||||
);
|
||||
}
|
||||
|
||||
// 获取持久化的唯一设备ID
|
||||
static Future<String> getDeviceId({bool forceRefresh = false}) async {
|
||||
// 如果强制刷新或配置中不存在,则重新生成
|
||||
if (forceRefresh || SCGlobalConfig.imei.isEmpty) {
|
||||
final newId = await _g();
|
||||
DataPersistence.setUniqueId(newId);
|
||||
SCGlobalConfig.imei = newId;
|
||||
return newId;
|
||||
}
|
||||
|
||||
// 检查存储中的ID
|
||||
String storedId = DataPersistence.getUniqueId();
|
||||
if (storedId.isNotEmpty) {
|
||||
SCGlobalConfig.imei = storedId;
|
||||
return storedId;
|
||||
}
|
||||
|
||||
// 生成新ID并存储
|
||||
final newId = await _g();
|
||||
DataPersistence.setUniqueId(newId);
|
||||
SCGlobalConfig.imei = newId;
|
||||
return newId;
|
||||
}
|
||||
|
||||
// 生成组合唯一ID
|
||||
static Future<String> _g() async {
|
||||
final packageInfo = await PackageInfo.fromPlatform();
|
||||
// 生成新 UUID 并存储
|
||||
final newId = '${const Uuid().v4()}-${packageInfo.packageName}';
|
||||
DataPersistence.setUniqueId(newId);
|
||||
return newId;
|
||||
}
|
||||
}
|
||||
|
||||
@ -9,7 +9,7 @@ import 'package:yumi/shared/tools/sc_path_utils.dart';
|
||||
import 'package:yumi/shared/tools/sc_room_effect_scheduler.dart';
|
||||
import 'package:yumi/shared/data_sources/sources/local/file_cache_manager.dart';
|
||||
import 'package:tancent_vap/utils/constant.dart';
|
||||
import 'package:tancent_vap/widgets/vap_view.dart';
|
||||
import 'package:yumi/ui_kit/widgets/room/effect/gift_mp4_effect_view.dart';
|
||||
|
||||
import 'package:yumi/shared/data_sources/sources/local/data_persistence.dart';
|
||||
|
||||
@ -40,7 +40,7 @@ class SCGiftVapSvgaManager {
|
||||
final SCPriorityQueue<SCVapTask> _entryQueue = SCPriorityQueue<SCVapTask>(
|
||||
(a, b) => a.compareTo(b), // 调用 SCVapTask 的 compareTo 方法
|
||||
);
|
||||
VapController? _rgc;
|
||||
SCGiftMp4Controller? _mp4Controller;
|
||||
SVGAAnimationController? _rsc;
|
||||
bool _play = false;
|
||||
bool _dis = false;
|
||||
@ -62,9 +62,9 @@ class SCGiftVapSvgaManager {
|
||||
void setMute(bool muteMusic) {
|
||||
_mute = muteMusic;
|
||||
_rsc?.muted = _mute;
|
||||
final vapController = _rgc;
|
||||
if (vapController != null) {
|
||||
unawaited(vapController.setMute(_mute));
|
||||
final mp4Controller = _mp4Controller;
|
||||
if (mp4Controller != null) {
|
||||
unawaited(mp4Controller.setMute(_mute));
|
||||
}
|
||||
unawaited(DataPersistence.setPlayGiftMusic(_mute));
|
||||
}
|
||||
@ -73,12 +73,23 @@ class SCGiftVapSvgaManager {
|
||||
return _mute;
|
||||
}
|
||||
|
||||
void _log(String message) {}
|
||||
void _log(String message) {
|
||||
debugPrint('[GiftFx][manager] $message');
|
||||
}
|
||||
|
||||
bool _needsSvgaController(String path) {
|
||||
return SCPathUtils.getFileExtension(path).toLowerCase() == ".svga";
|
||||
}
|
||||
|
||||
bool _needsMp4Controller(String path) {
|
||||
final ext = SCPathUtils.getFileExtension(path).toLowerCase();
|
||||
return ext == ".mp4" || ext == ".vap";
|
||||
}
|
||||
|
||||
bool _isSupportedEffectPath(String path) {
|
||||
return _needsSvgaController(path) || _needsMp4Controller(path);
|
||||
}
|
||||
|
||||
static int resolveEffectPriority({
|
||||
int priority = 0,
|
||||
int type = giftEffectType,
|
||||
@ -103,7 +114,7 @@ class SCGiftVapSvgaManager {
|
||||
if (_needsSvgaController(task.path)) {
|
||||
return _rsc != null;
|
||||
}
|
||||
return _rgc != null;
|
||||
return _mp4Controller != null;
|
||||
}
|
||||
|
||||
bool _isPlayableFileReady(String path) {
|
||||
@ -133,6 +144,13 @@ class SCGiftVapSvgaManager {
|
||||
_isPreloadedOrLoading(path)) {
|
||||
return;
|
||||
}
|
||||
if (!_isSupportedEffectPath(path)) {
|
||||
_log(
|
||||
'preload ignored because effect file extension is unsupported '
|
||||
'path=$path ext=${SCPathUtils.getFileExtension(path)}',
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (highPriority) {
|
||||
_log('high priority preload path=$path');
|
||||
await _warmupPath(path);
|
||||
@ -434,27 +452,25 @@ class SCGiftVapSvgaManager {
|
||||
}
|
||||
|
||||
// 绑定控制器
|
||||
void bindVapCtrl(VapController vapController) {
|
||||
void bindMp4Ctrl(SCGiftMp4Controller mp4Controller) {
|
||||
_mute = DataPersistence.getPlayGiftMusic();
|
||||
_dis = false;
|
||||
_rgc = vapController;
|
||||
unawaited(vapController.setMute(_mute));
|
||||
_mp4Controller = mp4Controller;
|
||||
unawaited(mp4Controller.setMute(_mute));
|
||||
_log(
|
||||
'bindVapCtrl hasVapCtrl=${_rgc != null} '
|
||||
'bindMp4Ctrl hasMp4Ctrl=${_mp4Controller != null} '
|
||||
'hasSvgaCtrl=${_rsc != null} queue=$_queueSummary mute=$_mute',
|
||||
);
|
||||
_rgc?.setAnimListener(
|
||||
onVideoStart: () {
|
||||
_log('vap onVideoStart path=${_currentTask?.path}');
|
||||
_mp4Controller?.setPlaybackListener(
|
||||
onStart: () {
|
||||
_log('mp4 onVideoStart path=${_currentTask?.path}');
|
||||
},
|
||||
onVideoComplete: () {
|
||||
_log('vap onVideoComplete path=${_currentTask?.path}');
|
||||
onComplete: () {
|
||||
_log('mp4 onVideoComplete path=${_currentTask?.path}');
|
||||
_hcs();
|
||||
},
|
||||
onFailed: (code, type, msg) {
|
||||
_log(
|
||||
'vap onFailed path=${_currentTask?.path} code=$code type=$type msg=$msg',
|
||||
);
|
||||
onFailed: (error) {
|
||||
_log('mp4 onFailed path=${_currentTask?.path} error=$error');
|
||||
_hcs();
|
||||
},
|
||||
);
|
||||
@ -469,7 +485,7 @@ class SCGiftVapSvgaManager {
|
||||
_rsc?.muted = _mute;
|
||||
_log(
|
||||
'bindSvgaCtrl hasSvgaCtrl=${_rsc != null} '
|
||||
'hasVapCtrl=${_rgc != null} queue=$_queueSummary',
|
||||
'hasMp4Ctrl=${_mp4Controller != null} queue=$_queueSummary',
|
||||
);
|
||||
_rsc?.addStatusListener((AnimationStatus status) {
|
||||
if (status.isCompleted) {
|
||||
@ -493,6 +509,13 @@ class SCGiftVapSvgaManager {
|
||||
_log('play ignored because path is empty');
|
||||
return;
|
||||
}
|
||||
if (!_isSupportedEffectPath(path)) {
|
||||
_log(
|
||||
'play ignored because effect file extension is unsupported '
|
||||
'path=$path ext=${SCPathUtils.getFileExtension(path)}',
|
||||
);
|
||||
return;
|
||||
}
|
||||
final resolvedPriority = resolveEffectPriority(
|
||||
priority: priority,
|
||||
type: type,
|
||||
@ -503,12 +526,13 @@ class SCGiftVapSvgaManager {
|
||||
'sdkInt=${SCGlobalConfig.sdkInt} maxSdkNoAnim=${SCGlobalConfig.maxSdkNoAnim} '
|
||||
'lowPerformance=${SCGlobalConfig.isLowPerformanceDevice} '
|
||||
'disposed=$_dis playing=$_play queueBefore=$_queueSummary '
|
||||
'hasSvgaCtrl=${_rsc != null} hasVapCtrl=${_rgc != null}',
|
||||
'hasSvgaCtrl=${_rsc != null} hasMp4Ctrl=${_mp4Controller != null}',
|
||||
);
|
||||
if (SCGlobalConfig.allowsHighCostAnimations) {
|
||||
if (_dis) {
|
||||
_log('play ignored because manager is disposed path=$path');
|
||||
return;
|
||||
_log(
|
||||
'manager disposed/no controller; queue play until bind path=$path',
|
||||
);
|
||||
}
|
||||
final task = SCVapTask(
|
||||
path: path,
|
||||
@ -546,7 +570,7 @@ class SCGiftVapSvgaManager {
|
||||
_log(
|
||||
'controller not ready for path=${task?.path} '
|
||||
'needSvga=${task != null ? _needsSvgaController(task.path) : "unknown"} '
|
||||
'hasSvgaCtrl=${_rsc != null} hasVapCtrl=${_rgc != null} '
|
||||
'hasSvgaCtrl=${_rsc != null} hasMp4Ctrl=${_mp4Controller != null} '
|
||||
'queue=$_queueSummary',
|
||||
);
|
||||
return;
|
||||
@ -596,51 +620,47 @@ class SCGiftVapSvgaManager {
|
||||
_log('forward asset svga path=${task.path}');
|
||||
} else {
|
||||
_log('play asset vap/mp4 path=${task.path}');
|
||||
if (task.customResources != null) {
|
||||
task.customResources?.forEach((k, v) async {
|
||||
await _rgc?.setVapTagContent(k, v);
|
||||
});
|
||||
}
|
||||
await _rgc?.playAsset(task.path);
|
||||
await _mp4Controller?.playAsset(task.path);
|
||||
}
|
||||
}
|
||||
|
||||
// 播放本地文件
|
||||
Future<void> _pf(SCVapTask task) async {
|
||||
Future<void> _pf(SCVapTask task, {String? playablePath}) async {
|
||||
if (_dis) {
|
||||
return;
|
||||
}
|
||||
final resolvedPath = playablePath ?? task.path;
|
||||
if (SCPathUtils.getFileExtension(task.path).toLowerCase() == ".svga") {
|
||||
final entity = await _loadSvgaEntity(task.path);
|
||||
final entity = await _loadSvgaEntity(resolvedPath);
|
||||
if (!_isCurrentTask(task)) {
|
||||
return;
|
||||
}
|
||||
_log('play local svga file path=${task.path}');
|
||||
_log('play local svga file path=$resolvedPath source=${task.path}');
|
||||
_rsc?.muted = _mute;
|
||||
_rsc?.videoItem = entity;
|
||||
_rsc?.reset();
|
||||
_rsc?.forward();
|
||||
return;
|
||||
}
|
||||
if (_rgc == null) {
|
||||
_log('skip playFile because vap controller is null path=${task.path}');
|
||||
if (!_needsMp4Controller(task.path) && !_needsMp4Controller(resolvedPath)) {
|
||||
_log(
|
||||
'skip playFile because effect file extension is unsupported '
|
||||
'path=$resolvedPath source=${task.path}',
|
||||
);
|
||||
_finishCurrentTask();
|
||||
return;
|
||||
}
|
||||
_log('play local vap/mp4 file path=${task.path}');
|
||||
await _rgc?.setMute(_mute);
|
||||
if (_mp4Controller == null) {
|
||||
_log('skip playFile because mp4 controller is null path=$resolvedPath');
|
||||
_finishCurrentTask();
|
||||
return;
|
||||
}
|
||||
_log('play local vap/mp4 file path=$resolvedPath source=${task.path}');
|
||||
await _mp4Controller?.setMute(_mute);
|
||||
if (!_isCurrentTask(task)) {
|
||||
return;
|
||||
}
|
||||
if (task.customResources != null) {
|
||||
task.customResources?.forEach((k, v) async {
|
||||
await _rgc?.setVapTagContent(k, v);
|
||||
});
|
||||
}
|
||||
if (!_isCurrentTask(task)) {
|
||||
return;
|
||||
}
|
||||
await _rgc!.playFile(task.path);
|
||||
await _mp4Controller!.playFile(resolvedPath);
|
||||
}
|
||||
|
||||
// 播放网络资源
|
||||
@ -672,22 +692,15 @@ class SCGiftVapSvgaManager {
|
||||
}
|
||||
if (!_dis) {
|
||||
_log('use prepared network vap/mp4 local path=$playablePath');
|
||||
await _pf(
|
||||
SCVapTask(
|
||||
path: playablePath,
|
||||
type: task.type,
|
||||
priority: task.priority,
|
||||
customResources: task.customResources,
|
||||
),
|
||||
);
|
||||
await _pf(task, playablePath: playablePath);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 处理控制器状态变化
|
||||
void _hcs() {
|
||||
if (_rgc != null && !_dis) {
|
||||
_log('finish vap task path=${_currentTask?.path}');
|
||||
if (_mp4Controller != null && !_dis) {
|
||||
_log('finish mp4 task path=${_currentTask?.path}');
|
||||
_finishCurrentTask(delay: const Duration(milliseconds: 50));
|
||||
}
|
||||
}
|
||||
@ -703,7 +716,7 @@ class SCGiftVapSvgaManager {
|
||||
return;
|
||||
}
|
||||
_log('task watchdog timeout path=${task.path}');
|
||||
_rgc?.stop();
|
||||
_mp4Controller?.stop();
|
||||
_rsc?.stop();
|
||||
_rsc?.reset();
|
||||
_rsc?.videoItem = null;
|
||||
@ -741,7 +754,7 @@ class SCGiftVapSvgaManager {
|
||||
_preloadQueue.clear();
|
||||
_queuedPreloadPaths.clear();
|
||||
_activePreloadCount = 0;
|
||||
_rgc?.stop();
|
||||
_mp4Controller?.stop();
|
||||
_rsc?.stop();
|
||||
_rsc?.reset();
|
||||
_rsc?.videoItem = null;
|
||||
@ -790,8 +803,8 @@ class SCGiftVapSvgaManager {
|
||||
_playablePathTasks.clear();
|
||||
clearMemoryCache(includeCurrent: true);
|
||||
_playablePathCache.clear();
|
||||
_rgc?.dispose();
|
||||
_rgc = null;
|
||||
_mp4Controller?.dispose();
|
||||
_mp4Controller = null;
|
||||
_rsc?.dispose();
|
||||
_rsc = null;
|
||||
}
|
||||
|
||||
350
lib/ui_kit/widgets/room/effect/gift_mp4_effect_view.dart
Normal file
350
lib/ui_kit/widgets/room/effect/gift_mp4_effect_view.dart
Normal file
@ -0,0 +1,350 @@
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:video_player/video_player.dart';
|
||||
|
||||
enum SCGiftMp4SourceType { asset, file }
|
||||
|
||||
typedef SCGiftMp4Callback = void Function();
|
||||
typedef SCGiftMp4FailedCallback = void Function(Object? error);
|
||||
|
||||
class SCGiftMp4PlayRequest {
|
||||
const SCGiftMp4PlayRequest({
|
||||
required this.path,
|
||||
required this.sourceType,
|
||||
required this.token,
|
||||
required this.muted,
|
||||
});
|
||||
|
||||
final String path;
|
||||
final SCGiftMp4SourceType sourceType;
|
||||
final int token;
|
||||
final bool muted;
|
||||
}
|
||||
|
||||
class SCGiftMp4Controller extends ChangeNotifier {
|
||||
static const MethodChannel _events = MethodChannel(
|
||||
'com.org.yumiparty/gift_mp4_events',
|
||||
);
|
||||
static const MethodChannel _control = MethodChannel(
|
||||
'com.org.yumiparty/gift_mp4_control',
|
||||
);
|
||||
static final Map<int, SCGiftMp4Controller> _controllersByViewId = {};
|
||||
static bool _eventsHandlerInstalled = false;
|
||||
|
||||
SCGiftMp4Controller() {
|
||||
_installEventsHandler();
|
||||
}
|
||||
|
||||
SCGiftMp4PlayRequest? _request;
|
||||
SCGiftMp4Callback? _onStart;
|
||||
SCGiftMp4Callback? _onComplete;
|
||||
SCGiftMp4FailedCallback? _onFailed;
|
||||
int _nextToken = 0;
|
||||
int? _activeViewId;
|
||||
bool _muted = false;
|
||||
bool _disposed = false;
|
||||
|
||||
SCGiftMp4PlayRequest? get request => _request;
|
||||
|
||||
static void _installEventsHandler() {
|
||||
if (_eventsHandlerInstalled) {
|
||||
return;
|
||||
}
|
||||
_eventsHandlerInstalled = true;
|
||||
_events.setMethodCallHandler(_dispatchNativeEvent);
|
||||
}
|
||||
|
||||
static Future<void> _dispatchNativeEvent(MethodCall call) async {
|
||||
final args = call.arguments;
|
||||
if (args is! Map) {
|
||||
return;
|
||||
}
|
||||
final viewId = args['viewId'];
|
||||
if (viewId is! int) {
|
||||
return;
|
||||
}
|
||||
await _controllersByViewId[viewId]?._handleNativeEvent(call);
|
||||
}
|
||||
|
||||
void setPlaybackListener({
|
||||
SCGiftMp4Callback? onStart,
|
||||
SCGiftMp4Callback? onComplete,
|
||||
SCGiftMp4FailedCallback? onFailed,
|
||||
}) {
|
||||
_onStart = onStart;
|
||||
_onComplete = onComplete;
|
||||
_onFailed = onFailed;
|
||||
}
|
||||
|
||||
Future<void> setMute(bool muted) async {
|
||||
_muted = muted;
|
||||
final viewId = _activeViewId;
|
||||
if (viewId == null) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await _control.invokeMethod<void>('setMute', {
|
||||
'viewId': viewId,
|
||||
'muted': muted,
|
||||
});
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
Future<void> playAsset(String path) =>
|
||||
_play(path: path, sourceType: SCGiftMp4SourceType.asset);
|
||||
|
||||
Future<void> playFile(String path) =>
|
||||
_play(path: path, sourceType: SCGiftMp4SourceType.file);
|
||||
|
||||
Future<void> _play({
|
||||
required String path,
|
||||
required SCGiftMp4SourceType sourceType,
|
||||
}) async {
|
||||
if (_disposed || path.isEmpty) {
|
||||
debugPrint(
|
||||
'[GiftFx][mp4] ignore play disposed=$_disposed pathEmpty=${path.isEmpty}',
|
||||
);
|
||||
return;
|
||||
}
|
||||
_unregisterActiveView();
|
||||
_activeViewId = null;
|
||||
_request = SCGiftMp4PlayRequest(
|
||||
path: path,
|
||||
sourceType: sourceType,
|
||||
token: ++_nextToken,
|
||||
muted: _muted,
|
||||
);
|
||||
debugPrint(
|
||||
'[GiftFx][mp4] request token=$_nextToken source=${sourceType.name} path=$path',
|
||||
);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void attachViewId(int viewId, int token) {
|
||||
if (_request?.token != token || _disposed) {
|
||||
debugPrint(
|
||||
'[GiftFx][mp4] ignore attach viewId=$viewId token=$token '
|
||||
'activeToken=${_request?.token} disposed=$_disposed',
|
||||
);
|
||||
return;
|
||||
}
|
||||
_unregisterActiveView();
|
||||
_activeViewId = viewId;
|
||||
_controllersByViewId[viewId] = this;
|
||||
debugPrint('[GiftFx][mp4] attach viewId=$viewId token=$token');
|
||||
unawaited(setMute(_muted));
|
||||
}
|
||||
|
||||
void stop() {
|
||||
final viewId = _activeViewId;
|
||||
_request = null;
|
||||
_unregisterActiveView();
|
||||
_activeViewId = null;
|
||||
_nextToken++;
|
||||
if (viewId != null) {
|
||||
unawaited(
|
||||
_control
|
||||
.invokeMethod<void>('stop', {'viewId': viewId})
|
||||
.catchError((_) {}),
|
||||
);
|
||||
}
|
||||
if (!_disposed) {
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
void _unregisterActiveView() {
|
||||
final viewId = _activeViewId;
|
||||
if (viewId == null) {
|
||||
return;
|
||||
}
|
||||
if (identical(_controllersByViewId[viewId], this)) {
|
||||
_controllersByViewId.remove(viewId);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _handleNativeEvent(MethodCall call) async {
|
||||
final args = call.arguments;
|
||||
if (args is! Map) {
|
||||
return;
|
||||
}
|
||||
final token = args['token'];
|
||||
final request = _request;
|
||||
if (request == null || token != request.token || _disposed) {
|
||||
return;
|
||||
}
|
||||
|
||||
switch (call.method) {
|
||||
case 'started':
|
||||
debugPrint('[GiftFx][mp4] native started token=$token');
|
||||
_onStart?.call();
|
||||
break;
|
||||
case 'completed':
|
||||
debugPrint('[GiftFx][mp4] native completed token=$token');
|
||||
_request = null;
|
||||
_unregisterActiveView();
|
||||
_activeViewId = null;
|
||||
notifyListeners();
|
||||
_onComplete?.call();
|
||||
break;
|
||||
case 'failed':
|
||||
final error = args['message'] ?? args['code'];
|
||||
debugPrint('[GiftFx][mp4] native failed token=$token error=$error');
|
||||
_request = null;
|
||||
_unregisterActiveView();
|
||||
_activeViewId = null;
|
||||
notifyListeners();
|
||||
_onFailed?.call(error);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_disposed = true;
|
||||
stop();
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
class GiftMp4EffectView extends StatefulWidget {
|
||||
const GiftMp4EffectView({super.key, required this.controller});
|
||||
|
||||
final SCGiftMp4Controller controller;
|
||||
|
||||
@override
|
||||
State<GiftMp4EffectView> createState() => _GiftMp4EffectViewState();
|
||||
}
|
||||
|
||||
class _GiftMp4EffectViewState extends State<GiftMp4EffectView> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AnimatedBuilder(
|
||||
animation: widget.controller,
|
||||
builder: (context, _) {
|
||||
final request = widget.controller.request;
|
||||
if (request == null) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
if (!kIsWeb && defaultTargetPlatform == TargetPlatform.android) {
|
||||
return AndroidView(
|
||||
key: ValueKey('gift-mp4-${request.token}-${request.path}'),
|
||||
viewType: 'gift_mp4_video_view',
|
||||
creationParams: <String, Object>{
|
||||
'path': request.path,
|
||||
'sourceType': request.sourceType.name,
|
||||
'token': request.token,
|
||||
'muted': request.muted,
|
||||
'renderMode': 'auto',
|
||||
},
|
||||
creationParamsCodec: const StandardMessageCodec(),
|
||||
onPlatformViewCreated:
|
||||
(viewId) =>
|
||||
widget.controller.attachViewId(viewId, request.token),
|
||||
);
|
||||
}
|
||||
return _FallbackMp4EffectVideo(
|
||||
request: request,
|
||||
onCompleted: widget.controller.stop,
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _FallbackMp4EffectVideo extends StatefulWidget {
|
||||
const _FallbackMp4EffectVideo({
|
||||
required this.request,
|
||||
required this.onCompleted,
|
||||
});
|
||||
|
||||
final SCGiftMp4PlayRequest request;
|
||||
final VoidCallback onCompleted;
|
||||
|
||||
@override
|
||||
State<_FallbackMp4EffectVideo> createState() =>
|
||||
_FallbackMp4EffectVideoState();
|
||||
}
|
||||
|
||||
class _FallbackMp4EffectVideoState extends State<_FallbackMp4EffectVideo> {
|
||||
VideoPlayerController? _controller;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_prepare();
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(covariant _FallbackMp4EffectVideo oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
if (oldWidget.request.token != widget.request.token) {
|
||||
_disposeController();
|
||||
_prepare();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _prepare() async {
|
||||
final controller =
|
||||
widget.request.sourceType == SCGiftMp4SourceType.asset
|
||||
? VideoPlayerController.asset(widget.request.path)
|
||||
: VideoPlayerController.file(File(widget.request.path));
|
||||
_controller = controller;
|
||||
controller.addListener(_handleState);
|
||||
await controller.setLooping(false);
|
||||
await controller.setVolume(widget.request.muted ? 0 : 1);
|
||||
await controller.initialize();
|
||||
if (!mounted || _controller != controller) {
|
||||
return;
|
||||
}
|
||||
await controller.play();
|
||||
if (mounted) {
|
||||
setState(() {});
|
||||
}
|
||||
}
|
||||
|
||||
void _handleState() {
|
||||
final controller = _controller;
|
||||
if (controller == null || !controller.value.isInitialized) {
|
||||
return;
|
||||
}
|
||||
final duration = controller.value.duration;
|
||||
if (duration != Duration.zero && controller.value.position >= duration) {
|
||||
widget.onCompleted();
|
||||
}
|
||||
}
|
||||
|
||||
void _disposeController() {
|
||||
final controller = _controller;
|
||||
if (controller == null) {
|
||||
return;
|
||||
}
|
||||
controller.removeListener(_handleState);
|
||||
controller.dispose();
|
||||
_controller = null;
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_disposeController();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final controller = _controller;
|
||||
if (controller == null || !controller.value.isInitialized) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
return Center(
|
||||
child: AspectRatio(
|
||||
aspectRatio: controller.value.aspectRatio,
|
||||
child: VideoPlayer(controller),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -1,73 +1,68 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_svga/flutter_svga.dart';
|
||||
import 'package:tancent_vap/utils/constant.dart';
|
||||
import 'package:tancent_vap/widgets/vap_view.dart';
|
||||
import 'package:yumi/shared/tools/sc_gift_vap_svga_manager.dart';
|
||||
|
||||
class VapPlusSvgaPlayer extends StatefulWidget {
|
||||
final String tag;
|
||||
|
||||
const VapPlusSvgaPlayer({super.key, required this.tag});
|
||||
|
||||
@override
|
||||
State<VapPlusSvgaPlayer> createState() => _VapPlusSvgaPlayerState();
|
||||
}
|
||||
|
||||
class _VapPlusSvgaPlayerState extends State<VapPlusSvgaPlayer>
|
||||
with TickerProviderStateMixin {
|
||||
late SVGAAnimationController _svgaController;
|
||||
|
||||
void _giftFxLog(String message) {}
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_svgaController = SVGAAnimationController(vsync: this);
|
||||
_giftFxLog('initState tag=${widget.tag}');
|
||||
if (widget.tag == "room_gift") {
|
||||
SCGiftVapSvgaManager().bindSvgaCtrl(_svgaController);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_giftFxLog('dispose tag=${widget.tag}');
|
||||
SCGiftVapSvgaManager().dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return IgnorePointer(
|
||||
child: RepaintBoundary(
|
||||
child: Stack(
|
||||
children: [
|
||||
Positioned.fill(
|
||||
child: RepaintBoundary(
|
||||
child: VapView(
|
||||
scaleType: ScaleType.fitCenter,
|
||||
onViewCreated: (controller) {
|
||||
_giftFxLog('onViewCreated tag=${widget.tag}');
|
||||
if (widget.tag == "room_gift") {
|
||||
SCGiftVapSvgaManager().bindVapCtrl(controller);
|
||||
// VapManager().play("https://res.cloudinary.com/dkmchpua1/video/upload/v1737624783/vcg9co6yyfqsadgety1n.mp4");
|
||||
}
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
Positioned.fill(
|
||||
child: RepaintBoundary(
|
||||
child: SVGAImage(
|
||||
_svgaController,
|
||||
fit: BoxFit.contain,
|
||||
allowDrawingOverflow: false,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_svga/flutter_svga.dart';
|
||||
import 'package:yumi/shared/tools/sc_gift_vap_svga_manager.dart';
|
||||
import 'package:yumi/ui_kit/widgets/room/effect/gift_mp4_effect_view.dart';
|
||||
|
||||
class VapPlusSvgaPlayer extends StatefulWidget {
|
||||
final String tag;
|
||||
|
||||
const VapPlusSvgaPlayer({super.key, required this.tag});
|
||||
|
||||
@override
|
||||
State<VapPlusSvgaPlayer> createState() => _VapPlusSvgaPlayerState();
|
||||
}
|
||||
|
||||
class _VapPlusSvgaPlayerState extends State<VapPlusSvgaPlayer>
|
||||
with TickerProviderStateMixin {
|
||||
late SVGAAnimationController _svgaController;
|
||||
late SCGiftMp4Controller _mp4Controller;
|
||||
|
||||
void _giftFxLog(String message) {
|
||||
debugPrint('[GiftFx][host] $message');
|
||||
}
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_svgaController = SVGAAnimationController(vsync: this);
|
||||
_mp4Controller = SCGiftMp4Controller();
|
||||
_giftFxLog('initState tag=${widget.tag}');
|
||||
if (widget.tag == "room_gift") {
|
||||
SCGiftVapSvgaManager().bindSvgaCtrl(_svgaController);
|
||||
SCGiftVapSvgaManager().bindMp4Ctrl(_mp4Controller);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_giftFxLog('dispose tag=${widget.tag}');
|
||||
SCGiftVapSvgaManager().dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return IgnorePointer(
|
||||
child: RepaintBoundary(
|
||||
child: Stack(
|
||||
children: [
|
||||
Positioned.fill(
|
||||
child: RepaintBoundary(
|
||||
child: GiftMp4EffectView(controller: _mp4Controller),
|
||||
),
|
||||
),
|
||||
Positioned.fill(
|
||||
child: RepaintBoundary(
|
||||
child: SVGAImage(
|
||||
_svgaController,
|
||||
fit: BoxFit.contain,
|
||||
allowDrawingOverflow: false,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,13 +1,21 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_screenutil/flutter_screenutil.dart';
|
||||
import 'package:flutter_smart_dialog/flutter_smart_dialog.dart';
|
||||
import 'package:yumi/app/constants/sc_global_config.dart';
|
||||
import 'package:yumi/app_localizations.dart';
|
||||
import 'package:yumi/shared/business_logic/models/res/login_res.dart'
|
||||
show PropsResources;
|
||||
import 'package:yumi/shared/business_logic/models/res/store_list_res.dart';
|
||||
import 'package:yumi/shared/data_sources/sources/local/file_cache_manager.dart';
|
||||
import 'package:yumi/shared/data_sources/sources/local/user_manager.dart';
|
||||
import 'package:yumi/shared/tools/sc_path_utils.dart';
|
||||
import 'package:yumi/ui_kit/components/sc_compontent.dart';
|
||||
import 'package:yumi/ui_kit/components/text/sc_text.dart';
|
||||
import 'package:yumi/ui_kit/widgets/props/sc_data_card_resource_view.dart';
|
||||
import 'package:yumi/ui_kit/widgets/room/effect/gift_mp4_effect_view.dart';
|
||||
|
||||
const Duration _kStoreBagSkeletonAnimationDuration = Duration(
|
||||
milliseconds: 1450,
|
||||
@ -21,6 +29,48 @@ const String _kStoreItemPreviewDialogTag = "storeItemFullScreenPreview";
|
||||
|
||||
enum SCStoreItemPreviewKind { resource, avatarFrame, dataCard }
|
||||
|
||||
void showStoreBagFullScreenResourcePreview(
|
||||
BuildContext context, {
|
||||
PropsResources? resource,
|
||||
String? sourceUrl,
|
||||
String? coverUrl,
|
||||
SCStoreItemPreviewKind previewKind = SCStoreItemPreviewKind.resource,
|
||||
BoxFit fit = BoxFit.contain,
|
||||
}) {
|
||||
final source = _firstNonBlank([sourceUrl, resource?.sourceUrl]);
|
||||
final cover = _firstNonBlank([coverUrl, resource?.cover]);
|
||||
if (source.isEmpty && cover.isEmpty && resource == null) {
|
||||
return;
|
||||
}
|
||||
SmartDialog.show(
|
||||
tag: _kStoreItemPreviewDialogTag,
|
||||
alignment: Alignment.center,
|
||||
animationType: SmartAnimationType.fade,
|
||||
maskColor: Colors.transparent,
|
||||
builder: (_) {
|
||||
final size = MediaQuery.sizeOf(context);
|
||||
return GestureDetector(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onTap: () => SmartDialog.dismiss(tag: _kStoreItemPreviewDialogTag),
|
||||
child: Container(
|
||||
width: size.width,
|
||||
height: size.height,
|
||||
color: Colors.black.withValues(alpha: 0.86),
|
||||
child: IgnorePointer(
|
||||
child: _StoreBagFullScreenPreview(
|
||||
resource: resource,
|
||||
sourceUrl: source,
|
||||
coverUrl: cover,
|
||||
previewKind: previewKind,
|
||||
fit: fit,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget buildStoreBagItemTitle(
|
||||
String content, {
|
||||
required double fontSize,
|
||||
@ -195,26 +245,11 @@ class SCStoreGridItemCard extends StatelessWidget {
|
||||
}
|
||||
|
||||
void _showFullScreenPreview(BuildContext context) {
|
||||
SmartDialog.show(
|
||||
tag: _kStoreItemPreviewDialogTag,
|
||||
alignment: Alignment.center,
|
||||
animationType: SmartAnimationType.fade,
|
||||
maskColor: Colors.transparent,
|
||||
builder: (_) {
|
||||
final size = MediaQuery.sizeOf(context);
|
||||
return GestureDetector(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onTap: () => SmartDialog.dismiss(tag: _kStoreItemPreviewDialogTag),
|
||||
child: Container(
|
||||
width: size.width,
|
||||
height: size.height,
|
||||
color: Colors.black.withValues(alpha: 0.86),
|
||||
child: Center(
|
||||
child: IgnorePointer(child: _buildFullScreenPreview(size)),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
showStoreBagFullScreenResourcePreview(
|
||||
context,
|
||||
resource: res.propsResources,
|
||||
previewKind: previewKind,
|
||||
fit: previewFit,
|
||||
);
|
||||
}
|
||||
|
||||
@ -231,41 +266,6 @@ class SCStoreGridItemCard extends StatelessWidget {
|
||||
return child;
|
||||
}
|
||||
|
||||
Widget _buildFullScreenPreview(Size screenSize) {
|
||||
switch (previewKind) {
|
||||
case SCStoreItemPreviewKind.avatarFrame:
|
||||
return head(
|
||||
url: AccountStorage().getCurrentUser()?.userProfile?.userAvatar ?? "",
|
||||
width: screenSize.width * 0.58,
|
||||
height: screenSize.width * 0.58,
|
||||
headdress: res.propsResources?.sourceUrl,
|
||||
headdressCover: res.propsResources?.cover,
|
||||
);
|
||||
case SCStoreItemPreviewKind.dataCard:
|
||||
final width = screenSize.width * 0.94;
|
||||
return ClipRRect(
|
||||
borderRadius: BorderRadius.circular(14.w),
|
||||
child: SizedBox(
|
||||
width: width,
|
||||
height: screenSize.height * 0.72,
|
||||
child: SCDataCardResourceView(
|
||||
resource: res.propsResources,
|
||||
fit: BoxFit.contain,
|
||||
),
|
||||
),
|
||||
);
|
||||
case SCStoreItemPreviewKind.resource:
|
||||
return SizedBox(
|
||||
width: screenSize.width * 0.86,
|
||||
height: screenSize.height * 0.42,
|
||||
child: SCDataCardResourceView(
|
||||
resource: res.propsResources,
|
||||
fit: previewFit,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Widget _buildPriceRow(int amount, PropsPrices? firstPrice) {
|
||||
return SizedBox(
|
||||
width: double.infinity,
|
||||
@ -343,6 +343,278 @@ class SCStoreGridItemCard extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
class _StoreBagFullScreenPreview extends StatelessWidget {
|
||||
const _StoreBagFullScreenPreview({
|
||||
this.resource,
|
||||
this.sourceUrl,
|
||||
this.coverUrl,
|
||||
required this.previewKind,
|
||||
required this.fit,
|
||||
});
|
||||
|
||||
final PropsResources? resource;
|
||||
final String? sourceUrl;
|
||||
final String? coverUrl;
|
||||
final SCStoreItemPreviewKind previewKind;
|
||||
final BoxFit fit;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final source = _firstNonBlank([sourceUrl, resource?.sourceUrl]);
|
||||
if (previewKind == SCStoreItemPreviewKind.avatarFrame &&
|
||||
!_isMp4Like(source)) {
|
||||
final screenSize = MediaQuery.sizeOf(context);
|
||||
return Center(
|
||||
child: head(
|
||||
url: AccountStorage().getCurrentUser()?.userProfile?.userAvatar ?? "",
|
||||
width: screenSize.width * 0.58,
|
||||
height: screenSize.width * 0.58,
|
||||
headdress: source,
|
||||
headdressCover: _firstNonBlank([coverUrl, resource?.cover]),
|
||||
),
|
||||
);
|
||||
}
|
||||
return _StoreBagFullScreenResourceView(
|
||||
resource: resource,
|
||||
sourceUrl: sourceUrl,
|
||||
coverUrl: coverUrl,
|
||||
fit: fit,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _StoreBagFullScreenResourceView extends StatelessWidget {
|
||||
const _StoreBagFullScreenResourceView({
|
||||
this.resource,
|
||||
this.sourceUrl,
|
||||
this.coverUrl,
|
||||
required this.fit,
|
||||
});
|
||||
|
||||
final PropsResources? resource;
|
||||
final String? sourceUrl;
|
||||
final String? coverUrl;
|
||||
final BoxFit fit;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final width = _resolvedPreviewSize(constraints.maxWidth);
|
||||
final height = _resolvedPreviewSize(constraints.maxHeight);
|
||||
final source = _firstNonBlank([sourceUrl, resource?.sourceUrl]);
|
||||
final cover = _firstNonBlank([coverUrl, resource?.cover]);
|
||||
final resourceUrl = source.isNotEmpty ? source : cover;
|
||||
final fallback = _buildPreviewFallback(
|
||||
cover,
|
||||
width: width,
|
||||
height: height,
|
||||
fit: fit,
|
||||
);
|
||||
|
||||
if (_isMp4Like(resourceUrl) &&
|
||||
!kIsWeb &&
|
||||
defaultTargetPlatform == TargetPlatform.android) {
|
||||
return _StoreBagMp4FullScreenPreview(
|
||||
sourceUrl: resourceUrl,
|
||||
fallback: fallback,
|
||||
loop: true,
|
||||
);
|
||||
}
|
||||
|
||||
return SCDataCardResourceView(
|
||||
resource: resource,
|
||||
sourceUrl: source.isNotEmpty ? source : resourceUrl,
|
||||
coverUrl: cover,
|
||||
width: width,
|
||||
height: height,
|
||||
fit: fit,
|
||||
loop: true,
|
||||
fallback: fallback,
|
||||
allowCoverAsResource: true,
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _StoreBagMp4FullScreenPreview extends StatefulWidget {
|
||||
const _StoreBagMp4FullScreenPreview({
|
||||
required this.sourceUrl,
|
||||
required this.fallback,
|
||||
required this.loop,
|
||||
});
|
||||
|
||||
final String sourceUrl;
|
||||
final Widget fallback;
|
||||
final bool loop;
|
||||
|
||||
@override
|
||||
State<_StoreBagMp4FullScreenPreview> createState() =>
|
||||
_StoreBagMp4FullScreenPreviewState();
|
||||
}
|
||||
|
||||
class _StoreBagMp4FullScreenPreviewState
|
||||
extends State<_StoreBagMp4FullScreenPreview> {
|
||||
late final SCGiftMp4Controller _controller;
|
||||
int _playToken = 0;
|
||||
bool _started = false;
|
||||
bool _failed = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_controller = SCGiftMp4Controller();
|
||||
_controller.setPlaybackListener(
|
||||
onStart: () {
|
||||
if (!mounted || _started) {
|
||||
return;
|
||||
}
|
||||
setState(() => _started = true);
|
||||
},
|
||||
onComplete: _handleComplete,
|
||||
onFailed: (_) => _markFailed(),
|
||||
);
|
||||
unawaited(_play());
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(covariant _StoreBagMp4FullScreenPreview oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
if (oldWidget.sourceUrl != widget.sourceUrl ||
|
||||
oldWidget.loop != widget.loop) {
|
||||
_playToken++;
|
||||
_controller.stop();
|
||||
setState(() {
|
||||
_started = false;
|
||||
_failed = false;
|
||||
});
|
||||
unawaited(_play());
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _play() async {
|
||||
final source = widget.sourceUrl.trim();
|
||||
if (source.isEmpty) {
|
||||
_markFailed();
|
||||
return;
|
||||
}
|
||||
final token = ++_playToken;
|
||||
try {
|
||||
final pathType = SCPathUtils.getPathType(source);
|
||||
if (pathType == PathType.network) {
|
||||
final file = await FileCacheManager.getInstance().getFile(url: source);
|
||||
if (!_isCurrent(token)) {
|
||||
return;
|
||||
}
|
||||
await _controller.setMute(true);
|
||||
await _controller.playFile(file.path);
|
||||
return;
|
||||
}
|
||||
if (pathType == PathType.asset) {
|
||||
if (!_isCurrent(token)) {
|
||||
return;
|
||||
}
|
||||
await _controller.setMute(true);
|
||||
await _controller.playAsset(source);
|
||||
return;
|
||||
}
|
||||
if (!_isCurrent(token)) {
|
||||
return;
|
||||
}
|
||||
await _controller.setMute(true);
|
||||
await _controller.playFile(_normalizeFilePath(source));
|
||||
} catch (_) {
|
||||
if (_isCurrent(token)) {
|
||||
_markFailed();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void _handleComplete() {
|
||||
if (!mounted || _failed || !widget.loop) {
|
||||
return;
|
||||
}
|
||||
setState(() => _started = false);
|
||||
unawaited(Future<void>.delayed(Duration.zero, _play));
|
||||
}
|
||||
|
||||
bool _isCurrent(int token) => mounted && token == _playToken && !_failed;
|
||||
|
||||
void _markFailed() {
|
||||
if (!mounted || _failed) {
|
||||
return;
|
||||
}
|
||||
setState(() {
|
||||
_failed = true;
|
||||
_started = false;
|
||||
});
|
||||
}
|
||||
|
||||
String _normalizeFilePath(String path) {
|
||||
final uri = Uri.tryParse(path);
|
||||
if (uri != null && uri.scheme == 'file') {
|
||||
return uri.toFilePath();
|
||||
}
|
||||
return path;
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_playToken++;
|
||||
_controller.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
if (!_started || _failed) widget.fallback,
|
||||
if (!_failed) GiftMp4EffectView(controller: _controller),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Widget _buildPreviewFallback(
|
||||
String coverUrl, {
|
||||
double? width,
|
||||
double? height,
|
||||
required BoxFit fit,
|
||||
}) {
|
||||
if (coverUrl.trim().isEmpty) {
|
||||
return SizedBox(width: width, height: height);
|
||||
}
|
||||
return netImage(
|
||||
url: coverUrl.trim(),
|
||||
width: width,
|
||||
height: height,
|
||||
fit: fit,
|
||||
noDefaultImg: true,
|
||||
);
|
||||
}
|
||||
|
||||
String _firstNonBlank(Iterable<String?> values) {
|
||||
for (final value in values) {
|
||||
final text = value?.trim() ?? "";
|
||||
if (text.isNotEmpty) {
|
||||
return text;
|
||||
}
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
bool _isMp4Like(String? url) {
|
||||
final extension = SCPathUtils.getFileExtension(url ?? "").toLowerCase();
|
||||
return extension == ".mp4" || extension == ".vap";
|
||||
}
|
||||
|
||||
double? _resolvedPreviewSize(double value) {
|
||||
return value.isFinite && value > 0 ? value : null;
|
||||
}
|
||||
|
||||
class SCBagGridSkeleton extends StatelessWidget {
|
||||
const SCBagGridSkeleton({
|
||||
super.key,
|
||||
@ -454,12 +726,13 @@ Widget _buildBagCardSkeleton(double progress, {required bool showTitle}) {
|
||||
),
|
||||
),
|
||||
Column(
|
||||
mainAxisSize: showTitle ? MainAxisSize.max : MainAxisSize.min,
|
||||
children: [
|
||||
SizedBox(height: 24.w),
|
||||
if (showTitle) SizedBox(height: 24.w),
|
||||
_buildStoreBagSkeletonBone(
|
||||
progress,
|
||||
width: 55.w,
|
||||
height: 55.w,
|
||||
width: 82.w,
|
||||
height: 82.w,
|
||||
borderRadius: BorderRadius.circular(12.w),
|
||||
),
|
||||
if (showTitle) ...[
|
||||
|
||||
494
pubspec.lock
494
pubspec.lock
File diff suppressed because it is too large
Load Diff
Loading…
x
Reference in New Issue
Block a user