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 {
|
dependencies {
|
||||||
implementation("androidx.multidex:multidex:2.0.1")
|
implementation("androidx.multidex:multidex:2.0.1")
|
||||||
implementation("com.android.installreferrer:installreferrer:2.2")
|
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(platform("com.google.firebase:firebase-bom:34.0.0"))
|
||||||
// implementation("com.google.firebase:firebase-auth")
|
// implementation("com.google.firebase:firebase-auth")
|
||||||
// implementation("com.google.firebase:firebase-core:16.0.8")
|
// 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()
|
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) {
|
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
|
@override
|
||||||
String get apiHost => const String.fromEnvironment(
|
String get apiHost => const String.fromEnvironment(
|
||||||
'API_HOST',
|
'API_HOST',
|
||||||
defaultValue: 'http://192.168.110.64:1100/',
|
defaultValue: 'http://192.168.110.66:1100/',
|
||||||
); // 默认连线上环境,本地调试可通过 --dart-define=API_HOST 覆盖 本地“http://192.168.110.64:1100/”,线上“https://jvapi.haiyihy.com/”
|
); // 默认连线上环境,本地调试可通过 --dart-define=API_HOST 覆盖 本地“http://192.168.110.66:1100/”,线上“https://jvapi.haiyihy.com/”
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get imgHost => 'https://img.atuchat.com/'; // 测试图片服务器,上架前需替换为正式域名
|
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;
|
Timer? _comboSendBatchTimer;
|
||||||
bool _isComboSendBatchInFlight = false;
|
bool _isComboSendBatchInFlight = false;
|
||||||
|
|
||||||
void _giftFxLog(String message) {}
|
void _giftFxLog(String message) {
|
||||||
|
debugPrint('[GiftFx][gift] $message');
|
||||||
|
}
|
||||||
|
|
||||||
String _describeGiftSendError(Object error) {
|
String _describeGiftSendError(Object error) {
|
||||||
if (error is DioException) {
|
if (error is DioException) {
|
||||||
|
|||||||
@ -1,17 +1,10 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_screenutil/flutter_screenutil.dart';
|
import 'package:flutter_screenutil/flutter_screenutil.dart';
|
||||||
import 'package:flutter_smart_dialog/flutter_smart_dialog.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/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/data_sources/sources/repositories/sc_store_repository_imp.dart';
|
||||||
import 'package:yumi/shared/business_logic/models/res/store_list_res.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/props_store_chatbox_detail_dialog.dart';
|
||||||
import 'package:yumi/ui_kit/widgets/store/store_bag_page_helpers.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 {
|
class StoreChatboxPage extends SCPageList {
|
||||||
|
const StoreChatboxPage({super.key});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
_StoreChatboxPageState createState() => _StoreChatboxPageState();
|
SCPageListState createState() => _StoreChatboxPageState();
|
||||||
}
|
}
|
||||||
|
|
||||||
class _StoreChatboxPageState
|
class _StoreChatboxPageState
|
||||||
@ -41,7 +36,7 @@ class _StoreChatboxPageState
|
|||||||
crossAxisCount: gridViewCount,
|
crossAxisCount: gridViewCount,
|
||||||
mainAxisSpacing: 12.w,
|
mainAxisSpacing: 12.w,
|
||||||
crossAxisSpacing: 12.w,
|
crossAxisSpacing: 12.w,
|
||||||
childAspectRatio: 0.78,
|
childAspectRatio: 0.88,
|
||||||
);
|
);
|
||||||
loadData(1);
|
loadData(1);
|
||||||
}
|
}
|
||||||
@ -59,100 +54,13 @@ class _StoreChatboxPageState
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget buildItem(StoreListResBean res) {
|
Widget buildItem(StoreListResBean res) {
|
||||||
return GestureDetector(
|
return SCStoreGridItemCard(
|
||||||
child: Container(
|
res: res.res,
|
||||||
decoration: BoxDecoration(
|
discount: disCount,
|
||||||
color:
|
coverHeight: 45.w,
|
||||||
res.isSelecte
|
previewWidth: 88.w,
|
||||||
? const Color(0xff18F2B1).withValues(alpha: 0.1)
|
previewHeight: 58.w,
|
||||||
: SCGlobalConfig.businessLogicStrategy
|
onDetailsTap: () => _showDetail(res.res),
|
||||||
.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);
|
|
||||||
},
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -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 {
|
class StoreListResBean {
|
||||||
|
|||||||
@ -1,13 +1,10 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_screenutil/flutter_screenutil.dart';
|
import 'package:flutter_screenutil/flutter_screenutil.dart';
|
||||||
import 'package:flutter_smart_dialog/flutter_smart_dialog.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/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_currency_type.dart';
|
||||||
import 'package:yumi/shared/data_sources/models/enum/sc_props_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/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/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/props_store_data_card_detail_dialog.dart';
|
||||||
import 'package:yumi/ui_kit/widgets/store/store_bag_page_helpers.dart';
|
import 'package:yumi/ui_kit/widgets/store/store_bag_page_helpers.dart';
|
||||||
@ -35,7 +32,7 @@ class StoreDataCardPageState
|
|||||||
crossAxisCount: gridViewCount,
|
crossAxisCount: gridViewCount,
|
||||||
mainAxisSpacing: 12.w,
|
mainAxisSpacing: 12.w,
|
||||||
crossAxisSpacing: 12.w,
|
crossAxisSpacing: 12.w,
|
||||||
childAspectRatio: 0.78,
|
childAspectRatio: 0.88,
|
||||||
);
|
);
|
||||||
loadData(1);
|
loadData(1);
|
||||||
}
|
}
|
||||||
@ -54,101 +51,18 @@ class StoreDataCardPageState
|
|||||||
@override
|
@override
|
||||||
Widget buildItem(StoreDataCardListResBean item) {
|
Widget buildItem(StoreDataCardListResBean item) {
|
||||||
final res = item;
|
final res = item;
|
||||||
final prices = res.res.propsPrices ?? const <PropsPrices>[];
|
return SCStoreGridItemCard(
|
||||||
final firstPrice = prices.isNotEmpty ? prices.first : null;
|
res: res.res,
|
||||||
return GestureDetector(
|
discount: disCount,
|
||||||
child: Container(
|
previewKind: SCStoreItemPreviewKind.dataCard,
|
||||||
decoration: BoxDecoration(
|
coverWidth: 72.w,
|
||||||
color:
|
coverHeight: 50.w,
|
||||||
res.isSelecte
|
previewWidth: 88.w,
|
||||||
? const Color(0xff18F2B1).withValues(alpha: 0.1)
|
previewHeight: 58.w,
|
||||||
: SCGlobalConfig.businessLogicStrategy
|
coverFit: BoxFit.cover,
|
||||||
.getStoreItemBackgroundColor(),
|
previewFit: BoxFit.cover,
|
||||||
border: Border.all(
|
coverBorderRadius: BorderRadius.circular(6.w),
|
||||||
color:
|
onDetailsTap: () => _showDetail(res.res),
|
||||||
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);
|
|
||||||
},
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -183,14 +97,6 @@ class StoreDataCardPageState
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
void _selectItem(StoreDataCardListResBean res) {
|
|
||||||
for (final value in items) {
|
|
||||||
value.isSelecte = false;
|
|
||||||
}
|
|
||||||
res.isSelecte = true;
|
|
||||||
setState(() {});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
class StoreDataCardListResBean {
|
class StoreDataCardListResBean {
|
||||||
|
|||||||
@ -1,16 +1,9 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_screenutil/flutter_screenutil.dart';
|
import 'package:flutter_screenutil/flutter_screenutil.dart';
|
||||||
import 'package:flutter_smart_dialog/flutter_smart_dialog.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/ui_kit/components/sc_page_list.dart';
|
||||||
import 'package:yumi/shared/data_sources/sources/repositories/sc_store_repository_imp.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/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/props_store_headdress_detail_dialog.dart';
|
||||||
import 'package:yumi/ui_kit/widgets/store/store_bag_page_helpers.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 {
|
class StoreHeaddressPage extends SCPageList {
|
||||||
|
const StoreHeaddressPage({super.key});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
_StoreHeaddressPageState createState() => _StoreHeaddressPageState();
|
SCPageListState createState() => _StoreHeaddressPageState();
|
||||||
}
|
}
|
||||||
|
|
||||||
class _StoreHeaddressPageState
|
class _StoreHeaddressPageState
|
||||||
@ -40,7 +35,7 @@ class _StoreHeaddressPageState
|
|||||||
crossAxisCount: gridViewCount,
|
crossAxisCount: gridViewCount,
|
||||||
mainAxisSpacing: 12.w,
|
mainAxisSpacing: 12.w,
|
||||||
crossAxisSpacing: 12.w,
|
crossAxisSpacing: 12.w,
|
||||||
childAspectRatio: 0.78,
|
childAspectRatio: 0.88,
|
||||||
);
|
);
|
||||||
loadData(1);
|
loadData(1);
|
||||||
}
|
}
|
||||||
@ -58,100 +53,11 @@ class _StoreHeaddressPageState
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget buildItem(StoreListResBean res) {
|
Widget buildItem(StoreListResBean res) {
|
||||||
return GestureDetector(
|
return SCStoreGridItemCard(
|
||||||
child: Container(
|
res: res.res,
|
||||||
decoration: BoxDecoration(
|
discount: disCount,
|
||||||
color:
|
previewKind: SCStoreItemPreviewKind.avatarFrame,
|
||||||
res.isSelecte
|
onDetailsTap: () => _showDetail(res.res),
|
||||||
? 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);
|
|
||||||
},
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -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 {
|
class StoreListResBean {
|
||||||
|
|||||||
@ -1,10 +1,7 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_screenutil/flutter_screenutil.dart';
|
import 'package:flutter_screenutil/flutter_screenutil.dart';
|
||||||
import 'package:flutter_smart_dialog/flutter_smart_dialog.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/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/data_sources/sources/repositories/sc_store_repository_imp.dart';
|
||||||
import 'package:yumi/shared/business_logic/models/res/store_list_res.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';
|
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 {
|
class StoreMountainsPage extends SCPageList {
|
||||||
StoreMountainsPage();
|
const StoreMountainsPage({super.key});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
_StoreMountainsPageState createState() => _StoreMountainsPageState();
|
SCPageListState createState() => _StoreMountainsPageState();
|
||||||
}
|
}
|
||||||
|
|
||||||
class _StoreMountainsPageState
|
class _StoreMountainsPageState
|
||||||
@ -39,16 +36,11 @@ class _StoreMountainsPageState
|
|||||||
crossAxisCount: gridViewCount,
|
crossAxisCount: gridViewCount,
|
||||||
mainAxisSpacing: 12.w,
|
mainAxisSpacing: 12.w,
|
||||||
crossAxisSpacing: 12.w,
|
crossAxisSpacing: 12.w,
|
||||||
childAspectRatio: 0.78,
|
childAspectRatio: 0.88,
|
||||||
);
|
);
|
||||||
loadData(1);
|
loadData(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
|
||||||
void dispose() {
|
|
||||||
super.dispose();
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
@ -62,100 +54,10 @@ class _StoreMountainsPageState
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget buildItem(StoreListResBean res) {
|
Widget buildItem(StoreListResBean res) {
|
||||||
return GestureDetector(
|
return SCStoreGridItemCard(
|
||||||
child: Container(
|
res: res.res,
|
||||||
decoration: BoxDecoration(
|
discount: disCount,
|
||||||
color:
|
onDetailsTap: () => _showDetail(res.res),
|
||||||
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);
|
|
||||||
},
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -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/material.dart';
|
||||||
import 'package:flutter_screenutil/flutter_screenutil.dart';
|
import 'package:flutter_screenutil/flutter_screenutil.dart';
|
||||||
import 'package:flutter_smart_dialog/flutter_smart_dialog.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_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/data_sources/sources/repositories/sc_store_repository_imp.dart';
|
||||||
import 'package:yumi/shared/business_logic/models/res/store_list_res.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/props_store_theme_detail_dialog.dart';
|
||||||
import 'package:yumi/ui_kit/widgets/store/store_bag_page_helpers.dart';
|
import 'package:yumi/ui_kit/widgets/store/store_bag_page_helpers.dart';
|
||||||
import 'package:yumi/modules/store/headdress/store_headdress_page.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 {
|
class StoreThemePage extends SCPageList {
|
||||||
StoreThemePage();
|
const StoreThemePage({super.key});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
_StoreThemePagePageState createState() => _StoreThemePagePageState();
|
SCPageListState createState() => _StoreThemePagePageState();
|
||||||
}
|
}
|
||||||
|
|
||||||
class _StoreThemePagePageState
|
class _StoreThemePagePageState
|
||||||
@ -43,7 +37,7 @@ class _StoreThemePagePageState
|
|||||||
crossAxisCount: gridViewCount,
|
crossAxisCount: gridViewCount,
|
||||||
mainAxisSpacing: 12.w,
|
mainAxisSpacing: 12.w,
|
||||||
crossAxisSpacing: 12.w,
|
crossAxisSpacing: 12.w,
|
||||||
childAspectRatio: 0.78,
|
childAspectRatio: 0.88,
|
||||||
);
|
);
|
||||||
loadData(1);
|
loadData(1);
|
||||||
}
|
}
|
||||||
@ -61,101 +55,11 @@ class _StoreThemePagePageState
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget buildItem(StoreListResBean res) {
|
Widget buildItem(StoreListResBean res) {
|
||||||
return GestureDetector(
|
return SCStoreGridItemCard(
|
||||||
child: Container(
|
res: res.res,
|
||||||
decoration: BoxDecoration(
|
discount: disCount,
|
||||||
color:
|
coverBorderRadius: BorderRadius.all(Radius.circular(8.w)),
|
||||||
res.isSelecte
|
onDetailsTap: () => _showDetail(res.res),
|
||||||
? 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);
|
|
||||||
},
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -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(
|
Expanded(
|
||||||
child:
|
child:
|
||||||
items.isEmpty && isLoading
|
items.isEmpty && isLoading
|
||||||
? const SCBagGridSkeleton()
|
? const SCBagGridSkeleton(showTitle: false)
|
||||||
: buildList(context),
|
: buildList(context),
|
||||||
),
|
),
|
||||||
selecteChatbox != null ? SizedBox(height: 10.w) : Container(),
|
selecteChatbox != null ? SizedBox(height: 10.w) : Container(),
|
||||||
@ -234,19 +234,14 @@ class _BagsChatboxPageState
|
|||||||
alignment: Alignment.center,
|
alignment: Alignment.center,
|
||||||
children: [
|
children: [
|
||||||
Column(
|
Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
SizedBox(height: 25.w),
|
|
||||||
netImage(
|
netImage(
|
||||||
url: res.propsResources?.cover ?? "",
|
url: res.propsResources?.cover ?? "",
|
||||||
height: 35.w,
|
width: 96.w,
|
||||||
|
height: 52.w,
|
||||||
fit: BoxFit.contain,
|
fit: BoxFit.contain,
|
||||||
),
|
),
|
||||||
SizedBox(height: 8.w),
|
|
||||||
buildStoreBagItemTitle(
|
|
||||||
res.propsResources?.name ?? "",
|
|
||||||
textColor: Colors.black,
|
|
||||||
fontSize: 12.sp,
|
|
||||||
),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
PositionedDirectional(
|
PositionedDirectional(
|
||||||
@ -308,6 +303,11 @@ class _BagsChatboxPageState
|
|||||||
onTap: () {
|
onTap: () {
|
||||||
selecteChatbox = res;
|
selecteChatbox = res;
|
||||||
setState(() {});
|
setState(() {});
|
||||||
|
showStoreBagFullScreenResourcePreview(
|
||||||
|
context,
|
||||||
|
resource: res.propsResources,
|
||||||
|
fit: BoxFit.contain,
|
||||||
|
);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -69,7 +69,7 @@ class BagsDataCardPageState
|
|||||||
Expanded(
|
Expanded(
|
||||||
child:
|
child:
|
||||||
items.isEmpty && isLoading
|
items.isEmpty && isLoading
|
||||||
? const SCBagGridSkeleton()
|
? const SCBagGridSkeleton(showTitle: false)
|
||||||
: buildList(context),
|
: buildList(context),
|
||||||
),
|
),
|
||||||
hasSelection ? SizedBox(height: 10.w) : const SizedBox.shrink(),
|
hasSelection ? SizedBox(height: 10.w) : const SizedBox.shrink(),
|
||||||
@ -243,23 +243,17 @@ class BagsDataCardPageState
|
|||||||
alignment: Alignment.center,
|
alignment: Alignment.center,
|
||||||
children: [
|
children: [
|
||||||
Column(
|
Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
SizedBox(height: 25.w),
|
|
||||||
ClipRRect(
|
ClipRRect(
|
||||||
borderRadius: BorderRadius.circular(6.w),
|
borderRadius: BorderRadius.circular(8.w),
|
||||||
child: netImage(
|
child: netImage(
|
||||||
url: res.propsResources?.cover ?? "",
|
url: res.propsResources?.cover ?? "",
|
||||||
width: 72.w,
|
width: 108.w,
|
||||||
height: 50.w,
|
height: 75.w,
|
||||||
fit: BoxFit.cover,
|
fit: BoxFit.cover,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
SizedBox(height: 8.w),
|
|
||||||
buildStoreBagItemTitle(
|
|
||||||
res.propsResources?.name ?? "",
|
|
||||||
textColor: Colors.white,
|
|
||||||
fontSize: 12.sp,
|
|
||||||
),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
PositionedDirectional(
|
PositionedDirectional(
|
||||||
@ -321,6 +315,12 @@ class BagsDataCardPageState
|
|||||||
onTap: () {
|
onTap: () {
|
||||||
selectedDataCard = res;
|
selectedDataCard = res;
|
||||||
setState(() {});
|
setState(() {});
|
||||||
|
showStoreBagFullScreenResourcePreview(
|
||||||
|
context,
|
||||||
|
resource: res.propsResources,
|
||||||
|
previewKind: SCStoreItemPreviewKind.dataCard,
|
||||||
|
fit: BoxFit.contain,
|
||||||
|
);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -47,7 +47,7 @@ class _BagsGiftPageState
|
|||||||
backgroundColor: Colors.transparent,
|
backgroundColor: Colors.transparent,
|
||||||
body:
|
body:
|
||||||
items.isEmpty && isLoading
|
items.isEmpty && isLoading
|
||||||
? const SCBagGridSkeleton()
|
? const SCBagGridSkeleton(showTitle: false)
|
||||||
: buildList(context),
|
: buildList(context),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -62,6 +62,12 @@ class _BagsGiftPageState
|
|||||||
onTap: () {
|
onTap: () {
|
||||||
selectedGift = res;
|
selectedGift = res;
|
||||||
setState(() {});
|
setState(() {});
|
||||||
|
showStoreBagFullScreenResourcePreview(
|
||||||
|
context,
|
||||||
|
sourceUrl: gift?.giftSourceUrl,
|
||||||
|
coverUrl: gift?.giftPhoto,
|
||||||
|
fit: BoxFit.contain,
|
||||||
|
);
|
||||||
},
|
},
|
||||||
child: Container(
|
child: Container(
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
@ -77,21 +83,15 @@ class _BagsGiftPageState
|
|||||||
alignment: Alignment.center,
|
alignment: Alignment.center,
|
||||||
children: [
|
children: [
|
||||||
Column(
|
Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
SizedBox(height: 25.w),
|
|
||||||
netImage(
|
netImage(
|
||||||
url: gift?.giftPhoto ?? "",
|
url: gift?.giftPhoto ?? "",
|
||||||
width: 55.w,
|
width: 82.w,
|
||||||
height: 55.w,
|
height: 82.w,
|
||||||
fit: BoxFit.contain,
|
fit: BoxFit.contain,
|
||||||
),
|
),
|
||||||
SizedBox(height: 8.w),
|
SizedBox(height: 8.w),
|
||||||
buildStoreBagItemTitle(
|
|
||||||
gift?.giftName ?? "",
|
|
||||||
textColor: Colors.white,
|
|
||||||
fontSize: 12,
|
|
||||||
),
|
|
||||||
SizedBox(height: 6.w),
|
|
||||||
Row(
|
Row(
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
children: [
|
children: [
|
||||||
|
|||||||
@ -63,7 +63,7 @@ class _BagsHeaddressPageState
|
|||||||
Expanded(
|
Expanded(
|
||||||
child:
|
child:
|
||||||
items.isEmpty && isLoading
|
items.isEmpty && isLoading
|
||||||
? const SCBagGridSkeleton()
|
? const SCBagGridSkeleton(showTitle: false)
|
||||||
: buildList(context),
|
: buildList(context),
|
||||||
),
|
),
|
||||||
selecteHeaddress != null ? SizedBox(height: 10.w) : Container(),
|
selecteHeaddress != null ? SizedBox(height: 10.w) : Container(),
|
||||||
@ -234,18 +234,13 @@ class _BagsHeaddressPageState
|
|||||||
alignment: Alignment.center,
|
alignment: Alignment.center,
|
||||||
children: [
|
children: [
|
||||||
Column(
|
Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
SizedBox(height: 25.w),
|
|
||||||
netImage(
|
netImage(
|
||||||
url: res.propsResources?.cover ?? "",
|
url: res.propsResources?.cover ?? "",
|
||||||
width: 55.w,
|
width: 82.w,
|
||||||
height: 55.w,
|
height: 82.w,
|
||||||
),
|
fit: BoxFit.contain,
|
||||||
SizedBox(height: 8.w),
|
|
||||||
buildStoreBagItemTitle(
|
|
||||||
res.propsResources?.name ?? "",
|
|
||||||
textColor: Colors.white,
|
|
||||||
fontSize: 12.sp,
|
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@ -308,6 +303,12 @@ class _BagsHeaddressPageState
|
|||||||
onTap: () {
|
onTap: () {
|
||||||
selecteHeaddress = res;
|
selecteHeaddress = res;
|
||||||
setState(() {});
|
setState(() {});
|
||||||
|
showStoreBagFullScreenResourcePreview(
|
||||||
|
context,
|
||||||
|
resource: res.propsResources,
|
||||||
|
previewKind: SCStoreItemPreviewKind.avatarFrame,
|
||||||
|
fit: BoxFit.contain,
|
||||||
|
);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -69,7 +69,7 @@ class _BagsMountainsPageState
|
|||||||
Expanded(
|
Expanded(
|
||||||
child:
|
child:
|
||||||
items.isEmpty && isLoading
|
items.isEmpty && isLoading
|
||||||
? const SCBagGridSkeleton()
|
? const SCBagGridSkeleton(showTitle: false)
|
||||||
: buildList(context),
|
: buildList(context),
|
||||||
),
|
),
|
||||||
selecteMountains != null ? SizedBox(height: 10.w) : Container(),
|
selecteMountains != null ? SizedBox(height: 10.w) : Container(),
|
||||||
@ -240,18 +240,13 @@ class _BagsMountainsPageState
|
|||||||
alignment: Alignment.center,
|
alignment: Alignment.center,
|
||||||
children: [
|
children: [
|
||||||
Column(
|
Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
SizedBox(height: 25.w),
|
|
||||||
netImage(
|
netImage(
|
||||||
url: res.propsResources?.cover ?? "",
|
url: res.propsResources?.cover ?? "",
|
||||||
width: 55.w,
|
width: 82.w,
|
||||||
height: 55.w,
|
height: 82.w,
|
||||||
),
|
fit: BoxFit.contain,
|
||||||
SizedBox(height: 8.w),
|
|
||||||
buildStoreBagItemTitle(
|
|
||||||
res.propsResources?.name ?? "",
|
|
||||||
textColor: Colors.white,
|
|
||||||
fontSize: 12.sp,
|
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@ -293,6 +288,11 @@ class _BagsMountainsPageState
|
|||||||
onTap: () {
|
onTap: () {
|
||||||
selecteMountains = res;
|
selecteMountains = res;
|
||||||
setState(() {});
|
setState(() {});
|
||||||
|
showStoreBagFullScreenResourcePreview(
|
||||||
|
context,
|
||||||
|
resource: res.propsResources,
|
||||||
|
fit: BoxFit.contain,
|
||||||
|
);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -227,15 +227,14 @@ class _BagsThemePageState
|
|||||||
alignment: Alignment.center,
|
alignment: Alignment.center,
|
||||||
children: [
|
children: [
|
||||||
Column(
|
Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
SizedBox(height: 25.w),
|
|
||||||
netImage(
|
netImage(
|
||||||
url: res.themeBack ?? "",
|
url: res.themeBack ?? "",
|
||||||
width: 55.w,
|
width: 82.w,
|
||||||
height: 55.w,
|
height: 82.w,
|
||||||
borderRadius: BorderRadius.all(Radius.circular(8.w)),
|
borderRadius: BorderRadius.all(Radius.circular(8.w)),
|
||||||
),
|
),
|
||||||
SizedBox(height: 8.w),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
PositionedDirectional(
|
PositionedDirectional(
|
||||||
@ -276,6 +275,12 @@ class _BagsThemePageState
|
|||||||
onTap: () {
|
onTap: () {
|
||||||
selecteTheme = res;
|
selecteTheme = res;
|
||||||
setState(() {});
|
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) {
|
Widget _buildHeaderIntroduction(SocialChatUserProfileManager ref) {
|
||||||
final intro = (ref.userProfile?.autograph ?? "").trim();
|
final intro = (ref.userProfile?.autograph ?? "").trim();
|
||||||
return Container(
|
return Container(
|
||||||
@ -610,7 +633,7 @@ class _PersonDetailPageState extends State<PersonDetailPage> {
|
|||||||
PropsResources? dataCard,
|
PropsResources? dataCard,
|
||||||
) {
|
) {
|
||||||
return SizedBox(
|
return SizedBox(
|
||||||
height: 520.w,
|
height: 570.w,
|
||||||
child: Stack(
|
child: Stack(
|
||||||
children: [
|
children: [
|
||||||
SizedBox(
|
SizedBox(
|
||||||
@ -674,6 +697,8 @@ class _PersonDetailPageState extends State<PersonDetailPage> {
|
|||||||
SizedBox(height: 5.w),
|
SizedBox(height: 5.w),
|
||||||
_buildHeaderMeta(ref),
|
_buildHeaderMeta(ref),
|
||||||
SizedBox(height: 4.w),
|
SizedBox(height: 4.w),
|
||||||
|
_buildHeaderLongBadgeStrip(ref),
|
||||||
|
SizedBox(height: 4.w),
|
||||||
_buildHeaderBadgeStrip(ref),
|
_buildHeaderBadgeStrip(ref),
|
||||||
SizedBox(height: 6.w),
|
SizedBox(height: 6.w),
|
||||||
_buildHeaderIntroduction(ref),
|
_buildHeaderIntroduction(ref),
|
||||||
|
|||||||
@ -156,7 +156,9 @@ class RealTimeMessagingManager extends ChangeNotifier {
|
|||||||
|
|
||||||
BuildContext? context;
|
BuildContext? context;
|
||||||
|
|
||||||
void _giftFxLog(String message) {}
|
void _giftFxLog(String message) {
|
||||||
|
debugPrint('[GiftFx][rtm] $message');
|
||||||
|
}
|
||||||
|
|
||||||
void _enqueueGiftFloatingMessage(
|
void _enqueueGiftFloatingMessage(
|
||||||
SCFloatingMessage message, {
|
SCFloatingMessage message, {
|
||||||
|
|||||||
@ -32,18 +32,32 @@ class SCUserBadgeRes {
|
|||||||
final List<WearBadge> longBadges;
|
final List<WearBadge> longBadges;
|
||||||
final List<WearBadge> shortBadges;
|
final List<WearBadge> shortBadges;
|
||||||
|
|
||||||
List<WearBadge> get displayBadges {
|
List<WearBadge> get allBadges {
|
||||||
if (longBadges.isNotEmpty || shortBadges.isNotEmpty) {
|
if (longBadges.isNotEmpty || shortBadges.isNotEmpty) {
|
||||||
return <WearBadge>[...longBadges, ...shortBadges];
|
return <WearBadge>[...longBadges, ...shortBadges];
|
||||||
}
|
}
|
||||||
return badges;
|
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 {
|
List<WearBadge> get honorWallBadges {
|
||||||
final source =
|
final source =
|
||||||
shortBadges.isNotEmpty
|
shortBadges.isNotEmpty
|
||||||
? shortBadges
|
? shortBadges.where(_isHonorWallBadge).toList()
|
||||||
: displayBadges.where(_isHonorWallBadge).toList();
|
: allBadges.where(_isHonorWallBadge).toList();
|
||||||
final result =
|
final result =
|
||||||
source.where((badge) => _badgeUrl(badge).isNotEmpty).toList();
|
source.where((badge) => _badgeUrl(badge).isNotEmpty).toList();
|
||||||
result.sort((a, b) => _badgeSortValue(b).compareTo(_badgeSortValue(a)));
|
result.sort((a, b) => _badgeSortValue(b).compareTo(_badgeSortValue(a)));
|
||||||
@ -103,20 +117,36 @@ class SCUserBadgeRes {
|
|||||||
type == "ACHIEVEMENT";
|
type == "ACHIEVEMENT";
|
||||||
}
|
}
|
||||||
|
|
||||||
static bool _isHonorWallBadge(WearBadge badge) {
|
static bool isShortDisplayBadge(WearBadge badge) {
|
||||||
final sourceType = badge.sourceType?.trim().toUpperCase() ?? "";
|
final sourceType = badge.sourceType?.trim().toUpperCase() ?? "";
|
||||||
final type = badge.type?.trim().toUpperCase() ?? "";
|
final type = badge.type?.trim().toUpperCase() ?? "";
|
||||||
if (type == "LONG" || type == "VIP" || sourceType == "VIP") {
|
if (type == "LONG" || type == "VIP" || sourceType == "VIP") {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
if (sourceType.isNotEmpty) {
|
if (sourceType.isNotEmpty) {
|
||||||
return sourceType == "ACTIVITY" || sourceType == "ACHIEVEMENT";
|
return sourceType == "ACTIVITY" ||
|
||||||
|
sourceType == "ACHIEVEMENT" ||
|
||||||
|
sourceType == "HONOR_ACTIVITY" ||
|
||||||
|
sourceType == "HONOR_ADMIN";
|
||||||
}
|
}
|
||||||
return type == "SHORT" ||
|
if (type.isNotEmpty) {
|
||||||
type == "ACTIVITY" ||
|
return type == "SHORT" ||
|
||||||
type == "ACHIEVEMENT" ||
|
type == "ACTIVITY" ||
|
||||||
type == "HONOR_ACTIVITY" ||
|
type == "ACHIEVEMENT" ||
|
||||||
type == "HONOR_ADMIN";
|
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) {
|
static int _badgeSortValue(WearBadge badge) {
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
|
|
||||||
import 'package:device_info_plus/device_info_plus.dart';
|
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/app/constants/sc_global_config.dart';
|
||||||
import 'package:yumi/shared/data_sources/sources/local/data_persistence.dart';
|
import 'package:yumi/shared/data_sources/sources/local/data_persistence.dart';
|
||||||
import 'package:package_info_plus/package_info_plus.dart';
|
import 'package:package_info_plus/package_info_plus.dart';
|
||||||
@ -24,6 +25,7 @@ class SCDeviceIdUtils {
|
|||||||
isLowRamDevice: dev.isLowRamDevice,
|
isLowRamDevice: dev.isLowRamDevice,
|
||||||
processorCount: processorCount,
|
processorCount: processorCount,
|
||||||
);
|
);
|
||||||
|
_logPerformanceProfile();
|
||||||
} else if (Platform.isIOS) {
|
} else if (Platform.isIOS) {
|
||||||
SCGlobalConfig.channel = "AppStore";
|
SCGlobalConfig.channel = "AppStore";
|
||||||
final dev = await DeviceInfoPlugin().iosInfo;
|
final dev = await DeviceInfoPlugin().iosInfo;
|
||||||
@ -33,9 +35,20 @@ class SCDeviceIdUtils {
|
|||||||
isLowRamDevice: false,
|
isLowRamDevice: false,
|
||||||
processorCount: processorCount,
|
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
|
// 获取持久化的唯一设备ID
|
||||||
static Future<String> getDeviceId({bool forceRefresh = false}) async {
|
static Future<String> getDeviceId({bool forceRefresh = false}) async {
|
||||||
// 如果强制刷新或配置中不存在,则重新生成
|
// 如果强制刷新或配置中不存在,则重新生成
|
||||||
|
|||||||
@ -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/tools/sc_room_effect_scheduler.dart';
|
||||||
import 'package:yumi/shared/data_sources/sources/local/file_cache_manager.dart';
|
import 'package:yumi/shared/data_sources/sources/local/file_cache_manager.dart';
|
||||||
import 'package:tancent_vap/utils/constant.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';
|
import 'package:yumi/shared/data_sources/sources/local/data_persistence.dart';
|
||||||
|
|
||||||
@ -40,7 +40,7 @@ class SCGiftVapSvgaManager {
|
|||||||
final SCPriorityQueue<SCVapTask> _entryQueue = SCPriorityQueue<SCVapTask>(
|
final SCPriorityQueue<SCVapTask> _entryQueue = SCPriorityQueue<SCVapTask>(
|
||||||
(a, b) => a.compareTo(b), // 调用 SCVapTask 的 compareTo 方法
|
(a, b) => a.compareTo(b), // 调用 SCVapTask 的 compareTo 方法
|
||||||
);
|
);
|
||||||
VapController? _rgc;
|
SCGiftMp4Controller? _mp4Controller;
|
||||||
SVGAAnimationController? _rsc;
|
SVGAAnimationController? _rsc;
|
||||||
bool _play = false;
|
bool _play = false;
|
||||||
bool _dis = false;
|
bool _dis = false;
|
||||||
@ -62,9 +62,9 @@ class SCGiftVapSvgaManager {
|
|||||||
void setMute(bool muteMusic) {
|
void setMute(bool muteMusic) {
|
||||||
_mute = muteMusic;
|
_mute = muteMusic;
|
||||||
_rsc?.muted = _mute;
|
_rsc?.muted = _mute;
|
||||||
final vapController = _rgc;
|
final mp4Controller = _mp4Controller;
|
||||||
if (vapController != null) {
|
if (mp4Controller != null) {
|
||||||
unawaited(vapController.setMute(_mute));
|
unawaited(mp4Controller.setMute(_mute));
|
||||||
}
|
}
|
||||||
unawaited(DataPersistence.setPlayGiftMusic(_mute));
|
unawaited(DataPersistence.setPlayGiftMusic(_mute));
|
||||||
}
|
}
|
||||||
@ -73,12 +73,23 @@ class SCGiftVapSvgaManager {
|
|||||||
return _mute;
|
return _mute;
|
||||||
}
|
}
|
||||||
|
|
||||||
void _log(String message) {}
|
void _log(String message) {
|
||||||
|
debugPrint('[GiftFx][manager] $message');
|
||||||
|
}
|
||||||
|
|
||||||
bool _needsSvgaController(String path) {
|
bool _needsSvgaController(String path) {
|
||||||
return SCPathUtils.getFileExtension(path).toLowerCase() == ".svga";
|
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({
|
static int resolveEffectPriority({
|
||||||
int priority = 0,
|
int priority = 0,
|
||||||
int type = giftEffectType,
|
int type = giftEffectType,
|
||||||
@ -103,7 +114,7 @@ class SCGiftVapSvgaManager {
|
|||||||
if (_needsSvgaController(task.path)) {
|
if (_needsSvgaController(task.path)) {
|
||||||
return _rsc != null;
|
return _rsc != null;
|
||||||
}
|
}
|
||||||
return _rgc != null;
|
return _mp4Controller != null;
|
||||||
}
|
}
|
||||||
|
|
||||||
bool _isPlayableFileReady(String path) {
|
bool _isPlayableFileReady(String path) {
|
||||||
@ -133,6 +144,13 @@ class SCGiftVapSvgaManager {
|
|||||||
_isPreloadedOrLoading(path)) {
|
_isPreloadedOrLoading(path)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (!_isSupportedEffectPath(path)) {
|
||||||
|
_log(
|
||||||
|
'preload ignored because effect file extension is unsupported '
|
||||||
|
'path=$path ext=${SCPathUtils.getFileExtension(path)}',
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (highPriority) {
|
if (highPriority) {
|
||||||
_log('high priority preload path=$path');
|
_log('high priority preload path=$path');
|
||||||
await _warmupPath(path);
|
await _warmupPath(path);
|
||||||
@ -434,27 +452,25 @@ class SCGiftVapSvgaManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 绑定控制器
|
// 绑定控制器
|
||||||
void bindVapCtrl(VapController vapController) {
|
void bindMp4Ctrl(SCGiftMp4Controller mp4Controller) {
|
||||||
_mute = DataPersistence.getPlayGiftMusic();
|
_mute = DataPersistence.getPlayGiftMusic();
|
||||||
_dis = false;
|
_dis = false;
|
||||||
_rgc = vapController;
|
_mp4Controller = mp4Controller;
|
||||||
unawaited(vapController.setMute(_mute));
|
unawaited(mp4Controller.setMute(_mute));
|
||||||
_log(
|
_log(
|
||||||
'bindVapCtrl hasVapCtrl=${_rgc != null} '
|
'bindMp4Ctrl hasMp4Ctrl=${_mp4Controller != null} '
|
||||||
'hasSvgaCtrl=${_rsc != null} queue=$_queueSummary mute=$_mute',
|
'hasSvgaCtrl=${_rsc != null} queue=$_queueSummary mute=$_mute',
|
||||||
);
|
);
|
||||||
_rgc?.setAnimListener(
|
_mp4Controller?.setPlaybackListener(
|
||||||
onVideoStart: () {
|
onStart: () {
|
||||||
_log('vap onVideoStart path=${_currentTask?.path}');
|
_log('mp4 onVideoStart path=${_currentTask?.path}');
|
||||||
},
|
},
|
||||||
onVideoComplete: () {
|
onComplete: () {
|
||||||
_log('vap onVideoComplete path=${_currentTask?.path}');
|
_log('mp4 onVideoComplete path=${_currentTask?.path}');
|
||||||
_hcs();
|
_hcs();
|
||||||
},
|
},
|
||||||
onFailed: (code, type, msg) {
|
onFailed: (error) {
|
||||||
_log(
|
_log('mp4 onFailed path=${_currentTask?.path} error=$error');
|
||||||
'vap onFailed path=${_currentTask?.path} code=$code type=$type msg=$msg',
|
|
||||||
);
|
|
||||||
_hcs();
|
_hcs();
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@ -469,7 +485,7 @@ class SCGiftVapSvgaManager {
|
|||||||
_rsc?.muted = _mute;
|
_rsc?.muted = _mute;
|
||||||
_log(
|
_log(
|
||||||
'bindSvgaCtrl hasSvgaCtrl=${_rsc != null} '
|
'bindSvgaCtrl hasSvgaCtrl=${_rsc != null} '
|
||||||
'hasVapCtrl=${_rgc != null} queue=$_queueSummary',
|
'hasMp4Ctrl=${_mp4Controller != null} queue=$_queueSummary',
|
||||||
);
|
);
|
||||||
_rsc?.addStatusListener((AnimationStatus status) {
|
_rsc?.addStatusListener((AnimationStatus status) {
|
||||||
if (status.isCompleted) {
|
if (status.isCompleted) {
|
||||||
@ -493,6 +509,13 @@ class SCGiftVapSvgaManager {
|
|||||||
_log('play ignored because path is empty');
|
_log('play ignored because path is empty');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (!_isSupportedEffectPath(path)) {
|
||||||
|
_log(
|
||||||
|
'play ignored because effect file extension is unsupported '
|
||||||
|
'path=$path ext=${SCPathUtils.getFileExtension(path)}',
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
final resolvedPriority = resolveEffectPriority(
|
final resolvedPriority = resolveEffectPriority(
|
||||||
priority: priority,
|
priority: priority,
|
||||||
type: type,
|
type: type,
|
||||||
@ -503,12 +526,13 @@ class SCGiftVapSvgaManager {
|
|||||||
'sdkInt=${SCGlobalConfig.sdkInt} maxSdkNoAnim=${SCGlobalConfig.maxSdkNoAnim} '
|
'sdkInt=${SCGlobalConfig.sdkInt} maxSdkNoAnim=${SCGlobalConfig.maxSdkNoAnim} '
|
||||||
'lowPerformance=${SCGlobalConfig.isLowPerformanceDevice} '
|
'lowPerformance=${SCGlobalConfig.isLowPerformanceDevice} '
|
||||||
'disposed=$_dis playing=$_play queueBefore=$_queueSummary '
|
'disposed=$_dis playing=$_play queueBefore=$_queueSummary '
|
||||||
'hasSvgaCtrl=${_rsc != null} hasVapCtrl=${_rgc != null}',
|
'hasSvgaCtrl=${_rsc != null} hasMp4Ctrl=${_mp4Controller != null}',
|
||||||
);
|
);
|
||||||
if (SCGlobalConfig.allowsHighCostAnimations) {
|
if (SCGlobalConfig.allowsHighCostAnimations) {
|
||||||
if (_dis) {
|
if (_dis) {
|
||||||
_log('play ignored because manager is disposed path=$path');
|
_log(
|
||||||
return;
|
'manager disposed/no controller; queue play until bind path=$path',
|
||||||
|
);
|
||||||
}
|
}
|
||||||
final task = SCVapTask(
|
final task = SCVapTask(
|
||||||
path: path,
|
path: path,
|
||||||
@ -546,7 +570,7 @@ class SCGiftVapSvgaManager {
|
|||||||
_log(
|
_log(
|
||||||
'controller not ready for path=${task?.path} '
|
'controller not ready for path=${task?.path} '
|
||||||
'needSvga=${task != null ? _needsSvgaController(task.path) : "unknown"} '
|
'needSvga=${task != null ? _needsSvgaController(task.path) : "unknown"} '
|
||||||
'hasSvgaCtrl=${_rsc != null} hasVapCtrl=${_rgc != null} '
|
'hasSvgaCtrl=${_rsc != null} hasMp4Ctrl=${_mp4Controller != null} '
|
||||||
'queue=$_queueSummary',
|
'queue=$_queueSummary',
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
@ -596,51 +620,47 @@ class SCGiftVapSvgaManager {
|
|||||||
_log('forward asset svga path=${task.path}');
|
_log('forward asset svga path=${task.path}');
|
||||||
} else {
|
} else {
|
||||||
_log('play asset vap/mp4 path=${task.path}');
|
_log('play asset vap/mp4 path=${task.path}');
|
||||||
if (task.customResources != null) {
|
await _mp4Controller?.playAsset(task.path);
|
||||||
task.customResources?.forEach((k, v) async {
|
|
||||||
await _rgc?.setVapTagContent(k, v);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
await _rgc?.playAsset(task.path);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 播放本地文件
|
// 播放本地文件
|
||||||
Future<void> _pf(SCVapTask task) async {
|
Future<void> _pf(SCVapTask task, {String? playablePath}) async {
|
||||||
if (_dis) {
|
if (_dis) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
final resolvedPath = playablePath ?? task.path;
|
||||||
if (SCPathUtils.getFileExtension(task.path).toLowerCase() == ".svga") {
|
if (SCPathUtils.getFileExtension(task.path).toLowerCase() == ".svga") {
|
||||||
final entity = await _loadSvgaEntity(task.path);
|
final entity = await _loadSvgaEntity(resolvedPath);
|
||||||
if (!_isCurrentTask(task)) {
|
if (!_isCurrentTask(task)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
_log('play local svga file path=${task.path}');
|
_log('play local svga file path=$resolvedPath source=${task.path}');
|
||||||
_rsc?.muted = _mute;
|
_rsc?.muted = _mute;
|
||||||
_rsc?.videoItem = entity;
|
_rsc?.videoItem = entity;
|
||||||
_rsc?.reset();
|
_rsc?.reset();
|
||||||
_rsc?.forward();
|
_rsc?.forward();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (_rgc == null) {
|
if (!_needsMp4Controller(task.path) && !_needsMp4Controller(resolvedPath)) {
|
||||||
_log('skip playFile because vap controller is null path=${task.path}');
|
_log(
|
||||||
|
'skip playFile because effect file extension is unsupported '
|
||||||
|
'path=$resolvedPath source=${task.path}',
|
||||||
|
);
|
||||||
_finishCurrentTask();
|
_finishCurrentTask();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
_log('play local vap/mp4 file path=${task.path}');
|
if (_mp4Controller == null) {
|
||||||
await _rgc?.setMute(_mute);
|
_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)) {
|
if (!_isCurrentTask(task)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (task.customResources != null) {
|
await _mp4Controller!.playFile(resolvedPath);
|
||||||
task.customResources?.forEach((k, v) async {
|
|
||||||
await _rgc?.setVapTagContent(k, v);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
if (!_isCurrentTask(task)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
await _rgc!.playFile(task.path);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 播放网络资源
|
// 播放网络资源
|
||||||
@ -672,22 +692,15 @@ class SCGiftVapSvgaManager {
|
|||||||
}
|
}
|
||||||
if (!_dis) {
|
if (!_dis) {
|
||||||
_log('use prepared network vap/mp4 local path=$playablePath');
|
_log('use prepared network vap/mp4 local path=$playablePath');
|
||||||
await _pf(
|
await _pf(task, playablePath: playablePath);
|
||||||
SCVapTask(
|
|
||||||
path: playablePath,
|
|
||||||
type: task.type,
|
|
||||||
priority: task.priority,
|
|
||||||
customResources: task.customResources,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 处理控制器状态变化
|
// 处理控制器状态变化
|
||||||
void _hcs() {
|
void _hcs() {
|
||||||
if (_rgc != null && !_dis) {
|
if (_mp4Controller != null && !_dis) {
|
||||||
_log('finish vap task path=${_currentTask?.path}');
|
_log('finish mp4 task path=${_currentTask?.path}');
|
||||||
_finishCurrentTask(delay: const Duration(milliseconds: 50));
|
_finishCurrentTask(delay: const Duration(milliseconds: 50));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -703,7 +716,7 @@ class SCGiftVapSvgaManager {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
_log('task watchdog timeout path=${task.path}');
|
_log('task watchdog timeout path=${task.path}');
|
||||||
_rgc?.stop();
|
_mp4Controller?.stop();
|
||||||
_rsc?.stop();
|
_rsc?.stop();
|
||||||
_rsc?.reset();
|
_rsc?.reset();
|
||||||
_rsc?.videoItem = null;
|
_rsc?.videoItem = null;
|
||||||
@ -741,7 +754,7 @@ class SCGiftVapSvgaManager {
|
|||||||
_preloadQueue.clear();
|
_preloadQueue.clear();
|
||||||
_queuedPreloadPaths.clear();
|
_queuedPreloadPaths.clear();
|
||||||
_activePreloadCount = 0;
|
_activePreloadCount = 0;
|
||||||
_rgc?.stop();
|
_mp4Controller?.stop();
|
||||||
_rsc?.stop();
|
_rsc?.stop();
|
||||||
_rsc?.reset();
|
_rsc?.reset();
|
||||||
_rsc?.videoItem = null;
|
_rsc?.videoItem = null;
|
||||||
@ -790,8 +803,8 @@ class SCGiftVapSvgaManager {
|
|||||||
_playablePathTasks.clear();
|
_playablePathTasks.clear();
|
||||||
clearMemoryCache(includeCurrent: true);
|
clearMemoryCache(includeCurrent: true);
|
||||||
_playablePathCache.clear();
|
_playablePathCache.clear();
|
||||||
_rgc?.dispose();
|
_mp4Controller?.dispose();
|
||||||
_rgc = null;
|
_mp4Controller = null;
|
||||||
_rsc?.dispose();
|
_rsc?.dispose();
|
||||||
_rsc = null;
|
_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/shared/data_sources/sources/repositories/sc_user_repository_impl.dart';
|
||||||
import 'package:yumi/ui_kit/components/sc_compontent.dart';
|
import 'package:yumi/ui_kit/components/sc_compontent.dart';
|
||||||
|
|
||||||
|
enum SCUserBadgeDisplayScope { short, long, all }
|
||||||
|
|
||||||
class SCUserBadgeStrip extends StatefulWidget {
|
class SCUserBadgeStrip extends StatefulWidget {
|
||||||
const SCUserBadgeStrip({
|
const SCUserBadgeStrip({
|
||||||
super.key,
|
super.key,
|
||||||
this.userId,
|
this.userId,
|
||||||
this.fallbackBadges = const <WearBadge>[],
|
this.fallbackBadges = const <WearBadge>[],
|
||||||
|
this.displayScope = SCUserBadgeDisplayScope.short,
|
||||||
this.height,
|
this.height,
|
||||||
this.badgeHeight,
|
this.badgeHeight,
|
||||||
this.longBadgeWidth,
|
this.longBadgeWidth,
|
||||||
this.spacing,
|
this.spacing,
|
||||||
|
this.runSpacing,
|
||||||
this.maxBadges,
|
this.maxBadges,
|
||||||
this.wrap = false,
|
this.wrap = false,
|
||||||
|
this.maxWrapRows,
|
||||||
|
this.scrollLastWrapRow = false,
|
||||||
this.reserveSpace = false,
|
this.reserveSpace = false,
|
||||||
this.alignment = WrapAlignment.center,
|
this.alignment = WrapAlignment.center,
|
||||||
});
|
});
|
||||||
|
|
||||||
final String? userId;
|
final String? userId;
|
||||||
final List<WearBadge> fallbackBadges;
|
final List<WearBadge> fallbackBadges;
|
||||||
|
final SCUserBadgeDisplayScope displayScope;
|
||||||
final double? height;
|
final double? height;
|
||||||
final double? badgeHeight;
|
final double? badgeHeight;
|
||||||
final double? longBadgeWidth;
|
final double? longBadgeWidth;
|
||||||
final double? spacing;
|
final double? spacing;
|
||||||
|
final double? runSpacing;
|
||||||
final int? maxBadges;
|
final int? maxBadges;
|
||||||
final bool wrap;
|
final bool wrap;
|
||||||
|
final int? maxWrapRows;
|
||||||
|
final bool scrollLastWrapRow;
|
||||||
final bool reserveSpace;
|
final bool reserveSpace;
|
||||||
final WrapAlignment alignment;
|
final WrapAlignment alignment;
|
||||||
|
|
||||||
@ -100,20 +110,48 @@ class _SCUserBadgeStripState extends State<SCUserBadgeStrip> {
|
|||||||
return FutureBuilder<SCUserBadgeRes>(
|
return FutureBuilder<SCUserBadgeRes>(
|
||||||
future: future,
|
future: future,
|
||||||
builder: (context, snapshot) {
|
builder: (context, snapshot) {
|
||||||
final remoteBadges =
|
if (snapshot.hasData) {
|
||||||
snapshot.data?.displayBadges ?? const <WearBadge>[];
|
return _buildBadges(_remoteBadges(snapshot.data), stripHeight);
|
||||||
final badges = remoteBadges.isNotEmpty ? remoteBadges : _fallbackBadges;
|
}
|
||||||
return _buildBadges(badges, stripHeight);
|
return _buildBadges(_fallbackBadges, stripHeight);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
List<WearBadge> get _fallbackBadges {
|
List<WearBadge> get _fallbackBadges {
|
||||||
return widget.fallbackBadges
|
return widget.fallbackBadges
|
||||||
|
.where(_matchesDisplayScope)
|
||||||
.where((badge) => _badgeUrl(badge).isNotEmpty)
|
.where((badge) => _badgeUrl(badge).isNotEmpty)
|
||||||
.toList();
|
.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) {
|
Widget _buildBadges(List<WearBadge> rawBadges, double stripHeight) {
|
||||||
final badges =
|
final badges =
|
||||||
widget.maxBadges == null
|
widget.maxBadges == null
|
||||||
@ -127,19 +165,29 @@ class _SCUserBadgeStripState extends State<SCUserBadgeStrip> {
|
|||||||
|
|
||||||
final badgeHeight = widget.badgeHeight ?? stripHeight;
|
final badgeHeight = widget.badgeHeight ?? stripHeight;
|
||||||
final spacing = widget.spacing ?? 5.w;
|
final spacing = widget.spacing ?? 5.w;
|
||||||
|
final runSpacing = widget.runSpacing ?? 5.w;
|
||||||
final children =
|
final children =
|
||||||
badges
|
badges
|
||||||
.map((badge) => _buildBadge(badge, badgeHeight: badgeHeight))
|
.map((badge) => _buildBadge(badge, badgeHeight: badgeHeight))
|
||||||
.toList();
|
.toList();
|
||||||
|
|
||||||
if (widget.wrap) {
|
if (widget.wrap) {
|
||||||
|
if (widget.maxWrapRows != null) {
|
||||||
|
return _buildLimitedWrapBadges(
|
||||||
|
badges,
|
||||||
|
stripHeight: stripHeight,
|
||||||
|
badgeHeight: badgeHeight,
|
||||||
|
spacing: spacing,
|
||||||
|
runSpacing: runSpacing,
|
||||||
|
);
|
||||||
|
}
|
||||||
return SizedBox(
|
return SizedBox(
|
||||||
height: stripHeight,
|
height: stripHeight,
|
||||||
child: Center(
|
child: Center(
|
||||||
child: Wrap(
|
child: Wrap(
|
||||||
alignment: widget.alignment,
|
alignment: widget.alignment,
|
||||||
spacing: spacing,
|
spacing: spacing,
|
||||||
runSpacing: 5.w,
|
runSpacing: runSpacing,
|
||||||
children: children,
|
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}) {
|
Widget _buildBadge(WearBadge badge, {required double badgeHeight}) {
|
||||||
final type = badge.type?.trim().toUpperCase() ?? "";
|
final width = _badgeWidth(badge, badgeHeight);
|
||||||
final sourceType = badge.sourceType?.trim().toUpperCase() ?? "";
|
|
||||||
final isLongBadge = type == "LONG" || type == "VIP" || sourceType == "VIP";
|
|
||||||
final width =
|
|
||||||
isLongBadge
|
|
||||||
? (widget.longBadgeWidth ?? badgeHeight * 2.2)
|
|
||||||
: badgeHeight;
|
|
||||||
return netImage(
|
return netImage(
|
||||||
url: _badgeUrl(badge),
|
url: _badgeUrl(badge),
|
||||||
width: width,
|
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) {
|
String _badgeUrl(WearBadge badge) {
|
||||||
return (badge.selectUrl ?? "").trim();
|
return (badge.selectUrl ?? "").trim();
|
||||||
}
|
}
|
||||||
@ -192,6 +405,20 @@ class _SCUserBadgeStripState extends State<SCUserBadgeStrip> {
|
|||||||
}
|
}
|
||||||
return spaced;
|
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 {
|
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,8 +1,7 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_svga/flutter_svga.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';
|
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 {
|
class VapPlusSvgaPlayer extends StatefulWidget {
|
||||||
final String tag;
|
final String tag;
|
||||||
@ -16,16 +15,21 @@ class VapPlusSvgaPlayer extends StatefulWidget {
|
|||||||
class _VapPlusSvgaPlayerState extends State<VapPlusSvgaPlayer>
|
class _VapPlusSvgaPlayerState extends State<VapPlusSvgaPlayer>
|
||||||
with TickerProviderStateMixin {
|
with TickerProviderStateMixin {
|
||||||
late SVGAAnimationController _svgaController;
|
late SVGAAnimationController _svgaController;
|
||||||
|
late SCGiftMp4Controller _mp4Controller;
|
||||||
|
|
||||||
void _giftFxLog(String message) {}
|
void _giftFxLog(String message) {
|
||||||
|
debugPrint('[GiftFx][host] $message');
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
_svgaController = SVGAAnimationController(vsync: this);
|
_svgaController = SVGAAnimationController(vsync: this);
|
||||||
|
_mp4Controller = SCGiftMp4Controller();
|
||||||
_giftFxLog('initState tag=${widget.tag}');
|
_giftFxLog('initState tag=${widget.tag}');
|
||||||
if (widget.tag == "room_gift") {
|
if (widget.tag == "room_gift") {
|
||||||
SCGiftVapSvgaManager().bindSvgaCtrl(_svgaController);
|
SCGiftVapSvgaManager().bindSvgaCtrl(_svgaController);
|
||||||
|
SCGiftVapSvgaManager().bindMp4Ctrl(_mp4Controller);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -44,16 +48,7 @@ class _VapPlusSvgaPlayerState extends State<VapPlusSvgaPlayer>
|
|||||||
children: [
|
children: [
|
||||||
Positioned.fill(
|
Positioned.fill(
|
||||||
child: RepaintBoundary(
|
child: RepaintBoundary(
|
||||||
child: VapView(
|
child: GiftMp4EffectView(controller: _mp4Controller),
|
||||||
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(
|
Positioned.fill(
|
||||||
|
|||||||
@ -1303,16 +1303,40 @@ class _MsgItemState extends State<MsgItem> {
|
|||||||
child: SCUserBadgeStrip(
|
child: SCUserBadgeStrip(
|
||||||
userId: userId,
|
userId: userId,
|
||||||
fallbackBadges: fallbackBadges,
|
fallbackBadges: fallbackBadges,
|
||||||
height: 23.w,
|
height: 28.w,
|
||||||
badgeHeight: 21.w,
|
badgeHeight: 25.w,
|
||||||
longBadgeWidth: 51.w,
|
longBadgeWidth: 51.w,
|
||||||
spacing: 4.5.w,
|
spacing: 5.w,
|
||||||
maxBadges: 8,
|
maxBadges: 8,
|
||||||
reserveSpace: true,
|
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) {
|
int _msgColor(String type) {
|
||||||
switch (type) {
|
switch (type) {
|
||||||
case SCRoomMsgType.joinRoom:
|
case SCRoomMsgType.joinRoom:
|
||||||
@ -1851,13 +1875,18 @@ class _MsgItemState extends State<MsgItem> {
|
|||||||
SizedBox(width: 8.w),
|
SizedBox(width: 8.w),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: SizedBox(
|
child: SizedBox(
|
||||||
height: 50.w,
|
height: 76.w,
|
||||||
child: Column(
|
child: Column(
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
_buildUserNameAndLevels(widget.msg.user),
|
_buildUserNameAndLevels(widget.msg.user),
|
||||||
SizedBox(height: 2.w),
|
SizedBox(height: 2.w),
|
||||||
|
_buildLongMedals(
|
||||||
|
userId: widget.msg.user?.id,
|
||||||
|
fallbackBadges: _activeWearBadges(widget.msg.user),
|
||||||
|
),
|
||||||
|
SizedBox(height: 2.w),
|
||||||
_buildMedals(
|
_buildMedals(
|
||||||
userId: widget.msg.user?.id,
|
userId: widget.msg.user?.id,
|
||||||
fallbackBadges: _activeWearBadges(widget.msg.user),
|
fallbackBadges: _activeWearBadges(widget.msg.user),
|
||||||
@ -1897,13 +1926,18 @@ class _MsgItemState extends State<MsgItem> {
|
|||||||
SizedBox(width: 8.w),
|
SizedBox(width: 8.w),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: SizedBox(
|
child: SizedBox(
|
||||||
height: 50.w,
|
height: 76.w,
|
||||||
child: Column(
|
child: Column(
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
_buildUserNameAndLevels(user),
|
_buildUserNameAndLevels(user),
|
||||||
SizedBox(height: 2.w),
|
SizedBox(height: 2.w),
|
||||||
|
_buildLongMedals(
|
||||||
|
userId: user.id,
|
||||||
|
fallbackBadges: _activeWearBadges(user),
|
||||||
|
),
|
||||||
|
SizedBox(height: 2.w),
|
||||||
_buildMedals(
|
_buildMedals(
|
||||||
userId: user.id,
|
userId: user.id,
|
||||||
fallbackBadges: _activeWearBadges(user),
|
fallbackBadges: _activeWearBadges(user),
|
||||||
|
|||||||
@ -282,13 +282,13 @@ class _RoomUserInfoCardState extends State<RoomUserInfoCard> {
|
|||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
_buildProfileHeader(context, profile, ref),
|
_buildProfileHeader(context, profile, ref),
|
||||||
SizedBox(height: 6.w),
|
SizedBox(height: 4.w),
|
||||||
_buildBadgeStrip(
|
_buildBadgeStrip(
|
||||||
profile?.id ?? widget.userId,
|
profile?.id ?? widget.userId,
|
||||||
ref,
|
ref,
|
||||||
profile,
|
profile,
|
||||||
),
|
),
|
||||||
SizedBox(height: 8.w),
|
SizedBox(height: 5.w),
|
||||||
_buildActions(
|
_buildActions(
|
||||||
context: context,
|
context: context,
|
||||||
ref: ref,
|
ref: ref,
|
||||||
@ -422,15 +422,17 @@ class _RoomUserInfoCardState extends State<RoomUserInfoCard> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
SizedBox(height: 5.w),
|
SizedBox(height: 5.w),
|
||||||
Row(
|
Wrap(
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
alignment: WrapAlignment.center,
|
||||||
|
crossAxisAlignment: WrapCrossAlignment.center,
|
||||||
|
spacing: 4.w,
|
||||||
|
runSpacing: 5.w,
|
||||||
children: [
|
children: [
|
||||||
msgRoleTag(
|
msgRoleTag(
|
||||||
ref.userCardInfo?.roomRole ?? "",
|
ref.userCardInfo?.roomRole ?? "",
|
||||||
width: 16.w,
|
width: 16.w,
|
||||||
height: 16.w,
|
height: 16.w,
|
||||||
),
|
),
|
||||||
SizedBox(width: 4.w),
|
|
||||||
socialchatNickNameText(
|
socialchatNickNameText(
|
||||||
maxWidth: 116.w,
|
maxWidth: 116.w,
|
||||||
profile?.userNickname ?? "",
|
profile?.userNickname ?? "",
|
||||||
@ -440,13 +442,11 @@ class _RoomUserInfoCardState extends State<RoomUserInfoCard> {
|
|||||||
type: "",
|
type: "",
|
||||||
needScroll: (profile?.userNickname?.characters.length ?? 0) > 12,
|
needScroll: (profile?.userNickname?.characters.length ?? 0) > 12,
|
||||||
),
|
),
|
||||||
SizedBox(width: 3.w),
|
|
||||||
getVIPBadge(
|
getVIPBadge(
|
||||||
profile?.vipLevel ?? profile?.getVIP()?.name,
|
profile?.vipLevel ?? profile?.getVIP()?.name,
|
||||||
width: 45.w,
|
width: 45.w,
|
||||||
height: 25.w,
|
height: 25.w,
|
||||||
),
|
),
|
||||||
SizedBox(width: 3.w),
|
|
||||||
getWealthLevel(
|
getWealthLevel(
|
||||||
profile?.wealthLevel ??
|
profile?.wealthLevel ??
|
||||||
ref.userCardInfo?.userLevel?.wealthLevel ??
|
ref.userCardInfo?.userLevel?.wealthLevel ??
|
||||||
@ -455,7 +455,6 @@ class _RoomUserInfoCardState extends State<RoomUserInfoCard> {
|
|||||||
height: 20.w,
|
height: 20.w,
|
||||||
fontSize: 9.sp,
|
fontSize: 9.sp,
|
||||||
),
|
),
|
||||||
SizedBox(width: 2.w),
|
|
||||||
getUserLevel(
|
getUserLevel(
|
||||||
profile?.charmLevel ??
|
profile?.charmLevel ??
|
||||||
ref.userCardInfo?.userLevel?.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),
|
SizedBox(height: 2.w),
|
||||||
_buildSpecialIdMetaRow(context, profile),
|
_buildSpecialIdMetaRow(context, profile),
|
||||||
],
|
],
|
||||||
@ -552,21 +553,55 @@ class _RoomUserInfoCardState extends State<RoomUserInfoCard> {
|
|||||||
return xb(profile?.userSex, height: 15.w, color: null);
|
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(
|
Widget _buildBadgeStrip(
|
||||||
String? userId,
|
String? userId,
|
||||||
SocialChatUserProfileManager ref,
|
SocialChatUserProfileManager ref,
|
||||||
SocialChatUserProfile? profile,
|
SocialChatUserProfile? profile,
|
||||||
) {
|
) {
|
||||||
return SCUserBadgeStrip(
|
return SizedBox(
|
||||||
userId: userId,
|
width: double.infinity,
|
||||||
fallbackBadges: _roomCardFallbackBadges(ref, profile),
|
child: Padding(
|
||||||
height: 70.w,
|
padding: EdgeInsets.symmetric(horizontal: 10.w),
|
||||||
badgeHeight: 57.5.w,
|
child: SCUserBadgeStrip(
|
||||||
longBadgeWidth: 145.w,
|
userId: userId,
|
||||||
spacing: 20.w,
|
fallbackBadges: _roomCardFallbackBadges(ref, profile),
|
||||||
maxBadges: 12,
|
displayScope: SCUserBadgeDisplayScope.short,
|
||||||
wrap: false,
|
height: 62.w,
|
||||||
reserveSpace: true,
|
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/material.dart';
|
||||||
import 'package:flutter_screenutil/flutter_screenutil.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/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(
|
const Duration _kStoreBagSkeletonAnimationDuration = Duration(
|
||||||
milliseconds: 1450,
|
milliseconds: 1450,
|
||||||
@ -10,6 +25,51 @@ const Color _kStoreBagSkeletonShellStrong = Color(0xFF1A4A41);
|
|||||||
const Color _kStoreBagSkeletonBoneBase = Color(0xFF2B5C53);
|
const Color _kStoreBagSkeletonBoneBase = Color(0xFF2B5C53);
|
||||||
const Color _kStoreBagSkeletonBoneHighlight = Color(0xFF5F8177);
|
const Color _kStoreBagSkeletonBoneHighlight = Color(0xFF5F8177);
|
||||||
const Color _kStoreBagSkeletonBorder = Color(0x66D8B57B);
|
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(
|
Widget buildStoreBagItemTitle(
|
||||||
String content, {
|
String content, {
|
||||||
@ -86,7 +146,7 @@ class SCStoreGridSkeleton extends StatelessWidget {
|
|||||||
crossAxisCount: 3,
|
crossAxisCount: 3,
|
||||||
mainAxisSpacing: 12.w,
|
mainAxisSpacing: 12.w,
|
||||||
crossAxisSpacing: 12.w,
|
crossAxisSpacing: 12.w,
|
||||||
childAspectRatio: 0.78,
|
childAspectRatio: 0.88,
|
||||||
),
|
),
|
||||||
itemCount: itemCount,
|
itemCount: itemCount,
|
||||||
itemBuilder: (context, index) => _buildStoreCardSkeleton(progress),
|
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 {
|
class SCBagGridSkeleton extends StatelessWidget {
|
||||||
const SCBagGridSkeleton({
|
const SCBagGridSkeleton({
|
||||||
super.key,
|
super.key,
|
||||||
@ -130,9 +650,18 @@ Widget _buildStoreCardSkeleton(double progress) {
|
|||||||
return DecoratedBox(
|
return DecoratedBox(
|
||||||
decoration: _buildStoreBagShellDecoration(radius: 8.w),
|
decoration: _buildStoreBagShellDecoration(radius: 8.w),
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: EdgeInsets.symmetric(horizontal: 10.w, vertical: 10.w),
|
padding: EdgeInsets.symmetric(horizontal: 8.w, vertical: 8.w),
|
||||||
child: Column(
|
child: Column(
|
||||||
children: [
|
children: [
|
||||||
|
Align(
|
||||||
|
alignment: AlignmentDirectional.centerStart,
|
||||||
|
child: _buildStoreBagSkeletonBone(
|
||||||
|
progress,
|
||||||
|
width: 42.w,
|
||||||
|
height: 14.w,
|
||||||
|
borderRadius: BorderRadius.circular(4.w),
|
||||||
|
),
|
||||||
|
),
|
||||||
SizedBox(height: 2.w),
|
SizedBox(height: 2.w),
|
||||||
_buildStoreBagSkeletonBone(
|
_buildStoreBagSkeletonBone(
|
||||||
progress,
|
progress,
|
||||||
@ -140,14 +669,7 @@ Widget _buildStoreCardSkeleton(double progress) {
|
|||||||
height: 55.w,
|
height: 55.w,
|
||||||
borderRadius: BorderRadius.circular(12.w),
|
borderRadius: BorderRadius.circular(12.w),
|
||||||
),
|
),
|
||||||
SizedBox(height: 10.w),
|
SizedBox(height: 8.w),
|
||||||
_buildStoreBagSkeletonBone(
|
|
||||||
progress,
|
|
||||||
width: double.infinity,
|
|
||||||
height: 12.w,
|
|
||||||
borderRadius: BorderRadius.circular(999.w),
|
|
||||||
),
|
|
||||||
SizedBox(height: 10.w),
|
|
||||||
Row(
|
Row(
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
children: [
|
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(
|
Column(
|
||||||
|
mainAxisSize: showTitle ? MainAxisSize.max : MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
SizedBox(height: 24.w),
|
if (showTitle) SizedBox(height: 24.w),
|
||||||
_buildStoreBagSkeletonBone(
|
_buildStoreBagSkeletonBone(
|
||||||
progress,
|
progress,
|
||||||
width: 55.w,
|
width: 82.w,
|
||||||
height: 55.w,
|
height: 82.w,
|
||||||
borderRadius: BorderRadius.circular(12.w),
|
borderRadius: BorderRadius.circular(12.w),
|
||||||
),
|
),
|
||||||
if (showTitle) ...[
|
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), [
|
expect(result.allBadges.map((badge) => badge.id), ['vip', 'old', 'new']);
|
||||||
'vip',
|
expect(result.longDisplayBadges.map((badge) => badge.id), ['vip']);
|
||||||
'old',
|
expect(result.displayBadges.map((badge) => badge.id), ['old', 'new']);
|
||||||
'new',
|
|
||||||
]);
|
|
||||||
expect(result.honorWallBadges.map((badge) => badge.id), ['new', 'old']);
|
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), [
|
expect(result.honorWallBadges.map((badge) => badge.id), [
|
||||||
'achievement',
|
'achievement',
|
||||||
'activity',
|
'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