Compare commits
16 Commits
d67b3181b0
...
071ab9ffed
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
071ab9ffed | ||
|
|
f03b6c605f | ||
|
|
b5cb66997b | ||
|
|
a800a0f941 | ||
|
|
2684fd4673 | ||
|
|
9294f2a08f | ||
|
|
8e3798e85c | ||
|
|
27c89a9f9e | ||
|
|
5be1f05899 | ||
|
|
79ec634ba0 | ||
|
|
5a44ec0c6d | ||
|
|
c2b5ef9e16 | ||
|
|
d5653afa4f | ||
|
|
660f712ae6 | ||
|
|
d009f9cc01 | ||
|
|
9f012fdb27 |
@ -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) {
|
||||
|
||||
@ -1,17 +1,10 @@
|
||||
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_localizations.dart';
|
||||
import 'package:yumi/ui_kit/components/sc_compontent.dart';
|
||||
import 'package:yumi/ui_kit/components/sc_tts.dart';
|
||||
import 'package:yumi/app/constants/sc_global_config.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import 'package:yumi/ui_kit/components/sc_page_list.dart';
|
||||
import 'package:yumi/shared/tools/sc_loading_manager.dart';
|
||||
import 'package:yumi/shared/data_sources/sources/repositories/sc_store_repository_imp.dart';
|
||||
import 'package:yumi/shared/business_logic/models/res/store_list_res.dart';
|
||||
import 'package:yumi/services/auth/user_profile_manager.dart';
|
||||
import 'package:yumi/ui_kit/widgets/store/props_store_chatbox_detail_dialog.dart';
|
||||
import 'package:yumi/ui_kit/widgets/store/store_bag_page_helpers.dart';
|
||||
|
||||
@ -20,8 +13,10 @@ import '../../../shared/data_sources/models/enum/sc_props_type.dart';
|
||||
|
||||
///聊天气泡框
|
||||
class StoreChatboxPage extends SCPageList {
|
||||
const StoreChatboxPage({super.key});
|
||||
|
||||
@override
|
||||
_StoreChatboxPageState createState() => _StoreChatboxPageState();
|
||||
SCPageListState createState() => _StoreChatboxPageState();
|
||||
}
|
||||
|
||||
class _StoreChatboxPageState
|
||||
@ -41,7 +36,7 @@ class _StoreChatboxPageState
|
||||
crossAxisCount: gridViewCount,
|
||||
mainAxisSpacing: 12.w,
|
||||
crossAxisSpacing: 12.w,
|
||||
childAspectRatio: 0.78,
|
||||
childAspectRatio: 0.88,
|
||||
);
|
||||
loadData(1);
|
||||
}
|
||||
@ -59,100 +54,13 @@ class _StoreChatboxPageState
|
||||
|
||||
@override
|
||||
Widget buildItem(StoreListResBean res) {
|
||||
return GestureDetector(
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
color:
|
||||
res.isSelecte
|
||||
? const Color(0xff18F2B1).withValues(alpha: 0.1)
|
||||
: SCGlobalConfig.businessLogicStrategy
|
||||
.getStoreItemBackgroundColor(),
|
||||
border: Border.all(
|
||||
color:
|
||||
res.isSelecte
|
||||
? SCGlobalConfig.businessLogicStrategy
|
||||
.getStoreItemSelectedBorderColor()
|
||||
: SCGlobalConfig.businessLogicStrategy
|
||||
.getStoreItemUnselectedBorderColor(),
|
||||
width: 1.w,
|
||||
),
|
||||
borderRadius: BorderRadius.all(Radius.circular(8.w)),
|
||||
),
|
||||
child: Stack(
|
||||
children: [
|
||||
Column(
|
||||
children: [
|
||||
SizedBox(height: 10.w),
|
||||
netImage(
|
||||
url: res.res.propsResources?.cover ?? "",
|
||||
height: 45.w,
|
||||
fit: BoxFit.contain,
|
||||
),
|
||||
SizedBox(height: 10.w),
|
||||
Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
SizedBox(width: 10.w),
|
||||
Expanded(
|
||||
child: Text.rich(
|
||||
textAlign: TextAlign.center,
|
||||
TextSpan(
|
||||
children: [
|
||||
WidgetSpan(
|
||||
alignment: PlaceholderAlignment.middle,
|
||||
child: Image.asset(
|
||||
SCGlobalConfig.businessLogicStrategy
|
||||
.getStoreItemGoldIcon(),
|
||||
width: 22.w,
|
||||
),
|
||||
),
|
||||
TextSpan(text: " "),
|
||||
disCount < 1
|
||||
? TextSpan(
|
||||
text: "${res.res.propsPrices![0].amount}",
|
||||
style: TextStyle(
|
||||
fontSize: 12.sp,
|
||||
color:
|
||||
SCGlobalConfig.businessLogicStrategy
|
||||
.getStoreItemPriceTextColor(),
|
||||
fontWeight: FontWeight.w600,
|
||||
decoration: TextDecoration.lineThrough,
|
||||
),
|
||||
)
|
||||
: TextSpan(),
|
||||
disCount < 1 ? TextSpan(text: " ") : TextSpan(),
|
||||
TextSpan(
|
||||
text:
|
||||
"${((res.res.propsPrices![0].amount ?? 0) * disCount).toInt()}/${res.res.propsPrices![0].days} ${SCAppLocalizations.of(context)!.day}",
|
||||
style: TextStyle(
|
||||
fontSize: 12.sp,
|
||||
color:
|
||||
SCGlobalConfig.businessLogicStrategy
|
||||
.getStoreItemPriceTextColor(),
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
strutStyle: StrutStyle(
|
||||
height: 1.3, // 行高倍数
|
||||
fontWeight: FontWeight.w500,
|
||||
forceStrutHeight: true, // 强制应用行高
|
||||
),
|
||||
),
|
||||
),
|
||||
SizedBox(width: 10.w),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
onTap: () {
|
||||
_selectItem(res);
|
||||
_showDetail(res.res);
|
||||
},
|
||||
return SCStoreGridItemCard(
|
||||
res: res.res,
|
||||
discount: disCount,
|
||||
coverHeight: 45.w,
|
||||
previewWidth: 88.w,
|
||||
previewHeight: 58.w,
|
||||
onDetailsTap: () => _showDetail(res.res),
|
||||
);
|
||||
}
|
||||
|
||||
@ -190,40 +98,6 @@ class _StoreChatboxPageState
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
void _selectItem(StoreListResBean res) {
|
||||
for (var value in items) {
|
||||
value.isSelecte = false;
|
||||
}
|
||||
res.isSelecte = true;
|
||||
setState(() {});
|
||||
}
|
||||
|
||||
void _buy(StoreListResBean res) {
|
||||
if (res.res.propsPrices!.isEmpty) {
|
||||
return;
|
||||
}
|
||||
SCLoadingManager.show();
|
||||
num days = res.res.propsPrices![0].days ?? 0;
|
||||
SCStoreRepositoryImp()
|
||||
.storePurchasing(
|
||||
res.res.id ?? "",
|
||||
SCPropsType.CHAT_BUBBLE.name,
|
||||
SCCurrencyType.GOLD.name,
|
||||
"$days",
|
||||
)
|
||||
.then((value) {
|
||||
SCTts.show(SCAppLocalizations.of(context)!.purchaseIsSuccessful);
|
||||
Provider.of<SocialChatUserProfileManager>(
|
||||
context,
|
||||
listen: false,
|
||||
).updateBalance(value);
|
||||
SCLoadingManager.hide();
|
||||
})
|
||||
.catchError((e) {
|
||||
SCLoadingManager.hide();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
class StoreListResBean {
|
||||
|
||||
@ -1,13 +1,10 @@
|
||||
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/store_list_res.dart';
|
||||
import 'package:yumi/shared/data_sources/models/enum/sc_currency_type.dart';
|
||||
import 'package:yumi/shared/data_sources/models/enum/sc_props_type.dart';
|
||||
import 'package:yumi/shared/data_sources/sources/repositories/sc_store_repository_imp.dart';
|
||||
import 'package:yumi/ui_kit/components/sc_compontent.dart';
|
||||
import 'package:yumi/ui_kit/components/sc_page_list.dart';
|
||||
import 'package:yumi/ui_kit/widgets/store/props_store_data_card_detail_dialog.dart';
|
||||
import 'package:yumi/ui_kit/widgets/store/store_bag_page_helpers.dart';
|
||||
@ -35,7 +32,7 @@ class StoreDataCardPageState
|
||||
crossAxisCount: gridViewCount,
|
||||
mainAxisSpacing: 12.w,
|
||||
crossAxisSpacing: 12.w,
|
||||
childAspectRatio: 0.78,
|
||||
childAspectRatio: 0.88,
|
||||
);
|
||||
loadData(1);
|
||||
}
|
||||
@ -54,101 +51,18 @@ class StoreDataCardPageState
|
||||
@override
|
||||
Widget buildItem(StoreDataCardListResBean item) {
|
||||
final res = item;
|
||||
final prices = res.res.propsPrices ?? const <PropsPrices>[];
|
||||
final firstPrice = prices.isNotEmpty ? prices.first : null;
|
||||
return GestureDetector(
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
color:
|
||||
res.isSelecte
|
||||
? const Color(0xff18F2B1).withValues(alpha: 0.1)
|
||||
: SCGlobalConfig.businessLogicStrategy
|
||||
.getStoreItemBackgroundColor(),
|
||||
border: Border.all(
|
||||
color:
|
||||
res.isSelecte
|
||||
? SCGlobalConfig.businessLogicStrategy
|
||||
.getStoreItemSelectedBorderColor()
|
||||
: SCGlobalConfig.businessLogicStrategy
|
||||
.getStoreItemUnselectedBorderColor(),
|
||||
width: 1.w,
|
||||
),
|
||||
borderRadius: BorderRadius.all(Radius.circular(8.w)),
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
SizedBox(height: 10.w),
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(6.w),
|
||||
child: netImage(
|
||||
url: res.res.propsResources?.cover ?? "",
|
||||
width: 72.w,
|
||||
height: 50.w,
|
||||
fit: BoxFit.cover,
|
||||
),
|
||||
),
|
||||
SizedBox(height: 12.w),
|
||||
Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
SizedBox(width: 10.w),
|
||||
Expanded(
|
||||
child: Text.rich(
|
||||
textAlign: TextAlign.center,
|
||||
TextSpan(
|
||||
children: [
|
||||
WidgetSpan(
|
||||
alignment: PlaceholderAlignment.middle,
|
||||
child: Image.asset(
|
||||
SCGlobalConfig.businessLogicStrategy
|
||||
.getStoreItemGoldIcon(),
|
||||
width: 22.w,
|
||||
),
|
||||
),
|
||||
const TextSpan(text: " "),
|
||||
if (disCount < 1)
|
||||
TextSpan(
|
||||
text: "${firstPrice?.amount ?? 0}",
|
||||
style: TextStyle(
|
||||
fontSize: 12.sp,
|
||||
color:
|
||||
SCGlobalConfig.businessLogicStrategy
|
||||
.getStoreItemPriceTextColor(),
|
||||
fontWeight: FontWeight.w600,
|
||||
decoration: TextDecoration.lineThrough,
|
||||
),
|
||||
),
|
||||
if (disCount < 1) const TextSpan(text: " "),
|
||||
TextSpan(
|
||||
text:
|
||||
"${((firstPrice?.amount ?? 0) * disCount).toInt()}/${firstPrice?.days ?? 0} ${SCAppLocalizations.of(context)!.day}",
|
||||
style: TextStyle(
|
||||
fontSize: 12.sp,
|
||||
color:
|
||||
SCGlobalConfig.businessLogicStrategy
|
||||
.getStoreItemPriceTextColor(),
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
strutStyle: const StrutStyle(
|
||||
height: 1.3,
|
||||
fontWeight: FontWeight.w500,
|
||||
forceStrutHeight: true,
|
||||
),
|
||||
),
|
||||
),
|
||||
SizedBox(width: 10.w),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
onTap: () {
|
||||
_selectItem(res);
|
||||
_showDetail(res.res);
|
||||
},
|
||||
return SCStoreGridItemCard(
|
||||
res: res.res,
|
||||
discount: disCount,
|
||||
previewKind: SCStoreItemPreviewKind.dataCard,
|
||||
coverWidth: 72.w,
|
||||
coverHeight: 50.w,
|
||||
previewWidth: 88.w,
|
||||
previewHeight: 58.w,
|
||||
coverFit: BoxFit.cover,
|
||||
previewFit: BoxFit.cover,
|
||||
coverBorderRadius: BorderRadius.circular(6.w),
|
||||
onDetailsTap: () => _showDetail(res.res),
|
||||
);
|
||||
}
|
||||
|
||||
@ -183,14 +97,6 @@ class StoreDataCardPageState
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
void _selectItem(StoreDataCardListResBean res) {
|
||||
for (final value in items) {
|
||||
value.isSelecte = false;
|
||||
}
|
||||
res.isSelecte = true;
|
||||
setState(() {});
|
||||
}
|
||||
}
|
||||
|
||||
class StoreDataCardListResBean {
|
||||
|
||||
@ -1,16 +1,9 @@
|
||||
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_localizations.dart';
|
||||
import 'package:yumi/ui_kit/components/sc_compontent.dart';
|
||||
import 'package:yumi/ui_kit/components/sc_tts.dart';
|
||||
import 'package:yumi/app/constants/sc_global_config.dart';
|
||||
import 'package:yumi/shared/tools/sc_loading_manager.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:yumi/ui_kit/components/sc_page_list.dart';
|
||||
import 'package:yumi/shared/data_sources/sources/repositories/sc_store_repository_imp.dart';
|
||||
import 'package:yumi/shared/business_logic/models/res/store_list_res.dart';
|
||||
import 'package:yumi/services/auth/user_profile_manager.dart';
|
||||
import 'package:yumi/ui_kit/widgets/store/props_store_headdress_detail_dialog.dart';
|
||||
import 'package:yumi/ui_kit/widgets/store/store_bag_page_helpers.dart';
|
||||
|
||||
@ -19,8 +12,10 @@ import '../../../shared/data_sources/models/enum/sc_props_type.dart';
|
||||
|
||||
///头饰
|
||||
class StoreHeaddressPage extends SCPageList {
|
||||
const StoreHeaddressPage({super.key});
|
||||
|
||||
@override
|
||||
_StoreHeaddressPageState createState() => _StoreHeaddressPageState();
|
||||
SCPageListState createState() => _StoreHeaddressPageState();
|
||||
}
|
||||
|
||||
class _StoreHeaddressPageState
|
||||
@ -40,7 +35,7 @@ class _StoreHeaddressPageState
|
||||
crossAxisCount: gridViewCount,
|
||||
mainAxisSpacing: 12.w,
|
||||
crossAxisSpacing: 12.w,
|
||||
childAspectRatio: 0.78,
|
||||
childAspectRatio: 0.88,
|
||||
);
|
||||
loadData(1);
|
||||
}
|
||||
@ -58,100 +53,11 @@ class _StoreHeaddressPageState
|
||||
|
||||
@override
|
||||
Widget buildItem(StoreListResBean res) {
|
||||
return GestureDetector(
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
color:
|
||||
res.isSelecte
|
||||
? const Color(0xff18F2B1).withValues(alpha: 0.1)
|
||||
: SCGlobalConfig.businessLogicStrategy
|
||||
.getStoreItemBackgroundColor(),
|
||||
border: Border.all(
|
||||
color:
|
||||
res.isSelecte
|
||||
? SCGlobalConfig.businessLogicStrategy
|
||||
.getStoreItemSelectedBorderColor()
|
||||
: SCGlobalConfig.businessLogicStrategy
|
||||
.getStoreItemUnselectedBorderColor(),
|
||||
width: 1.w,
|
||||
),
|
||||
borderRadius: BorderRadius.all(Radius.circular(8.w)),
|
||||
),
|
||||
child: Stack(
|
||||
children: [
|
||||
Column(
|
||||
children: [
|
||||
SizedBox(height: 10.w),
|
||||
netImage(
|
||||
url: res.res.propsResources?.cover ?? "",
|
||||
width: 55.w,
|
||||
height: 55.w,
|
||||
),
|
||||
SizedBox(height: 10.w),
|
||||
Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
SizedBox(width: 10.w),
|
||||
Expanded(
|
||||
child: Text.rich(
|
||||
textAlign: TextAlign.center,
|
||||
TextSpan(
|
||||
children: [
|
||||
WidgetSpan(
|
||||
alignment: PlaceholderAlignment.middle,
|
||||
child: Image.asset(
|
||||
SCGlobalConfig.businessLogicStrategy
|
||||
.getStoreItemGoldIcon(),
|
||||
width: 22.w,
|
||||
),
|
||||
),
|
||||
TextSpan(text: " "),
|
||||
disCount < 1
|
||||
? TextSpan(
|
||||
text: "${res.res.propsPrices![0].amount}",
|
||||
style: TextStyle(
|
||||
fontSize: 12.sp,
|
||||
color:
|
||||
SCGlobalConfig.businessLogicStrategy
|
||||
.getStoreItemPriceTextColor(),
|
||||
fontWeight: FontWeight.w600,
|
||||
decoration: TextDecoration.lineThrough,
|
||||
),
|
||||
)
|
||||
: TextSpan(),
|
||||
disCount < 1 ? TextSpan(text: " ") : TextSpan(),
|
||||
TextSpan(
|
||||
text:
|
||||
"${((res.res.propsPrices![0].amount ?? 0) * disCount).toInt()}/${res.res.propsPrices![0].days} ${SCAppLocalizations.of(context)!.day}",
|
||||
style: TextStyle(
|
||||
fontSize: 12.sp,
|
||||
color:
|
||||
SCGlobalConfig.businessLogicStrategy
|
||||
.getStoreItemPriceTextColor(),
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
strutStyle: StrutStyle(
|
||||
height: 1.3, // 行高倍数
|
||||
fontWeight: FontWeight.w500,
|
||||
forceStrutHeight: true, // 强制应用行高
|
||||
),
|
||||
),
|
||||
),
|
||||
SizedBox(width: 10.w),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
onTap: () {
|
||||
_selectItem(res);
|
||||
_showDetail(res.res);
|
||||
},
|
||||
return SCStoreGridItemCard(
|
||||
res: res.res,
|
||||
discount: disCount,
|
||||
previewKind: SCStoreItemPreviewKind.avatarFrame,
|
||||
onDetailsTap: () => _showDetail(res.res),
|
||||
);
|
||||
}
|
||||
|
||||
@ -189,40 +95,6 @@ class _StoreHeaddressPageState
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
void _selectItem(StoreListResBean res) {
|
||||
for (var value in items) {
|
||||
value.isSelecte = false;
|
||||
}
|
||||
res.isSelecte = true;
|
||||
setState(() {});
|
||||
}
|
||||
|
||||
void _buy(StoreListResBean res) {
|
||||
if (res.res.propsPrices!.isEmpty) {
|
||||
return;
|
||||
}
|
||||
SCLoadingManager.show();
|
||||
num days = res.res.propsPrices![0].days ?? 0;
|
||||
SCStoreRepositoryImp()
|
||||
.storePurchasing(
|
||||
res.res.id ?? "",
|
||||
SCPropsType.AVATAR_FRAME.name,
|
||||
SCCurrencyType.GOLD.name,
|
||||
"$days",
|
||||
)
|
||||
.then((value) {
|
||||
SCTts.show(SCAppLocalizations.of(context)!.purchaseIsSuccessful);
|
||||
Provider.of<SocialChatUserProfileManager>(
|
||||
context,
|
||||
listen: false,
|
||||
).updateBalance(value);
|
||||
SCLoadingManager.hide();
|
||||
})
|
||||
.catchError((e) {
|
||||
SCLoadingManager.hide();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
class StoreListResBean {
|
||||
|
||||
@ -1,10 +1,7 @@
|
||||
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_localizations.dart';
|
||||
import 'package:yumi/ui_kit/components/sc_compontent.dart';
|
||||
import 'package:yumi/ui_kit/components/sc_page_list.dart';
|
||||
import 'package:yumi/app/constants/sc_global_config.dart';
|
||||
import 'package:yumi/shared/data_sources/sources/repositories/sc_store_repository_imp.dart';
|
||||
import 'package:yumi/shared/business_logic/models/res/store_list_res.dart';
|
||||
import 'package:yumi/ui_kit/widgets/store/props_store_mountains_detail_dialog.dart';
|
||||
@ -16,10 +13,10 @@ import '../../../shared/data_sources/models/enum/sc_props_type.dart';
|
||||
|
||||
///坐骑
|
||||
class StoreMountainsPage extends SCPageList {
|
||||
StoreMountainsPage();
|
||||
const StoreMountainsPage({super.key});
|
||||
|
||||
@override
|
||||
_StoreMountainsPageState createState() => _StoreMountainsPageState();
|
||||
SCPageListState createState() => _StoreMountainsPageState();
|
||||
}
|
||||
|
||||
class _StoreMountainsPageState
|
||||
@ -39,16 +36,11 @@ class _StoreMountainsPageState
|
||||
crossAxisCount: gridViewCount,
|
||||
mainAxisSpacing: 12.w,
|
||||
crossAxisSpacing: 12.w,
|
||||
childAspectRatio: 0.78,
|
||||
childAspectRatio: 0.88,
|
||||
);
|
||||
loadData(1);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
@ -62,100 +54,10 @@ class _StoreMountainsPageState
|
||||
|
||||
@override
|
||||
Widget buildItem(StoreListResBean res) {
|
||||
return GestureDetector(
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
color:
|
||||
res.isSelecte
|
||||
? const Color(0xff18F2B1).withValues(alpha: 0.1)
|
||||
: SCGlobalConfig.businessLogicStrategy
|
||||
.getStoreItemBackgroundColor(),
|
||||
border: Border.all(
|
||||
color:
|
||||
res.isSelecte
|
||||
? SCGlobalConfig.businessLogicStrategy
|
||||
.getStoreItemSelectedBorderColor()
|
||||
: SCGlobalConfig.businessLogicStrategy
|
||||
.getStoreItemUnselectedBorderColor(),
|
||||
width: 1.w,
|
||||
),
|
||||
borderRadius: BorderRadius.all(Radius.circular(8.w)),
|
||||
),
|
||||
child: Stack(
|
||||
children: [
|
||||
Column(
|
||||
children: [
|
||||
SizedBox(height: 10.w),
|
||||
netImage(
|
||||
url: res.res.propsResources?.cover ?? "",
|
||||
width: 55.w,
|
||||
height: 55.w,
|
||||
),
|
||||
SizedBox(height: 10.w),
|
||||
Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
SizedBox(width: 10.w),
|
||||
Expanded(
|
||||
child: Text.rich(
|
||||
textAlign: TextAlign.center,
|
||||
TextSpan(
|
||||
children: [
|
||||
WidgetSpan(
|
||||
alignment: PlaceholderAlignment.middle,
|
||||
child: Image.asset(
|
||||
SCGlobalConfig.businessLogicStrategy
|
||||
.getStoreItemGoldIcon(),
|
||||
width: 22.w,
|
||||
),
|
||||
),
|
||||
TextSpan(text: " "),
|
||||
disCount < 1
|
||||
? TextSpan(
|
||||
text: "${res.res.propsPrices![0].amount}",
|
||||
style: TextStyle(
|
||||
fontSize: 12.sp,
|
||||
color:
|
||||
SCGlobalConfig.businessLogicStrategy
|
||||
.getStoreItemPriceTextColor(),
|
||||
fontWeight: FontWeight.w600,
|
||||
decoration: TextDecoration.lineThrough,
|
||||
),
|
||||
)
|
||||
: TextSpan(),
|
||||
disCount < 1 ? TextSpan(text: " ") : TextSpan(),
|
||||
TextSpan(
|
||||
text:
|
||||
"${((res.res.propsPrices![0].amount ?? 0) * disCount).toInt()}/${res.res.propsPrices![0].days} ${SCAppLocalizations.of(context)!.day}",
|
||||
style: TextStyle(
|
||||
fontSize: 12.sp,
|
||||
color:
|
||||
SCGlobalConfig.businessLogicStrategy
|
||||
.getStoreItemPriceTextColor(),
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
strutStyle: StrutStyle(
|
||||
height: 1.3, // 行高倍数
|
||||
fontWeight: FontWeight.w500,
|
||||
forceStrutHeight: true, // 强制应用行高
|
||||
),
|
||||
),
|
||||
),
|
||||
SizedBox(width: 10.w),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
onTap: () {
|
||||
_selectItem(res);
|
||||
_showDetail(res.res);
|
||||
},
|
||||
return SCStoreGridItemCard(
|
||||
res: res.res,
|
||||
discount: disCount,
|
||||
onDetailsTap: () => _showDetail(res.res),
|
||||
);
|
||||
}
|
||||
|
||||
@ -193,12 +95,4 @@ class _StoreMountainsPageState
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
void _selectItem(StoreListResBean res) {
|
||||
for (var value in items) {
|
||||
value.isSelecte = false;
|
||||
}
|
||||
res.isSelecte = true;
|
||||
setState(() {});
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,16 +1,10 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_screenutil/flutter_screenutil.dart';
|
||||
import 'package:flutter_smart_dialog/flutter_smart_dialog.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import 'package:yumi/app_localizations.dart';
|
||||
import 'package:yumi/ui_kit/components/sc_compontent.dart';
|
||||
import 'package:yumi/ui_kit/components/sc_page_list.dart';
|
||||
import 'package:yumi/ui_kit/components/sc_tts.dart';
|
||||
import 'package:yumi/app/constants/sc_global_config.dart';
|
||||
import 'package:yumi/shared/data_sources/sources/repositories/sc_store_repository_imp.dart';
|
||||
import 'package:yumi/shared/business_logic/models/res/store_list_res.dart';
|
||||
import 'package:yumi/services/auth/user_profile_manager.dart';
|
||||
import 'package:yumi/ui_kit/widgets/store/props_store_theme_detail_dialog.dart';
|
||||
import 'package:yumi/ui_kit/widgets/store/store_bag_page_helpers.dart';
|
||||
import 'package:yumi/modules/store/headdress/store_headdress_page.dart';
|
||||
@ -20,10 +14,10 @@ import '../../../shared/data_sources/models/enum/sc_props_type.dart';
|
||||
|
||||
///房间主题
|
||||
class StoreThemePage extends SCPageList {
|
||||
StoreThemePage();
|
||||
const StoreThemePage({super.key});
|
||||
|
||||
@override
|
||||
_StoreThemePagePageState createState() => _StoreThemePagePageState();
|
||||
SCPageListState createState() => _StoreThemePagePageState();
|
||||
}
|
||||
|
||||
class _StoreThemePagePageState
|
||||
@ -43,7 +37,7 @@ class _StoreThemePagePageState
|
||||
crossAxisCount: gridViewCount,
|
||||
mainAxisSpacing: 12.w,
|
||||
crossAxisSpacing: 12.w,
|
||||
childAspectRatio: 0.78,
|
||||
childAspectRatio: 0.88,
|
||||
);
|
||||
loadData(1);
|
||||
}
|
||||
@ -61,101 +55,11 @@ class _StoreThemePagePageState
|
||||
|
||||
@override
|
||||
Widget buildItem(StoreListResBean res) {
|
||||
return GestureDetector(
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
color:
|
||||
res.isSelecte
|
||||
? const Color(0xff18F2B1).withValues(alpha: 0.1)
|
||||
: SCGlobalConfig.businessLogicStrategy
|
||||
.getStoreItemBackgroundColor(),
|
||||
border: Border.all(
|
||||
color:
|
||||
res.isSelecte
|
||||
? SCGlobalConfig.businessLogicStrategy
|
||||
.getStoreItemSelectedBorderColor()
|
||||
: SCGlobalConfig.businessLogicStrategy
|
||||
.getStoreItemUnselectedBorderColor(),
|
||||
width: 1.w,
|
||||
),
|
||||
borderRadius: BorderRadius.all(Radius.circular(8.w)),
|
||||
),
|
||||
child: Stack(
|
||||
children: [
|
||||
Column(
|
||||
children: [
|
||||
SizedBox(height: 10.w),
|
||||
netImage(
|
||||
url: res.res.propsResources?.cover ?? "",
|
||||
width: 55.w,
|
||||
height: 55.w,
|
||||
borderRadius: BorderRadius.all(Radius.circular(8.w)),
|
||||
),
|
||||
SizedBox(height: 10.w),
|
||||
Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
SizedBox(width: 10.w),
|
||||
Expanded(
|
||||
child: Text.rich(
|
||||
textAlign: TextAlign.center,
|
||||
TextSpan(
|
||||
children: [
|
||||
WidgetSpan(
|
||||
alignment: PlaceholderAlignment.middle,
|
||||
child: Image.asset(
|
||||
SCGlobalConfig.businessLogicStrategy
|
||||
.getStoreItemGoldIcon(),
|
||||
width: 22.w,
|
||||
),
|
||||
),
|
||||
TextSpan(text: " "),
|
||||
disCount < 1
|
||||
? TextSpan(
|
||||
text: "${res.res.propsPrices![0].amount}",
|
||||
style: TextStyle(
|
||||
fontSize: 12.sp,
|
||||
color:
|
||||
SCGlobalConfig.businessLogicStrategy
|
||||
.getStoreItemPriceTextColor(),
|
||||
fontWeight: FontWeight.w600,
|
||||
decoration: TextDecoration.lineThrough,
|
||||
),
|
||||
)
|
||||
: TextSpan(),
|
||||
disCount < 1 ? TextSpan(text: " ") : TextSpan(),
|
||||
TextSpan(
|
||||
text:
|
||||
"${((res.res.propsPrices![0].amount ?? 0) * disCount).toInt()}/${res.res.propsPrices![0].days} ${SCAppLocalizations.of(context)!.day}",
|
||||
style: TextStyle(
|
||||
fontSize: 12.sp,
|
||||
color:
|
||||
SCGlobalConfig.businessLogicStrategy
|
||||
.getStoreItemPriceTextColor(),
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
strutStyle: StrutStyle(
|
||||
height: 1.3, // 行高倍数
|
||||
fontWeight: FontWeight.w500,
|
||||
forceStrutHeight: true, // 强制应用行高
|
||||
),
|
||||
),
|
||||
),
|
||||
SizedBox(width: 10.w),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
onTap: () {
|
||||
_selectItem(res);
|
||||
_showDetail(res.res);
|
||||
},
|
||||
return SCStoreGridItemCard(
|
||||
res: res.res,
|
||||
discount: disCount,
|
||||
coverBorderRadius: BorderRadius.all(Radius.circular(8.w)),
|
||||
onDetailsTap: () => _showDetail(res.res),
|
||||
);
|
||||
}
|
||||
|
||||
@ -193,35 +97,4 @@ class _StoreThemePagePageState
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void _selectItem(StoreListResBean res) {
|
||||
for (var value in items) {
|
||||
value.isSelecte = false;
|
||||
}
|
||||
res.isSelecte = true;
|
||||
setState(() {});
|
||||
}
|
||||
|
||||
void _buy(StoreListResBean res) {
|
||||
num days = 0;
|
||||
if (res.res.propsPrices!.length > 1) {
|
||||
days = res.res.propsPrices![1].days ?? 0;
|
||||
} else {
|
||||
days = res.res.propsPrices![0].days ?? 0;
|
||||
}
|
||||
SCStoreRepositoryImp()
|
||||
.storePurchasing(
|
||||
res.res.id ?? "",
|
||||
SCPropsType.THEME.name,
|
||||
SCCurrencyType.GOLD.name,
|
||||
"$days",
|
||||
)
|
||||
.then((value) {
|
||||
SCTts.show(SCAppLocalizations.of(context)!.purchaseIsSuccessful);
|
||||
Provider.of<SocialChatUserProfileManager>(
|
||||
context,
|
||||
listen: false,
|
||||
).updateBalance(value);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@ -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,
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@ -436,6 +436,29 @@ class _PersonDetailPageState extends State<PersonDetailPage> {
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildHeaderLongBadgeStrip(SocialChatUserProfileManager ref) {
|
||||
return SizedBox(
|
||||
width: ScreenUtil().screenWidth - 40.w,
|
||||
child: SCUserBadgeStrip(
|
||||
userId: ref.userProfile?.id ?? widget.tageId,
|
||||
fallbackBadges:
|
||||
ref.userProfile?.wearBadge
|
||||
?.where((item) => item.use ?? false)
|
||||
.toList() ??
|
||||
const <WearBadge>[],
|
||||
displayScope: SCUserBadgeDisplayScope.long,
|
||||
height: 45.w,
|
||||
badgeHeight: 20.w,
|
||||
longBadgeWidth: 62.w,
|
||||
spacing: 4.w,
|
||||
wrap: true,
|
||||
maxWrapRows: 2,
|
||||
scrollLastWrapRow: true,
|
||||
reserveSpace: false,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildHeaderIntroduction(SocialChatUserProfileManager ref) {
|
||||
final intro = (ref.userProfile?.autograph ?? "").trim();
|
||||
return Container(
|
||||
@ -610,7 +633,7 @@ class _PersonDetailPageState extends State<PersonDetailPage> {
|
||||
PropsResources? dataCard,
|
||||
) {
|
||||
return SizedBox(
|
||||
height: 520.w,
|
||||
height: 570.w,
|
||||
child: Stack(
|
||||
children: [
|
||||
SizedBox(
|
||||
@ -674,6 +697,8 @@ class _PersonDetailPageState extends State<PersonDetailPage> {
|
||||
SizedBox(height: 5.w),
|
||||
_buildHeaderMeta(ref),
|
||||
SizedBox(height: 4.w),
|
||||
_buildHeaderLongBadgeStrip(ref),
|
||||
SizedBox(height: 4.w),
|
||||
_buildHeaderBadgeStrip(ref),
|
||||
SizedBox(height: 6.w),
|
||||
_buildHeaderIntroduction(ref),
|
||||
|
||||
@ -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, {
|
||||
|
||||
@ -32,18 +32,32 @@ class SCUserBadgeRes {
|
||||
final List<WearBadge> longBadges;
|
||||
final List<WearBadge> shortBadges;
|
||||
|
||||
List<WearBadge> get displayBadges {
|
||||
List<WearBadge> get allBadges {
|
||||
if (longBadges.isNotEmpty || shortBadges.isNotEmpty) {
|
||||
return <WearBadge>[...longBadges, ...shortBadges];
|
||||
}
|
||||
return badges;
|
||||
}
|
||||
|
||||
List<WearBadge> get displayBadges {
|
||||
return shortDisplayBadges;
|
||||
}
|
||||
|
||||
List<WearBadge> get shortDisplayBadges {
|
||||
final source = shortBadges.isNotEmpty ? shortBadges : badges;
|
||||
return source.where(isShortDisplayBadge).toList();
|
||||
}
|
||||
|
||||
List<WearBadge> get longDisplayBadges {
|
||||
final source = longBadges.isNotEmpty ? longBadges : badges;
|
||||
return source.where(isLongDisplayBadge).toList();
|
||||
}
|
||||
|
||||
List<WearBadge> get honorWallBadges {
|
||||
final source =
|
||||
shortBadges.isNotEmpty
|
||||
? shortBadges
|
||||
: displayBadges.where(_isHonorWallBadge).toList();
|
||||
? shortBadges.where(_isHonorWallBadge).toList()
|
||||
: allBadges.where(_isHonorWallBadge).toList();
|
||||
final result =
|
||||
source.where((badge) => _badgeUrl(badge).isNotEmpty).toList();
|
||||
result.sort((a, b) => _badgeSortValue(b).compareTo(_badgeSortValue(a)));
|
||||
@ -103,20 +117,36 @@ class SCUserBadgeRes {
|
||||
type == "ACHIEVEMENT";
|
||||
}
|
||||
|
||||
static bool _isHonorWallBadge(WearBadge badge) {
|
||||
static bool isShortDisplayBadge(WearBadge badge) {
|
||||
final sourceType = badge.sourceType?.trim().toUpperCase() ?? "";
|
||||
final type = badge.type?.trim().toUpperCase() ?? "";
|
||||
if (type == "LONG" || type == "VIP" || sourceType == "VIP") {
|
||||
return false;
|
||||
}
|
||||
if (sourceType.isNotEmpty) {
|
||||
return sourceType == "ACTIVITY" || sourceType == "ACHIEVEMENT";
|
||||
return sourceType == "ACTIVITY" ||
|
||||
sourceType == "ACHIEVEMENT" ||
|
||||
sourceType == "HONOR_ACTIVITY" ||
|
||||
sourceType == "HONOR_ADMIN";
|
||||
}
|
||||
return type == "SHORT" ||
|
||||
type == "ACTIVITY" ||
|
||||
type == "ACHIEVEMENT" ||
|
||||
type == "HONOR_ACTIVITY" ||
|
||||
type == "HONOR_ADMIN";
|
||||
if (type.isNotEmpty) {
|
||||
return type == "SHORT" ||
|
||||
type == "ACTIVITY" ||
|
||||
type == "ACHIEVEMENT" ||
|
||||
type == "HONOR_ACTIVITY" ||
|
||||
type == "HONOR_ADMIN";
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
static bool isLongDisplayBadge(WearBadge badge) {
|
||||
final sourceType = badge.sourceType?.trim().toUpperCase() ?? "";
|
||||
final type = badge.type?.trim().toUpperCase() ?? "";
|
||||
return type == "LONG" || type == "VIP" || sourceType == "VIP";
|
||||
}
|
||||
|
||||
static bool _isHonorWallBadge(WearBadge badge) {
|
||||
return isShortDisplayBadge(badge);
|
||||
}
|
||||
|
||||
static int _badgeSortValue(WearBadge badge) {
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -6,29 +6,39 @@ import 'package:yumi/shared/data_sources/sources/local/user_manager.dart';
|
||||
import 'package:yumi/shared/data_sources/sources/repositories/sc_user_repository_impl.dart';
|
||||
import 'package:yumi/ui_kit/components/sc_compontent.dart';
|
||||
|
||||
enum SCUserBadgeDisplayScope { short, long, all }
|
||||
|
||||
class SCUserBadgeStrip extends StatefulWidget {
|
||||
const SCUserBadgeStrip({
|
||||
super.key,
|
||||
this.userId,
|
||||
this.fallbackBadges = const <WearBadge>[],
|
||||
this.displayScope = SCUserBadgeDisplayScope.short,
|
||||
this.height,
|
||||
this.badgeHeight,
|
||||
this.longBadgeWidth,
|
||||
this.spacing,
|
||||
this.runSpacing,
|
||||
this.maxBadges,
|
||||
this.wrap = false,
|
||||
this.maxWrapRows,
|
||||
this.scrollLastWrapRow = false,
|
||||
this.reserveSpace = false,
|
||||
this.alignment = WrapAlignment.center,
|
||||
});
|
||||
|
||||
final String? userId;
|
||||
final List<WearBadge> fallbackBadges;
|
||||
final SCUserBadgeDisplayScope displayScope;
|
||||
final double? height;
|
||||
final double? badgeHeight;
|
||||
final double? longBadgeWidth;
|
||||
final double? spacing;
|
||||
final double? runSpacing;
|
||||
final int? maxBadges;
|
||||
final bool wrap;
|
||||
final int? maxWrapRows;
|
||||
final bool scrollLastWrapRow;
|
||||
final bool reserveSpace;
|
||||
final WrapAlignment alignment;
|
||||
|
||||
@ -100,20 +110,48 @@ class _SCUserBadgeStripState extends State<SCUserBadgeStrip> {
|
||||
return FutureBuilder<SCUserBadgeRes>(
|
||||
future: future,
|
||||
builder: (context, snapshot) {
|
||||
final remoteBadges =
|
||||
snapshot.data?.displayBadges ?? const <WearBadge>[];
|
||||
final badges = remoteBadges.isNotEmpty ? remoteBadges : _fallbackBadges;
|
||||
return _buildBadges(badges, stripHeight);
|
||||
if (snapshot.hasData) {
|
||||
return _buildBadges(_remoteBadges(snapshot.data), stripHeight);
|
||||
}
|
||||
return _buildBadges(_fallbackBadges, stripHeight);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
List<WearBadge> get _fallbackBadges {
|
||||
return widget.fallbackBadges
|
||||
.where(_matchesDisplayScope)
|
||||
.where((badge) => _badgeUrl(badge).isNotEmpty)
|
||||
.toList();
|
||||
}
|
||||
|
||||
List<WearBadge> _remoteBadges(SCUserBadgeRes? data) {
|
||||
if (data == null) {
|
||||
return const <WearBadge>[];
|
||||
}
|
||||
switch (widget.displayScope) {
|
||||
case SCUserBadgeDisplayScope.long:
|
||||
return data.longDisplayBadges;
|
||||
case SCUserBadgeDisplayScope.all:
|
||||
return data.allBadges
|
||||
.where((badge) => _badgeUrl(badge).isNotEmpty)
|
||||
.toList();
|
||||
case SCUserBadgeDisplayScope.short:
|
||||
return data.shortDisplayBadges;
|
||||
}
|
||||
}
|
||||
|
||||
bool _matchesDisplayScope(WearBadge badge) {
|
||||
switch (widget.displayScope) {
|
||||
case SCUserBadgeDisplayScope.long:
|
||||
return SCUserBadgeRes.isLongDisplayBadge(badge);
|
||||
case SCUserBadgeDisplayScope.all:
|
||||
return true;
|
||||
case SCUserBadgeDisplayScope.short:
|
||||
return SCUserBadgeRes.isShortDisplayBadge(badge);
|
||||
}
|
||||
}
|
||||
|
||||
Widget _buildBadges(List<WearBadge> rawBadges, double stripHeight) {
|
||||
final badges =
|
||||
widget.maxBadges == null
|
||||
@ -127,19 +165,29 @@ class _SCUserBadgeStripState extends State<SCUserBadgeStrip> {
|
||||
|
||||
final badgeHeight = widget.badgeHeight ?? stripHeight;
|
||||
final spacing = widget.spacing ?? 5.w;
|
||||
final runSpacing = widget.runSpacing ?? 5.w;
|
||||
final children =
|
||||
badges
|
||||
.map((badge) => _buildBadge(badge, badgeHeight: badgeHeight))
|
||||
.toList();
|
||||
|
||||
if (widget.wrap) {
|
||||
if (widget.maxWrapRows != null) {
|
||||
return _buildLimitedWrapBadges(
|
||||
badges,
|
||||
stripHeight: stripHeight,
|
||||
badgeHeight: badgeHeight,
|
||||
spacing: spacing,
|
||||
runSpacing: runSpacing,
|
||||
);
|
||||
}
|
||||
return SizedBox(
|
||||
height: stripHeight,
|
||||
child: Center(
|
||||
child: Wrap(
|
||||
alignment: widget.alignment,
|
||||
spacing: spacing,
|
||||
runSpacing: 5.w,
|
||||
runSpacing: runSpacing,
|
||||
children: children,
|
||||
),
|
||||
),
|
||||
@ -156,14 +204,138 @@ class _SCUserBadgeStripState extends State<SCUserBadgeStrip> {
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildLimitedWrapBadges(
|
||||
List<WearBadge> badges, {
|
||||
required double stripHeight,
|
||||
required double badgeHeight,
|
||||
required double spacing,
|
||||
required double runSpacing,
|
||||
}) {
|
||||
final maxRows = widget.maxWrapRows ?? 1;
|
||||
if (maxRows <= 1) {
|
||||
return _buildScrollableBadgeRow(
|
||||
badges,
|
||||
stripHeight: stripHeight,
|
||||
badgeHeight: badgeHeight,
|
||||
spacing: spacing,
|
||||
);
|
||||
}
|
||||
|
||||
return SizedBox(
|
||||
height: stripHeight,
|
||||
child: LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final maxWidth =
|
||||
constraints.maxWidth.isFinite
|
||||
? constraints.maxWidth
|
||||
: _badgesTotalWidth(badges, badgeHeight, spacing);
|
||||
final widestBadge = badges
|
||||
.map((badge) => _badgeWidth(badge, badgeHeight))
|
||||
.fold<double>(
|
||||
0,
|
||||
(current, width) => width > current ? width : current,
|
||||
);
|
||||
final perRow =
|
||||
widestBadge <= 0
|
||||
? badges.length
|
||||
: ((maxWidth + spacing) / (widestBadge + spacing))
|
||||
.floor()
|
||||
.clamp(1, badges.length);
|
||||
final firstRows = maxRows - 1;
|
||||
final fixedCount = (perRow * firstRows).clamp(0, badges.length);
|
||||
final fixedBadges = badges.take(fixedCount).toList();
|
||||
final lastRowBadges = badges.skip(fixedCount).toList();
|
||||
final rows = <Widget>[];
|
||||
|
||||
for (var start = 0; start < fixedBadges.length; start += perRow) {
|
||||
final end =
|
||||
start + perRow > fixedBadges.length
|
||||
? fixedBadges.length
|
||||
: start + perRow;
|
||||
rows.add(
|
||||
_buildBadgeRow(
|
||||
fixedBadges.sublist(start, end),
|
||||
badgeHeight: badgeHeight,
|
||||
spacing: spacing,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (lastRowBadges.isNotEmpty) {
|
||||
rows.add(
|
||||
widget.scrollLastWrapRow
|
||||
? _buildScrollableBadgeRow(
|
||||
lastRowBadges,
|
||||
stripHeight: badgeHeight,
|
||||
badgeHeight: badgeHeight,
|
||||
spacing: spacing,
|
||||
)
|
||||
: _buildBadgeRow(
|
||||
lastRowBadges.take(perRow).toList(),
|
||||
badgeHeight: badgeHeight,
|
||||
spacing: spacing,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (rows.isEmpty) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
return Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: _withVerticalSpacing(rows, runSpacing),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildBadgeRow(
|
||||
List<WearBadge> badges, {
|
||||
required double badgeHeight,
|
||||
required double spacing,
|
||||
}) {
|
||||
return SizedBox(
|
||||
height: badgeHeight,
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
mainAxisAlignment: _rowAlignment,
|
||||
children: _withSpacing(
|
||||
badges
|
||||
.map((badge) => _buildBadge(badge, badgeHeight: badgeHeight))
|
||||
.toList(),
|
||||
spacing,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildScrollableBadgeRow(
|
||||
List<WearBadge> badges, {
|
||||
required double stripHeight,
|
||||
required double badgeHeight,
|
||||
required double spacing,
|
||||
}) {
|
||||
return SizedBox(
|
||||
height: stripHeight,
|
||||
child: SingleChildScrollView(
|
||||
scrollDirection: Axis.horizontal,
|
||||
physics: const BouncingScrollPhysics(),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: _withSpacing(
|
||||
badges
|
||||
.map((badge) => _buildBadge(badge, badgeHeight: badgeHeight))
|
||||
.toList(),
|
||||
spacing,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildBadge(WearBadge badge, {required double badgeHeight}) {
|
||||
final type = badge.type?.trim().toUpperCase() ?? "";
|
||||
final sourceType = badge.sourceType?.trim().toUpperCase() ?? "";
|
||||
final isLongBadge = type == "LONG" || type == "VIP" || sourceType == "VIP";
|
||||
final width =
|
||||
isLongBadge
|
||||
? (widget.longBadgeWidth ?? badgeHeight * 2.2)
|
||||
: badgeHeight;
|
||||
final width = _badgeWidth(badge, badgeHeight);
|
||||
return netImage(
|
||||
url: _badgeUrl(badge),
|
||||
width: width,
|
||||
@ -175,6 +347,47 @@ class _SCUserBadgeStripState extends State<SCUserBadgeStrip> {
|
||||
);
|
||||
}
|
||||
|
||||
double _badgeWidth(WearBadge badge, double badgeHeight) {
|
||||
final type = badge.type?.trim().toUpperCase() ?? "";
|
||||
final sourceType = badge.sourceType?.trim().toUpperCase() ?? "";
|
||||
final isLongBadge = type == "LONG" || type == "VIP" || sourceType == "VIP";
|
||||
return isLongBadge
|
||||
? (widget.longBadgeWidth ?? badgeHeight * 2.2)
|
||||
: badgeHeight;
|
||||
}
|
||||
|
||||
double _badgesTotalWidth(
|
||||
List<WearBadge> badges,
|
||||
double badgeHeight,
|
||||
double spacing,
|
||||
) {
|
||||
if (badges.isEmpty) {
|
||||
return 0;
|
||||
}
|
||||
final badgeWidth = badges.fold<double>(
|
||||
0,
|
||||
(sum, badge) => sum + _badgeWidth(badge, badgeHeight),
|
||||
);
|
||||
return badgeWidth + spacing * (badges.length - 1);
|
||||
}
|
||||
|
||||
MainAxisAlignment get _rowAlignment {
|
||||
switch (widget.alignment) {
|
||||
case WrapAlignment.start:
|
||||
return MainAxisAlignment.start;
|
||||
case WrapAlignment.end:
|
||||
return MainAxisAlignment.end;
|
||||
case WrapAlignment.spaceBetween:
|
||||
return MainAxisAlignment.spaceBetween;
|
||||
case WrapAlignment.spaceAround:
|
||||
return MainAxisAlignment.spaceAround;
|
||||
case WrapAlignment.spaceEvenly:
|
||||
return MainAxisAlignment.spaceEvenly;
|
||||
case WrapAlignment.center:
|
||||
return MainAxisAlignment.center;
|
||||
}
|
||||
}
|
||||
|
||||
String _badgeUrl(WearBadge badge) {
|
||||
return (badge.selectUrl ?? "").trim();
|
||||
}
|
||||
@ -192,6 +405,20 @@ class _SCUserBadgeStripState extends State<SCUserBadgeStrip> {
|
||||
}
|
||||
return spaced;
|
||||
}
|
||||
|
||||
List<Widget> _withVerticalSpacing(List<Widget> children, double spacing) {
|
||||
if (children.length < 2) {
|
||||
return children;
|
||||
}
|
||||
final spaced = <Widget>[];
|
||||
for (var i = 0; i < children.length; i++) {
|
||||
if (i > 0) {
|
||||
spaced.add(SizedBox(height: spacing));
|
||||
}
|
||||
spaced.add(children[i]);
|
||||
}
|
||||
return spaced;
|
||||
}
|
||||
}
|
||||
|
||||
class _BadgeFutureCacheEntry {
|
||||
|
||||
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,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -1303,16 +1303,40 @@ class _MsgItemState extends State<MsgItem> {
|
||||
child: SCUserBadgeStrip(
|
||||
userId: userId,
|
||||
fallbackBadges: fallbackBadges,
|
||||
height: 23.w,
|
||||
badgeHeight: 21.w,
|
||||
height: 28.w,
|
||||
badgeHeight: 25.w,
|
||||
longBadgeWidth: 51.w,
|
||||
spacing: 4.5.w,
|
||||
spacing: 5.w,
|
||||
maxBadges: 8,
|
||||
reserveSpace: true,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildLongMedals({
|
||||
required String? userId,
|
||||
List<WearBadge> fallbackBadges = const <WearBadge>[],
|
||||
}) {
|
||||
final hasUserId = (userId ?? "").trim().isNotEmpty;
|
||||
if (!hasUserId && fallbackBadges.isEmpty) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
return SizedBox(
|
||||
width: double.infinity,
|
||||
child: SCUserBadgeStrip(
|
||||
userId: userId,
|
||||
fallbackBadges: fallbackBadges,
|
||||
displayScope: SCUserBadgeDisplayScope.long,
|
||||
height: 18.w,
|
||||
badgeHeight: 16.w,
|
||||
longBadgeWidth: 50.w,
|
||||
spacing: 3.w,
|
||||
maxBadges: 8,
|
||||
reserveSpace: false,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
int _msgColor(String type) {
|
||||
switch (type) {
|
||||
case SCRoomMsgType.joinRoom:
|
||||
@ -1851,13 +1875,18 @@ class _MsgItemState extends State<MsgItem> {
|
||||
SizedBox(width: 8.w),
|
||||
Expanded(
|
||||
child: SizedBox(
|
||||
height: 50.w,
|
||||
height: 76.w,
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
_buildUserNameAndLevels(widget.msg.user),
|
||||
SizedBox(height: 2.w),
|
||||
_buildLongMedals(
|
||||
userId: widget.msg.user?.id,
|
||||
fallbackBadges: _activeWearBadges(widget.msg.user),
|
||||
),
|
||||
SizedBox(height: 2.w),
|
||||
_buildMedals(
|
||||
userId: widget.msg.user?.id,
|
||||
fallbackBadges: _activeWearBadges(widget.msg.user),
|
||||
@ -1897,13 +1926,18 @@ class _MsgItemState extends State<MsgItem> {
|
||||
SizedBox(width: 8.w),
|
||||
Expanded(
|
||||
child: SizedBox(
|
||||
height: 50.w,
|
||||
height: 76.w,
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
_buildUserNameAndLevels(user),
|
||||
SizedBox(height: 2.w),
|
||||
_buildLongMedals(
|
||||
userId: user.id,
|
||||
fallbackBadges: _activeWearBadges(user),
|
||||
),
|
||||
SizedBox(height: 2.w),
|
||||
_buildMedals(
|
||||
userId: user.id,
|
||||
fallbackBadges: _activeWearBadges(user),
|
||||
|
||||
@ -282,13 +282,13 @@ class _RoomUserInfoCardState extends State<RoomUserInfoCard> {
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
_buildProfileHeader(context, profile, ref),
|
||||
SizedBox(height: 6.w),
|
||||
SizedBox(height: 4.w),
|
||||
_buildBadgeStrip(
|
||||
profile?.id ?? widget.userId,
|
||||
ref,
|
||||
profile,
|
||||
),
|
||||
SizedBox(height: 8.w),
|
||||
SizedBox(height: 5.w),
|
||||
_buildActions(
|
||||
context: context,
|
||||
ref: ref,
|
||||
@ -422,15 +422,17 @@ class _RoomUserInfoCardState extends State<RoomUserInfoCard> {
|
||||
),
|
||||
),
|
||||
SizedBox(height: 5.w),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
Wrap(
|
||||
alignment: WrapAlignment.center,
|
||||
crossAxisAlignment: WrapCrossAlignment.center,
|
||||
spacing: 4.w,
|
||||
runSpacing: 5.w,
|
||||
children: [
|
||||
msgRoleTag(
|
||||
ref.userCardInfo?.roomRole ?? "",
|
||||
width: 16.w,
|
||||
height: 16.w,
|
||||
),
|
||||
SizedBox(width: 4.w),
|
||||
socialchatNickNameText(
|
||||
maxWidth: 116.w,
|
||||
profile?.userNickname ?? "",
|
||||
@ -440,13 +442,11 @@ class _RoomUserInfoCardState extends State<RoomUserInfoCard> {
|
||||
type: "",
|
||||
needScroll: (profile?.userNickname?.characters.length ?? 0) > 12,
|
||||
),
|
||||
SizedBox(width: 3.w),
|
||||
getVIPBadge(
|
||||
profile?.vipLevel ?? profile?.getVIP()?.name,
|
||||
width: 45.w,
|
||||
height: 25.w,
|
||||
),
|
||||
SizedBox(width: 3.w),
|
||||
getWealthLevel(
|
||||
profile?.wealthLevel ??
|
||||
ref.userCardInfo?.userLevel?.wealthLevel ??
|
||||
@ -455,7 +455,6 @@ class _RoomUserInfoCardState extends State<RoomUserInfoCard> {
|
||||
height: 20.w,
|
||||
fontSize: 9.sp,
|
||||
),
|
||||
SizedBox(width: 2.w),
|
||||
getUserLevel(
|
||||
profile?.charmLevel ??
|
||||
ref.userCardInfo?.userLevel?.charmLevel ??
|
||||
@ -466,6 +465,8 @@ class _RoomUserInfoCardState extends State<RoomUserInfoCard> {
|
||||
),
|
||||
],
|
||||
),
|
||||
SizedBox(height: 3.w),
|
||||
_buildHeaderLongBadgeStrip(profile?.id ?? widget.userId, ref, profile),
|
||||
SizedBox(height: 2.w),
|
||||
_buildSpecialIdMetaRow(context, profile),
|
||||
],
|
||||
@ -552,21 +553,55 @@ class _RoomUserInfoCardState extends State<RoomUserInfoCard> {
|
||||
return xb(profile?.userSex, height: 15.w, color: null);
|
||||
}
|
||||
|
||||
Widget _buildHeaderLongBadgeStrip(
|
||||
String? userId,
|
||||
SocialChatUserProfileManager ref,
|
||||
SocialChatUserProfile? profile,
|
||||
) {
|
||||
return SizedBox(
|
||||
width: double.infinity,
|
||||
child: Padding(
|
||||
padding: EdgeInsets.symmetric(horizontal: 10.w),
|
||||
child: SCUserBadgeStrip(
|
||||
userId: userId,
|
||||
fallbackBadges: _roomCardFallbackBadges(ref, profile),
|
||||
displayScope: SCUserBadgeDisplayScope.long,
|
||||
height: 62.w,
|
||||
badgeHeight: 20.w,
|
||||
longBadgeWidth: 62.w,
|
||||
spacing: 4.w,
|
||||
runSpacing: 4.w,
|
||||
wrap: true,
|
||||
maxWrapRows: 2,
|
||||
scrollLastWrapRow: true,
|
||||
reserveSpace: false,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildBadgeStrip(
|
||||
String? userId,
|
||||
SocialChatUserProfileManager ref,
|
||||
SocialChatUserProfile? profile,
|
||||
) {
|
||||
return SCUserBadgeStrip(
|
||||
userId: userId,
|
||||
fallbackBadges: _roomCardFallbackBadges(ref, profile),
|
||||
height: 70.w,
|
||||
badgeHeight: 57.5.w,
|
||||
longBadgeWidth: 145.w,
|
||||
spacing: 20.w,
|
||||
maxBadges: 12,
|
||||
wrap: false,
|
||||
reserveSpace: true,
|
||||
return SizedBox(
|
||||
width: double.infinity,
|
||||
child: Padding(
|
||||
padding: EdgeInsets.symmetric(horizontal: 10.w),
|
||||
child: SCUserBadgeStrip(
|
||||
userId: userId,
|
||||
fallbackBadges: _roomCardFallbackBadges(ref, profile),
|
||||
displayScope: SCUserBadgeDisplayScope.short,
|
||||
height: 62.w,
|
||||
badgeHeight: 57.5.w,
|
||||
longBadgeWidth: 145.w,
|
||||
spacing: 12.w,
|
||||
maxBadges: 12,
|
||||
wrap: false,
|
||||
reserveSpace: false,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@ -1,6 +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,
|
||||
@ -10,6 +25,51 @@ const Color _kStoreBagSkeletonShellStrong = Color(0xFF1A4A41);
|
||||
const Color _kStoreBagSkeletonBoneBase = Color(0xFF2B5C53);
|
||||
const Color _kStoreBagSkeletonBoneHighlight = Color(0xFF5F8177);
|
||||
const Color _kStoreBagSkeletonBorder = Color(0x66D8B57B);
|
||||
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, {
|
||||
@ -86,7 +146,7 @@ class SCStoreGridSkeleton extends StatelessWidget {
|
||||
crossAxisCount: 3,
|
||||
mainAxisSpacing: 12.w,
|
||||
crossAxisSpacing: 12.w,
|
||||
childAspectRatio: 0.78,
|
||||
childAspectRatio: 0.88,
|
||||
),
|
||||
itemCount: itemCount,
|
||||
itemBuilder: (context, index) => _buildStoreCardSkeleton(progress),
|
||||
@ -95,6 +155,466 @@ class SCStoreGridSkeleton extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
class SCStoreGridItemCard extends StatelessWidget {
|
||||
const SCStoreGridItemCard({
|
||||
super.key,
|
||||
required this.res,
|
||||
required this.discount,
|
||||
required this.onDetailsTap,
|
||||
this.previewKind = SCStoreItemPreviewKind.resource,
|
||||
this.coverWidth,
|
||||
this.coverHeight,
|
||||
this.previewWidth,
|
||||
this.previewHeight,
|
||||
this.coverFit = BoxFit.contain,
|
||||
this.previewFit = BoxFit.contain,
|
||||
this.coverBorderRadius,
|
||||
});
|
||||
|
||||
final StoreListRes res;
|
||||
final double discount;
|
||||
final VoidCallback onDetailsTap;
|
||||
final SCStoreItemPreviewKind previewKind;
|
||||
final double? coverWidth;
|
||||
final double? coverHeight;
|
||||
final double? previewWidth;
|
||||
final double? previewHeight;
|
||||
final BoxFit coverFit;
|
||||
final BoxFit previewFit;
|
||||
final BorderRadius? coverBorderRadius;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final firstPrice =
|
||||
(res.propsPrices?.isNotEmpty ?? false) ? res.propsPrices!.first : null;
|
||||
final amount = ((firstPrice?.amount ?? 0) * discount).toInt();
|
||||
final tagText = _buildDurationTag(context, firstPrice?.days);
|
||||
|
||||
return GestureDetector(
|
||||
onTap: onDetailsTap,
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
color:
|
||||
SCGlobalConfig.businessLogicStrategy
|
||||
.getStoreItemBackgroundColor(),
|
||||
border: Border.all(
|
||||
color:
|
||||
SCGlobalConfig.businessLogicStrategy
|
||||
.getStoreItemUnselectedBorderColor(),
|
||||
width: 1.w,
|
||||
),
|
||||
borderRadius: BorderRadius.all(Radius.circular(8.w)),
|
||||
),
|
||||
child: Stack(
|
||||
children: [
|
||||
PositionedDirectional(
|
||||
top: 6.w,
|
||||
start: 6.w,
|
||||
child: _buildDurationBadge(tagText),
|
||||
),
|
||||
Padding(
|
||||
padding: EdgeInsets.fromLTRB(6.w, 16.w, 6.w, 8.w),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
_buildPreviewTapArea(context),
|
||||
SizedBox(height: 8.w),
|
||||
_buildPriceRow(amount, firstPrice),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildPreviewTapArea(BuildContext context) {
|
||||
return GestureDetector(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onTap: () => _showFullScreenPreview(context),
|
||||
child: SizedBox(
|
||||
width: double.infinity,
|
||||
height:
|
||||
previewHeight ??
|
||||
coverHeight ??
|
||||
(previewKind == SCStoreItemPreviewKind.avatarFrame ? 64.w : 62.w),
|
||||
child: Center(child: _buildCover()),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _showFullScreenPreview(BuildContext context) {
|
||||
showStoreBagFullScreenResourcePreview(
|
||||
context,
|
||||
resource: res.propsResources,
|
||||
previewKind: previewKind,
|
||||
fit: previewFit,
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildCover() {
|
||||
Widget child = netImage(
|
||||
url: res.propsResources?.cover ?? "",
|
||||
width: coverWidth ?? 58.w,
|
||||
height: coverHeight ?? 58.w,
|
||||
fit: coverFit,
|
||||
);
|
||||
if (coverBorderRadius != null) {
|
||||
child = ClipRRect(borderRadius: coverBorderRadius!, child: child);
|
||||
}
|
||||
return child;
|
||||
}
|
||||
|
||||
Widget _buildPriceRow(int amount, PropsPrices? firstPrice) {
|
||||
return SizedBox(
|
||||
width: double.infinity,
|
||||
height: 22.w,
|
||||
child: FittedBox(
|
||||
fit: BoxFit.scaleDown,
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Image.asset(
|
||||
SCGlobalConfig.businessLogicStrategy.getStoreItemGoldIcon(),
|
||||
width: 18.w,
|
||||
height: 18.w,
|
||||
),
|
||||
SizedBox(width: 4.w),
|
||||
if (discount < 1) ...[
|
||||
text(
|
||||
"${firstPrice?.amount ?? 0}",
|
||||
fontSize: 12.sp,
|
||||
textColor:
|
||||
SCGlobalConfig.businessLogicStrategy
|
||||
.getStoreItemPriceTextColor(),
|
||||
fontWeight: FontWeight.w600,
|
||||
decoration: TextDecoration.lineThrough,
|
||||
),
|
||||
SizedBox(width: 4.w),
|
||||
],
|
||||
text(
|
||||
"$amount",
|
||||
fontSize: 13.sp,
|
||||
textColor:
|
||||
SCGlobalConfig.businessLogicStrategy
|
||||
.getStoreItemPriceTextColor(),
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildDurationBadge(String tagText) {
|
||||
if (tagText.isEmpty) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
return Container(
|
||||
constraints: BoxConstraints(maxWidth: 58.w),
|
||||
padding: EdgeInsets.symmetric(horizontal: 6.w, vertical: 2.w),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xff18F2B1).withValues(alpha: 0.16),
|
||||
borderRadius: BorderRadius.circular(4.w),
|
||||
border: Border.all(
|
||||
color: const Color(0xff18F2B1).withValues(alpha: 0.28),
|
||||
width: 0.5.w,
|
||||
),
|
||||
),
|
||||
child: text(
|
||||
tagText,
|
||||
fontSize: 9.sp,
|
||||
textColor:
|
||||
SCGlobalConfig.businessLogicStrategy.getStoreItemPriceTextColor(),
|
||||
fontWeight: FontWeight.w600,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
String _buildDurationTag(BuildContext context, num? days) {
|
||||
if (days == null) {
|
||||
return "";
|
||||
}
|
||||
final normalizedDays = days % 1 == 0 ? days.toInt().toString() : "$days";
|
||||
return "$normalizedDays ${SCAppLocalizations.of(context)!.day}";
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
@ -130,9 +650,18 @@ Widget _buildStoreCardSkeleton(double progress) {
|
||||
return DecoratedBox(
|
||||
decoration: _buildStoreBagShellDecoration(radius: 8.w),
|
||||
child: Padding(
|
||||
padding: EdgeInsets.symmetric(horizontal: 10.w, vertical: 10.w),
|
||||
padding: EdgeInsets.symmetric(horizontal: 8.w, vertical: 8.w),
|
||||
child: Column(
|
||||
children: [
|
||||
Align(
|
||||
alignment: AlignmentDirectional.centerStart,
|
||||
child: _buildStoreBagSkeletonBone(
|
||||
progress,
|
||||
width: 42.w,
|
||||
height: 14.w,
|
||||
borderRadius: BorderRadius.circular(4.w),
|
||||
),
|
||||
),
|
||||
SizedBox(height: 2.w),
|
||||
_buildStoreBagSkeletonBone(
|
||||
progress,
|
||||
@ -140,14 +669,7 @@ Widget _buildStoreCardSkeleton(double progress) {
|
||||
height: 55.w,
|
||||
borderRadius: BorderRadius.circular(12.w),
|
||||
),
|
||||
SizedBox(height: 10.w),
|
||||
_buildStoreBagSkeletonBone(
|
||||
progress,
|
||||
width: double.infinity,
|
||||
height: 12.w,
|
||||
borderRadius: BorderRadius.circular(999.w),
|
||||
),
|
||||
SizedBox(height: 10.w),
|
||||
SizedBox(height: 8.w),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
@ -166,13 +688,6 @@ Widget _buildStoreCardSkeleton(double progress) {
|
||||
),
|
||||
],
|
||||
),
|
||||
SizedBox(height: 6.w),
|
||||
_buildStoreBagSkeletonBone(
|
||||
progress,
|
||||
width: 48.w,
|
||||
height: 11.w,
|
||||
borderRadius: BorderRadius.circular(999.w),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
@ -211,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
@ -32,11 +32,9 @@ void main() {
|
||||
],
|
||||
});
|
||||
|
||||
expect(result.displayBadges.map((badge) => badge.id), [
|
||||
'vip',
|
||||
'old',
|
||||
'new',
|
||||
]);
|
||||
expect(result.allBadges.map((badge) => badge.id), ['vip', 'old', 'new']);
|
||||
expect(result.longDisplayBadges.map((badge) => badge.id), ['vip']);
|
||||
expect(result.displayBadges.map((badge) => badge.id), ['old', 'new']);
|
||||
expect(result.honorWallBadges.map((badge) => badge.id), ['new', 'old']);
|
||||
});
|
||||
|
||||
@ -73,10 +71,44 @@ void main() {
|
||||
],
|
||||
});
|
||||
|
||||
expect(result.longDisplayBadges.map((badge) => badge.id), [
|
||||
'long',
|
||||
'vip',
|
||||
]);
|
||||
expect(result.displayBadges.map((badge) => badge.id), [
|
||||
'activity',
|
||||
'achievement',
|
||||
]);
|
||||
expect(result.honorWallBadges.map((badge) => badge.id), [
|
||||
'achievement',
|
||||
'activity',
|
||||
]);
|
||||
});
|
||||
|
||||
test('does not expose long badges as short display badges', () {
|
||||
final result = SCUserBadgeRes.fromJson({
|
||||
'longBadges': [
|
||||
{
|
||||
'id': 'long-one',
|
||||
'type': 'LONG',
|
||||
'selectUrl': 'https://example.com/long-one.png',
|
||||
},
|
||||
{
|
||||
'id': 'vip-one',
|
||||
'sourceType': 'VIP',
|
||||
'selectUrl': 'https://example.com/vip-one.png',
|
||||
},
|
||||
],
|
||||
'shortBadges': [],
|
||||
});
|
||||
|
||||
expect(result.longDisplayBadges.map((badge) => badge.id), [
|
||||
'long-one',
|
||||
'vip-one',
|
||||
]);
|
||||
expect(result.shortDisplayBadges, isEmpty);
|
||||
expect(result.displayBadges, isEmpty);
|
||||
expect(result.honorWallBadges, isEmpty);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user