增加mp4的解析展示

This commit is contained in:
zhx 2026-05-18 19:04:56 +08:00
parent f03b6c605f
commit 071ab9ffed
22 changed files with 2491 additions and 568 deletions

View File

@ -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")

View File

@ -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)
}
}
}
}

View File

@ -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,
)
}
}
}

View File

@ -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)
}
}

View File

@ -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) {

Binary file not shown.

View File

@ -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/'; //

View 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'),
),
],
),
),
],
),
);
}
}

View File

@ -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) {

View File

@ -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,
);
},
);
}

View File

@ -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,
);
},
);
}

View File

@ -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: [

View File

@ -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,
);
},
);
}

View File

@ -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,
);
},
);
}

View File

@ -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,
);
},
);
}

View File

@ -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, {

View File

@ -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;
}
}

View File

@ -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;
}

View 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),
),
);
}
}

View File

@ -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,
),
),
),
],
),
),
);
}
}

View File

@ -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) ...[

File diff suppressed because it is too large Load Diff