feat: implement CPU-based metaball rendering and performance classification
This commit is contained in:
@@ -0,0 +1,204 @@
|
|||||||
|
package com.rosetta.messenger.ui.components.metaball
|
||||||
|
|
||||||
|
import android.graphics.Bitmap
|
||||||
|
import kotlin.math.abs
|
||||||
|
import kotlin.math.max
|
||||||
|
import kotlin.math.min
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Proper Stack Blur algorithm by Mario Klingemann.
|
||||||
|
* Uses a triangular (tent) kernel — each pixel is weighted by (radius + 1 - distance).
|
||||||
|
* Unlike box blur (simple moving average), produces smooth alpha gradients
|
||||||
|
* essential for clean alpha-threshold gooey effects.
|
||||||
|
*
|
||||||
|
* Used by ProfileMetaballOverlayCpu for devices without RenderEffect (API < 31).
|
||||||
|
*/
|
||||||
|
internal fun stackBlurBitmap(source: Bitmap, radius: Int): Bitmap {
|
||||||
|
if (radius < 1) return source
|
||||||
|
val bitmap = source.copy(source.config, true)
|
||||||
|
stackBlurBitmapInPlace(bitmap, radius)
|
||||||
|
return bitmap
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* In-place stack blur on a mutable bitmap.
|
||||||
|
*/
|
||||||
|
internal fun stackBlurBitmapInPlace(bitmap: Bitmap, radius: Int) {
|
||||||
|
if (radius < 1) return
|
||||||
|
|
||||||
|
val w = bitmap.width
|
||||||
|
val h = bitmap.height
|
||||||
|
val pix = IntArray(w * h)
|
||||||
|
bitmap.getPixels(pix, 0, w, 0, 0, w, h)
|
||||||
|
|
||||||
|
stackBlurPixels(pix, w, h, radius)
|
||||||
|
|
||||||
|
bitmap.setPixels(pix, 0, w, 0, 0, w, h)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Core stack blur on raw ARGB pixel array.
|
||||||
|
* Two-pass (horizontal + vertical) with triangular weight kernel.
|
||||||
|
* Divisor = (radius+1)^2, matching the sum of triangular weights.
|
||||||
|
*/
|
||||||
|
private fun stackBlurPixels(pix: IntArray, w: Int, h: Int, radius: Int) {
|
||||||
|
val wm = w - 1
|
||||||
|
val hm = h - 1
|
||||||
|
val wh = w * h
|
||||||
|
val div = 2 * radius + 1
|
||||||
|
|
||||||
|
// Separate channel arrays for inter-pass storage
|
||||||
|
val aArr = IntArray(wh)
|
||||||
|
val rArr = IntArray(wh)
|
||||||
|
val gArr = IntArray(wh)
|
||||||
|
val bArr = IntArray(wh)
|
||||||
|
|
||||||
|
val vmin = IntArray(max(w, h))
|
||||||
|
|
||||||
|
// Stack blur divisor: sum of triangular weights = (radius+1)^2
|
||||||
|
val r1 = radius + 1
|
||||||
|
val mulSum = r1 * r1
|
||||||
|
|
||||||
|
// Circular buffer: each entry = [a, r, g, b]
|
||||||
|
val stack = Array(div) { IntArray(4) }
|
||||||
|
|
||||||
|
var yi = 0
|
||||||
|
var yw = 0
|
||||||
|
|
||||||
|
// === HORIZONTAL PASS ===
|
||||||
|
for (y in 0 until h) {
|
||||||
|
var asum = 0; var rsum = 0; var gsum = 0; var bsum = 0
|
||||||
|
var ainsum = 0; var rinsum = 0; var ginsum = 0; var binsum = 0
|
||||||
|
var aoutsum = 0; var routsum = 0; var goutsum = 0; var boutsum = 0
|
||||||
|
|
||||||
|
// Initialize stack with edge-clamped pixels, weighted by distance from center
|
||||||
|
for (i in -radius..radius) {
|
||||||
|
val p = pix[yi + min(wm, max(i, 0))]
|
||||||
|
val sir = stack[i + radius]
|
||||||
|
sir[0] = (p shr 24) and 0xff
|
||||||
|
sir[1] = (p shr 16) and 0xff
|
||||||
|
sir[2] = (p shr 8) and 0xff
|
||||||
|
sir[3] = p and 0xff
|
||||||
|
|
||||||
|
val rbs = r1 - abs(i) // triangular weight
|
||||||
|
asum += sir[0] * rbs
|
||||||
|
rsum += sir[1] * rbs
|
||||||
|
gsum += sir[2] * rbs
|
||||||
|
bsum += sir[3] * rbs
|
||||||
|
|
||||||
|
if (i > 0) {
|
||||||
|
ainsum += sir[0]; rinsum += sir[1]; ginsum += sir[2]; binsum += sir[3]
|
||||||
|
} else {
|
||||||
|
aoutsum += sir[0]; routsum += sir[1]; goutsum += sir[2]; boutsum += sir[3]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var stackpointer = radius
|
||||||
|
|
||||||
|
for (x in 0 until w) {
|
||||||
|
aArr[yi] = asum / mulSum
|
||||||
|
rArr[yi] = rsum / mulSum
|
||||||
|
gArr[yi] = gsum / mulSum
|
||||||
|
bArr[yi] = bsum / mulSum
|
||||||
|
|
||||||
|
asum -= aoutsum; rsum -= routsum; gsum -= goutsum; bsum -= boutsum
|
||||||
|
|
||||||
|
val stackstart = stackpointer - radius + div
|
||||||
|
val sir = stack[stackstart % div]
|
||||||
|
|
||||||
|
aoutsum -= sir[0]; routsum -= sir[1]; goutsum -= sir[2]; boutsum -= sir[3]
|
||||||
|
|
||||||
|
if (y == 0) {
|
||||||
|
vmin[x] = min(x + radius + 1, wm)
|
||||||
|
}
|
||||||
|
val p = pix[yw + vmin[x]]
|
||||||
|
|
||||||
|
sir[0] = (p shr 24) and 0xff
|
||||||
|
sir[1] = (p shr 16) and 0xff
|
||||||
|
sir[2] = (p shr 8) and 0xff
|
||||||
|
sir[3] = p and 0xff
|
||||||
|
|
||||||
|
ainsum += sir[0]; rinsum += sir[1]; ginsum += sir[2]; binsum += sir[3]
|
||||||
|
asum += ainsum; rsum += rinsum; gsum += ginsum; bsum += binsum
|
||||||
|
|
||||||
|
stackpointer = (stackpointer + 1) % div
|
||||||
|
val sir2 = stack[stackpointer % div]
|
||||||
|
|
||||||
|
aoutsum += sir2[0]; routsum += sir2[1]; goutsum += sir2[2]; boutsum += sir2[3]
|
||||||
|
ainsum -= sir2[0]; rinsum -= sir2[1]; ginsum -= sir2[2]; binsum -= sir2[3]
|
||||||
|
|
||||||
|
yi++
|
||||||
|
}
|
||||||
|
yw += w
|
||||||
|
}
|
||||||
|
|
||||||
|
// === VERTICAL PASS ===
|
||||||
|
for (x in 0 until w) {
|
||||||
|
var asum = 0; var rsum = 0; var gsum = 0; var bsum = 0
|
||||||
|
var ainsum = 0; var rinsum = 0; var ginsum = 0; var binsum = 0
|
||||||
|
var aoutsum = 0; var routsum = 0; var goutsum = 0; var boutsum = 0
|
||||||
|
|
||||||
|
var yp = -radius * w
|
||||||
|
for (i in -radius..radius) {
|
||||||
|
yi = max(0, yp) + x
|
||||||
|
val sir = stack[i + radius]
|
||||||
|
sir[0] = aArr[yi]
|
||||||
|
sir[1] = rArr[yi]
|
||||||
|
sir[2] = gArr[yi]
|
||||||
|
sir[3] = bArr[yi]
|
||||||
|
|
||||||
|
val rbs = r1 - abs(i)
|
||||||
|
asum += sir[0] * rbs
|
||||||
|
rsum += sir[1] * rbs
|
||||||
|
gsum += sir[2] * rbs
|
||||||
|
bsum += sir[3] * rbs
|
||||||
|
|
||||||
|
if (i > 0) {
|
||||||
|
ainsum += sir[0]; rinsum += sir[1]; ginsum += sir[2]; binsum += sir[3]
|
||||||
|
} else {
|
||||||
|
aoutsum += sir[0]; routsum += sir[1]; goutsum += sir[2]; boutsum += sir[3]
|
||||||
|
}
|
||||||
|
if (i < hm) {
|
||||||
|
yp += w
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
yi = x
|
||||||
|
var stackpointer = radius
|
||||||
|
|
||||||
|
for (y in 0 until h) {
|
||||||
|
pix[yi] = (min(255, asum / mulSum) shl 24) or
|
||||||
|
(min(255, rsum / mulSum) shl 16) or
|
||||||
|
(min(255, gsum / mulSum) shl 8) or
|
||||||
|
min(255, bsum / mulSum)
|
||||||
|
|
||||||
|
asum -= aoutsum; rsum -= routsum; gsum -= goutsum; bsum -= boutsum
|
||||||
|
|
||||||
|
val stackstart = stackpointer - radius + div
|
||||||
|
val sir = stack[stackstart % div]
|
||||||
|
|
||||||
|
aoutsum -= sir[0]; routsum -= sir[1]; goutsum -= sir[2]; boutsum -= sir[3]
|
||||||
|
|
||||||
|
if (x == 0) {
|
||||||
|
vmin[y] = min(y + r1, hm) * w
|
||||||
|
}
|
||||||
|
val p = x + vmin[y]
|
||||||
|
|
||||||
|
sir[0] = aArr[p]
|
||||||
|
sir[1] = rArr[p]
|
||||||
|
sir[2] = gArr[p]
|
||||||
|
sir[3] = bArr[p]
|
||||||
|
|
||||||
|
ainsum += sir[0]; rinsum += sir[1]; ginsum += sir[2]; binsum += sir[3]
|
||||||
|
asum += ainsum; rsum += rinsum; gsum += ginsum; bsum += binsum
|
||||||
|
|
||||||
|
stackpointer = (stackpointer + 1) % div
|
||||||
|
val sir2 = stack[stackpointer % div]
|
||||||
|
|
||||||
|
aoutsum += sir2[0]; routsum += sir2[1]; goutsum += sir2[2]; boutsum += sir2[3]
|
||||||
|
ainsum -= sir2[0]; rinsum -= sir2[1]; ginsum -= sir2[2]; binsum -= sir2[3]
|
||||||
|
|
||||||
|
yi += w
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
package com.rosetta.messenger.ui.components.metaball
|
||||||
|
|
||||||
|
import android.app.ActivityManager
|
||||||
|
import android.content.Context
|
||||||
|
import android.os.Build
|
||||||
|
import android.util.Log
|
||||||
|
import java.io.RandomAccessFile
|
||||||
|
import java.util.Locale
|
||||||
|
import kotlin.math.ceil
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Device performance classification - port of Telegram's SharedConfig.measureDevicePerformanceClass()
|
||||||
|
* Used to select the appropriate metaball rendering tier:
|
||||||
|
* - HIGH: GPU path (Android 12+) or CPU path (older Android)
|
||||||
|
* - AVERAGE: GPU path with reduced quality (Android 12+) or Noop (older)
|
||||||
|
* - LOW: Noop (no metaball effect)
|
||||||
|
*/
|
||||||
|
enum class PerformanceClass {
|
||||||
|
LOW, AVERAGE, HIGH
|
||||||
|
}
|
||||||
|
|
||||||
|
object DevicePerformanceClass {
|
||||||
|
@Volatile
|
||||||
|
private var cached: PerformanceClass? = null
|
||||||
|
|
||||||
|
fun get(context: Context): PerformanceClass {
|
||||||
|
cached?.let { return it }
|
||||||
|
return measure(context).also {
|
||||||
|
cached = it
|
||||||
|
Log.d("DevicePerformance", "Performance class: $it")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun measure(context: Context): PerformanceClass {
|
||||||
|
val androidVersion = Build.VERSION.SDK_INT
|
||||||
|
val cpuCount = Runtime.getRuntime().availableProcessors()
|
||||||
|
val am = context.getSystemService(Context.ACTIVITY_SERVICE) as ActivityManager
|
||||||
|
val memoryClass = am.memoryClass
|
||||||
|
|
||||||
|
// Read max CPU frequency
|
||||||
|
var totalCpuFreq = 0
|
||||||
|
var freqResolved = 0
|
||||||
|
for (i in 0 until cpuCount) {
|
||||||
|
try {
|
||||||
|
val reader = RandomAccessFile(
|
||||||
|
String.format(
|
||||||
|
Locale.ENGLISH,
|
||||||
|
"/sys/devices/system/cpu/cpu%d/cpufreq/cpuinfo_max_freq", i
|
||||||
|
), "r"
|
||||||
|
)
|
||||||
|
val line = reader.readLine()
|
||||||
|
if (line != null) {
|
||||||
|
totalCpuFreq += line.trim().toInt() / 1000
|
||||||
|
freqResolved++
|
||||||
|
}
|
||||||
|
reader.close()
|
||||||
|
} catch (_: Throwable) {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val maxCpuFreq = if (freqResolved == 0) -1
|
||||||
|
else ceil(totalCpuFreq.toFloat() / freqResolved).toInt()
|
||||||
|
|
||||||
|
// Read total RAM
|
||||||
|
val ram: Long = try {
|
||||||
|
val memInfo = ActivityManager.MemoryInfo()
|
||||||
|
am.getMemoryInfo(memInfo)
|
||||||
|
memInfo.totalMem
|
||||||
|
} catch (_: Exception) {
|
||||||
|
-1L
|
||||||
|
}
|
||||||
|
|
||||||
|
// Classification thresholds from Telegram's SharedConfig.measureDevicePerformanceClass()
|
||||||
|
return when {
|
||||||
|
androidVersion < 21 ||
|
||||||
|
cpuCount <= 2 ||
|
||||||
|
memoryClass <= 100 ||
|
||||||
|
(cpuCount <= 4 && maxCpuFreq != -1 && maxCpuFreq <= 1250) ||
|
||||||
|
(cpuCount <= 4 && maxCpuFreq <= 1600 && memoryClass <= 128 && androidVersion <= 21) ||
|
||||||
|
(cpuCount <= 4 && maxCpuFreq <= 1300 && memoryClass <= 128 && androidVersion <= 24) ||
|
||||||
|
(ram != -1L && ram < 2L * 1024L * 1024L * 1024L)
|
||||||
|
-> PerformanceClass.LOW
|
||||||
|
|
||||||
|
cpuCount < 8 ||
|
||||||
|
memoryClass <= 160 ||
|
||||||
|
(maxCpuFreq != -1 && maxCpuFreq <= 2055) ||
|
||||||
|
(maxCpuFreq == -1 && cpuCount == 8 && androidVersion <= 23)
|
||||||
|
-> PerformanceClass.AVERAGE
|
||||||
|
|
||||||
|
else -> PerformanceClass.HIGH
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,74 +1,52 @@
|
|||||||
package com.rosetta.messenger.ui.components.metaball
|
package com.rosetta.messenger.ui.components.metaball
|
||||||
|
|
||||||
|
import android.graphics.ColorMatrixColorFilter
|
||||||
import android.graphics.RenderEffect
|
import android.graphics.RenderEffect
|
||||||
import android.graphics.RuntimeShader
|
|
||||||
import android.graphics.Shader
|
import android.graphics.Shader
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
import androidx.annotation.RequiresApi
|
import androidx.annotation.RequiresApi
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.BoxScope
|
import androidx.compose.foundation.layout.BoxScope
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.remember
|
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.graphics.asComposeRenderEffect
|
import androidx.compose.ui.graphics.asComposeRenderEffect
|
||||||
import androidx.compose.ui.graphics.graphicsLayer
|
import androidx.compose.ui.graphics.graphicsLayer
|
||||||
import org.intellij.lang.annotations.Language
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* AGSL Shader for metaball effect
|
* Alpha threshold color matrix for metaball effect.
|
||||||
*
|
* Same as GPU_ALPHA_THRESHOLD_MATRIX in ProfileMetaballOverlay:
|
||||||
* This shader creates the "liquid merge" effect by:
|
* newAlpha = oldAlpha * 51 - 6375 → values above ~125/255 become opaque.
|
||||||
* 1. Taking already-blurred content as input
|
* Works on API 31+ (Android 12) — no AGSL/RuntimeShader needed.
|
||||||
* 2. Applying alpha threshold cutoff (alpha > cutoff = opaque, else transparent)
|
|
||||||
*
|
|
||||||
* When two blurred shapes overlap, their alpha values combine,
|
|
||||||
* exceeding the threshold and creating a merged appearance.
|
|
||||||
*/
|
*/
|
||||||
@Language("AGSL")
|
private val METABALL_ALPHA_THRESHOLD_MATRIX = floatArrayOf(
|
||||||
const val MetaballShaderSource = """
|
1f, 0f, 0f, 0f, 0f,
|
||||||
uniform shader composable;
|
0f, 1f, 0f, 0f, 0f,
|
||||||
uniform float cutoff;
|
0f, 0f, 1f, 0f, 0f,
|
||||||
|
0f, 0f, 0f, 51f, 51f * -125f
|
||||||
half4 main(float2 fragCoord) {
|
)
|
||||||
half4 color = composable.eval(fragCoord);
|
|
||||||
float alpha = color.a;
|
|
||||||
|
|
||||||
// Hard threshold: if alpha > cutoff, make fully opaque, else transparent
|
|
||||||
if (alpha > cutoff) {
|
|
||||||
alpha = 1.0;
|
|
||||||
} else {
|
|
||||||
alpha = 0.0;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Return color with modified alpha
|
|
||||||
return half4(color.r, color.g, color.b, alpha);
|
|
||||||
}
|
|
||||||
"""
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Container that applies the metaball shader effect to its children.
|
* Container that applies the metaball alpha-threshold effect to its children.
|
||||||
*
|
*
|
||||||
* Children should use [customBlur] modifier to be part of the metaball effect.
|
* Children should use [customBlur] modifier to be part of the metaball effect.
|
||||||
* When blurred shapes overlap, they appear to "merge" like liquid blobs.
|
* When blurred shapes overlap, their alpha values combine,
|
||||||
*
|
* exceeding the threshold and creating a merged "liquid" appearance.
|
||||||
|
*
|
||||||
|
* Uses ColorMatrixColorFilter instead of AGSL shader — works on API 31+ (Android 12).
|
||||||
|
*
|
||||||
* @param modifier Modifier for the container
|
* @param modifier Modifier for the container
|
||||||
* @param cutoff Alpha threshold (0-1). Higher = harder edges. Default 0.5
|
|
||||||
* @param content Composable content (should contain MetaEntity elements)
|
* @param content Composable content (should contain MetaEntity elements)
|
||||||
*/
|
*/
|
||||||
@RequiresApi(Build.VERSION_CODES.TIRAMISU)
|
@RequiresApi(Build.VERSION_CODES.S)
|
||||||
@Composable
|
@Composable
|
||||||
fun MetaContainer(
|
fun MetaContainer(
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
cutoff: Float = 0.5f,
|
|
||||||
content: @Composable BoxScope.() -> Unit,
|
content: @Composable BoxScope.() -> Unit,
|
||||||
) {
|
) {
|
||||||
val metaShader = remember { RuntimeShader(MetaballShaderSource) }
|
|
||||||
|
|
||||||
Box(
|
Box(
|
||||||
modifier = modifier.graphicsLayer {
|
modifier = modifier.graphicsLayer {
|
||||||
metaShader.setFloatUniform("cutoff", cutoff)
|
renderEffect = RenderEffect.createColorFilterEffect(
|
||||||
renderEffect = RenderEffect.createRuntimeShaderEffect(
|
ColorMatrixColorFilter(METABALL_ALPHA_THRESHOLD_MATRIX)
|
||||||
metaShader, "composable"
|
|
||||||
).asComposeRenderEffect()
|
).asComposeRenderEffect()
|
||||||
},
|
},
|
||||||
content = content,
|
content = content,
|
||||||
@@ -77,10 +55,10 @@ fun MetaContainer(
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Modifier that applies GPU-accelerated blur effect.
|
* Modifier that applies GPU-accelerated blur effect.
|
||||||
*
|
*
|
||||||
* Use this on shapes inside [MetaContainer] to create the metaball effect.
|
* Use this on shapes inside [MetaContainer] to create the metaball effect.
|
||||||
* The blur radius determines how "soft" the edges are before threshold is applied.
|
* The blur radius determines how "soft" the edges are before threshold is applied.
|
||||||
*
|
*
|
||||||
* @param blur Blur radius in pixels. Higher = softer edges, larger merge area
|
* @param blur Blur radius in pixels. Higher = softer edges, larger merge area
|
||||||
*/
|
*/
|
||||||
@RequiresApi(Build.VERSION_CODES.S)
|
@RequiresApi(Build.VERSION_CODES.S)
|
||||||
@@ -100,11 +78,11 @@ fun Modifier.customBlur(blur: Float) = this.then(
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* A composable that wraps content with metaball-capable blur.
|
* A composable that wraps content with metaball-capable blur.
|
||||||
*
|
*
|
||||||
* This creates a layered effect:
|
* This creates a layered effect:
|
||||||
* - metaContent: The blurred shape that participates in metaball merging
|
* - metaContent: The blurred shape that participates in metaball merging
|
||||||
* - content: The actual visible content (not blurred)
|
* - content: The actual visible content (not blurred)
|
||||||
*
|
*
|
||||||
* @param modifier Modifier for the entity
|
* @param modifier Modifier for the entity
|
||||||
* @param blur Blur radius for the metaball effect
|
* @param blur Blur radius for the metaball effect
|
||||||
* @param metaContent The shape that will be blurred and merged (usually a solid color Box)
|
* @param metaContent The shape that will be blurred and merged (usually a solid color Box)
|
||||||
@@ -119,12 +97,10 @@ fun MetaEntity(
|
|||||||
content: @Composable BoxScope.() -> Unit = {},
|
content: @Composable BoxScope.() -> Unit = {},
|
||||||
) {
|
) {
|
||||||
Box(modifier = modifier) {
|
Box(modifier = modifier) {
|
||||||
// Blurred layer for metaball effect
|
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier.customBlur(blur),
|
modifier = Modifier.customBlur(blur),
|
||||||
content = metaContent,
|
content = metaContent,
|
||||||
)
|
)
|
||||||
// Visible content on top
|
|
||||||
content()
|
content()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,28 +1,42 @@
|
|||||||
package com.rosetta.messenger.ui.components.metaball
|
package com.rosetta.messenger.ui.components.metaball
|
||||||
|
|
||||||
|
import android.graphics.ColorMatrixColorFilter
|
||||||
import android.graphics.Path
|
import android.graphics.Path
|
||||||
import android.graphics.RectF
|
import android.graphics.RectF
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import android.graphics.RenderEffect
|
import android.graphics.RenderEffect
|
||||||
import android.graphics.RuntimeShader
|
|
||||||
import android.graphics.Shader
|
import android.graphics.Shader
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
import android.view.Gravity
|
import android.view.Gravity
|
||||||
import androidx.annotation.RequiresApi
|
import androidx.annotation.RequiresApi
|
||||||
import androidx.compose.foundation.Canvas
|
import androidx.compose.foundation.Canvas
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.border
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.BoxScope
|
import androidx.compose.foundation.layout.BoxScope
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.foundation.layout.offset
|
import androidx.compose.foundation.layout.offset
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.width
|
import androidx.compose.foundation.layout.width
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material3.Switch
|
||||||
|
import androidx.compose.material3.SwitchDefaults
|
||||||
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.ui.graphics.RectangleShape
|
import androidx.compose.ui.graphics.RectangleShape
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.DisposableEffect
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.derivedStateOf
|
import androidx.compose.runtime.derivedStateOf
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
@@ -33,9 +47,10 @@ import androidx.compose.ui.graphics.nativeCanvas
|
|||||||
import androidx.compose.ui.platform.LocalConfiguration
|
import androidx.compose.ui.platform.LocalConfiguration
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.platform.LocalDensity
|
import androidx.compose.ui.platform.LocalDensity
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.unit.Dp
|
import androidx.compose.ui.unit.Dp
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import org.intellij.lang.annotations.Language
|
import androidx.compose.ui.unit.sp
|
||||||
import kotlin.math.abs
|
import kotlin.math.abs
|
||||||
import kotlin.math.max
|
import kotlin.math.max
|
||||||
import kotlin.math.min
|
import kotlin.math.min
|
||||||
@@ -94,7 +109,6 @@ object ProfileMetaballConstants {
|
|||||||
|
|
||||||
// Blur settings for gooey effect (like Telegram's intensity = 15f)
|
// Blur settings for gooey effect (like Telegram's intensity = 15f)
|
||||||
const val BLUR_RADIUS = 15f
|
const val BLUR_RADIUS = 15f
|
||||||
const val CUTOFF = 0.5f
|
|
||||||
|
|
||||||
// Blur range for avatar (like Telegram: 2 + (1-fraction) * 20)
|
// Blur range for avatar (like Telegram: 2 + (1-fraction) * 20)
|
||||||
const val BLUR_MIN = 2f
|
const val BLUR_MIN = 2f
|
||||||
@@ -108,28 +122,27 @@ object ProfileMetaballConstants {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* AGSL Shader for metaball alpha threshold effect
|
* GPU alpha threshold via ColorMatrixColorFilter (API 31+)
|
||||||
* Same as Telegram's ColorMatrixColorFilter with alpha threshold
|
* Replaces AGSL RuntimeShader — same gooey effect, works on Android 12+
|
||||||
|
*
|
||||||
|
* From Telegram's ProfileGooeyView.GPUImpl:
|
||||||
|
* Alpha row: newAlpha = oldAlpha * 51 - 6375
|
||||||
|
* Values above ~125/255 become opaque, below become transparent.
|
||||||
|
* Equivalent to AGSL cutoff=0.5 but without requiring API 33.
|
||||||
*/
|
*/
|
||||||
@Language("AGSL")
|
private val GPU_ALPHA_THRESHOLD_MATRIX = floatArrayOf(
|
||||||
private const val ProfileMetaballShaderSource = """
|
1f, 0f, 0f, 0f, 0f,
|
||||||
uniform shader composable;
|
0f, 1f, 0f, 0f, 0f,
|
||||||
uniform float cutoff;
|
0f, 0f, 1f, 0f, 0f,
|
||||||
|
0f, 0f, 0f, 51f, 51f * -125f
|
||||||
half4 main(float2 fragCoord) {
|
)
|
||||||
half4 color = composable.eval(fragCoord);
|
|
||||||
float alpha = color.a;
|
/**
|
||||||
|
* No-notch fallback: black bar height at the top of the screen.
|
||||||
// Hard threshold for gooey effect
|
* Matches Telegram's BLACK_KING_BAR = 32dp.
|
||||||
if (alpha > cutoff) {
|
* When no notch is detected, the avatar merges into this bar instead.
|
||||||
alpha = 1.0;
|
*/
|
||||||
} else {
|
private const val BLACK_BAR_HEIGHT_DP = 32f
|
||||||
alpha = 0.0;
|
|
||||||
}
|
|
||||||
|
|
||||||
return half4(color.r, color.g, color.b, alpha);
|
|
||||||
}
|
|
||||||
"""
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* State for avatar position and size during animation
|
* State for avatar position and size during animation
|
||||||
@@ -336,7 +349,7 @@ private fun lerpFloat(start: Float, stop: Float, fraction: Float): Float {
|
|||||||
*
|
*
|
||||||
* IMPORTANT: Blur is applied ONLY to the metaball shapes, NOT to the avatar content!
|
* IMPORTANT: Blur is applied ONLY to the metaball shapes, NOT to the avatar content!
|
||||||
*/
|
*/
|
||||||
@RequiresApi(Build.VERSION_CODES.TIRAMISU)
|
@RequiresApi(Build.VERSION_CODES.S)
|
||||||
@Composable
|
@Composable
|
||||||
fun ProfileMetaballOverlay(
|
fun ProfileMetaballOverlay(
|
||||||
collapseProgress: Float,
|
collapseProgress: Float,
|
||||||
@@ -345,6 +358,7 @@ fun ProfileMetaballOverlay(
|
|||||||
headerHeight: Dp,
|
headerHeight: Dp,
|
||||||
hasAvatar: Boolean,
|
hasAvatar: Boolean,
|
||||||
@Suppress("UNUSED_PARAMETER") avatarColor: ComposeColor,
|
@Suppress("UNUSED_PARAMETER") avatarColor: ComposeColor,
|
||||||
|
factorMult: Float = 1f,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
avatarContent: @Composable BoxScope.() -> Unit = {},
|
avatarContent: @Composable BoxScope.() -> Unit = {},
|
||||||
) {
|
) {
|
||||||
@@ -445,27 +459,26 @@ fun ProfileMetaballOverlay(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Metaball shader
|
|
||||||
val metaShader = remember { RuntimeShader(ProfileMetaballShaderSource) }
|
|
||||||
|
|
||||||
// Path for metaball connector
|
// Path for metaball connector
|
||||||
val metaballPath = remember { Path() }
|
val metaballPath = remember { Path() }
|
||||||
val c1 = remember { Point() } // Notch center
|
val c1 = remember { Point() } // Notch center
|
||||||
val c2 = remember { Point() } // Avatar center
|
val c2 = remember { Point() } // Avatar center
|
||||||
|
|
||||||
// 🔥 Reusable RectF like Telegram's AndroidUtilities.rectTmp
|
// Reusable RectF like Telegram's AndroidUtilities.rectTmp
|
||||||
// Avoid creating new RectF objects every frame
|
|
||||||
val rectTmp = remember { RectF() }
|
val rectTmp = remember { RectF() }
|
||||||
|
|
||||||
|
// Black bar height for no-notch fallback (Telegram's BLACK_KING_BAR)
|
||||||
|
val blackBarHeightPx = with(density) { BLACK_BAR_HEIGHT_DP.dp.toPx() }
|
||||||
|
|
||||||
|
// Detect if device has a real centered notch (debug override supported)
|
||||||
|
val hasRealNotch = !MetaballDebug.forceNoNotch &&
|
||||||
|
notchInfo != null && notchInfo.gravity == Gravity.CENTER && notchInfo.bounds.width() > 0
|
||||||
|
|
||||||
// Calculate "v" parameter - thickness of connector based on distance
|
// Calculate "v" parameter - thickness of connector based on distance
|
||||||
// Like Telegram: v = clamp((1f - c / 1.3f) / 2f, 0.8f, 0)
|
|
||||||
val distance = avatarState.centerY - notchCenterY
|
val distance = avatarState.centerY - notchCenterY
|
||||||
val maxDist = avatarSizeExpandedPx
|
val maxDist = avatarSizeExpandedPx
|
||||||
val c = (distance / maxDist).coerceIn(-1f, 1f)
|
val c = (distance / maxDist).coerceIn(-1f, 1f)
|
||||||
|
|
||||||
// Like Telegram: when NOT near, reduce v
|
|
||||||
// float near = isNear ? 1f : 1f - (vr - dp(32)) / dp(40 - 32);
|
|
||||||
// v = Math.min(lerp(0f, 0.2f, near), v);
|
|
||||||
val baseV = ((1f - c / 1.3f) / 2f).coerceIn(0f, 0.8f)
|
val baseV = ((1f - c / 1.3f) / 2f).coerceIn(0f, 0.8f)
|
||||||
val near = if (avatarState.isNear) 1f else 1f - (avatarState.radius - dp32) / (dp40 - dp32)
|
val near = if (avatarState.isNear) 1f else 1f - (avatarState.radius - dp32) / (dp40 - dp32)
|
||||||
val v = if (!avatarState.isNear) {
|
val v = if (!avatarState.isNear) {
|
||||||
@@ -473,37 +486,37 @@ fun ProfileMetaballOverlay(
|
|||||||
} else {
|
} else {
|
||||||
baseV
|
baseV
|
||||||
}
|
}
|
||||||
|
|
||||||
// Show connector only when avatar is small enough (like Telegram isDrawing)
|
// Show connector when avatar is small enough (isDrawing) and not expanding
|
||||||
// AND not when expanding (no metaball effect when expanded)
|
// No longer requires hasRealNotch — works with black bar fallback too
|
||||||
// AND only when hasAvatar is true (no drop animation for placeholder)
|
val showConnector = hasAvatar && expansionProgress == 0f && avatarState.isDrawing && avatarState.showBlob && distance < maxDist * 1.5f
|
||||||
// AND only when device has real centered notch (Dynamic Island, punch-hole camera)
|
|
||||||
val hasRealNotch = notchInfo != null && notchInfo.gravity == Gravity.CENTER && notchInfo.bounds.width() > 0
|
// Show metaball layer when collapsing with avatar
|
||||||
val showConnector = hasRealNotch && hasAvatar && expansionProgress == 0f && avatarState.isDrawing && avatarState.showBlob && distance < maxDist * 1.5f
|
val showMetaballLayer = hasAvatar && expansionProgress == 0f
|
||||||
|
|
||||||
// Don't show black metaball shapes when expanded or when no avatar or no real notch
|
// Adjusted blur radius based on device performance (factorMult)
|
||||||
val showMetaballLayer = hasRealNotch && hasAvatar && expansionProgress == 0f
|
val adjustedBlurRadius = ProfileMetaballConstants.BLUR_RADIUS / factorMult
|
||||||
|
|
||||||
Box(modifier = modifier
|
Box(modifier = modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
.clip(RectangleShape) // Clip to bounds - prevents avatar overflow when expanded
|
.clip(RectangleShape)
|
||||||
) {
|
) {
|
||||||
// LAYER 1: Metaball shapes with blur effect (BLACK shapes only)
|
// LAYER 1: Metaball shapes with blur + alpha threshold (BLACK shapes only)
|
||||||
// HIDDEN when expanded - only show avatar content
|
|
||||||
if (showMetaballLayer) {
|
if (showMetaballLayer) {
|
||||||
Canvas(
|
Canvas(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
.graphicsLayer {
|
.graphicsLayer {
|
||||||
metaShader.setFloatUniform("cutoff", ProfileMetaballConstants.CUTOFF)
|
// ColorMatrixColorFilter for alpha threshold (API 31+)
|
||||||
// IMPORTANT: First blur, THEN threshold shader
|
// Replaces AGSL RuntimeShader — same gooey effect
|
||||||
// createChainEffect(outer, inner) - inner is applied first
|
|
||||||
val blurEffect = RenderEffect.createBlurEffect(
|
val blurEffect = RenderEffect.createBlurEffect(
|
||||||
ProfileMetaballConstants.BLUR_RADIUS,
|
adjustedBlurRadius,
|
||||||
ProfileMetaballConstants.BLUR_RADIUS,
|
adjustedBlurRadius,
|
||||||
Shader.TileMode.DECAL
|
Shader.TileMode.DECAL
|
||||||
)
|
)
|
||||||
val thresholdEffect = RenderEffect.createRuntimeShaderEffect(metaShader, "composable")
|
val thresholdEffect = RenderEffect.createColorFilterEffect(
|
||||||
|
ColorMatrixColorFilter(GPU_ALPHA_THRESHOLD_MATRIX)
|
||||||
|
)
|
||||||
// Chain: blur first (inner), then threshold (outer)
|
// Chain: blur first (inner), then threshold (outer)
|
||||||
renderEffect = RenderEffect.createChainEffect(thresholdEffect, blurEffect)
|
renderEffect = RenderEffect.createChainEffect(thresholdEffect, blurEffect)
|
||||||
.asComposeRenderEffect()
|
.asComposeRenderEffect()
|
||||||
@@ -513,36 +526,29 @@ fun ProfileMetaballOverlay(
|
|||||||
color = android.graphics.Color.BLACK
|
color = android.graphics.Color.BLACK
|
||||||
style = android.graphics.Paint.Style.FILL
|
style = android.graphics.Paint.Style.FILL
|
||||||
}
|
}
|
||||||
|
|
||||||
drawIntoCanvas { canvas ->
|
drawIntoCanvas { canvas ->
|
||||||
val nativeCanvas = canvas.nativeCanvas
|
val nativeCanvas = canvas.nativeCanvas
|
||||||
|
|
||||||
// Draw notch/camera circle (small circle at top)
|
// Draw target shape at top (notch or black bar fallback)
|
||||||
if (showConnector) {
|
if (showConnector) {
|
||||||
if (notchInfo != null && notchInfo.isLikelyCircle) {
|
if (hasRealNotch && notchInfo != null && notchInfo.isLikelyCircle) {
|
||||||
// Draw circle at actual notch position
|
|
||||||
nativeCanvas.drawCircle(
|
nativeCanvas.drawCircle(
|
||||||
notchCenterX,
|
notchCenterX,
|
||||||
notchCenterY,
|
notchCenterY,
|
||||||
notchRadiusPx,
|
notchRadiusPx,
|
||||||
paint
|
paint
|
||||||
)
|
)
|
||||||
} else if (notchInfo != null && notchInfo.isAccurate && notchInfo.path != null) {
|
} else if (hasRealNotch && notchInfo != null && notchInfo.isAccurate && notchInfo.path != null) {
|
||||||
// Draw actual notch path
|
|
||||||
nativeCanvas.drawPath(notchInfo.path, paint)
|
nativeCanvas.drawPath(notchInfo.path, paint)
|
||||||
} else if (notchInfo != null) {
|
} else if (hasRealNotch && notchInfo != null) {
|
||||||
// Draw rounded rect for non-accurate notch
|
|
||||||
val bounds = notchInfo.bounds
|
val bounds = notchInfo.bounds
|
||||||
val rad = kotlin.math.max(bounds.width(), bounds.height()) / 2f
|
val rad = kotlin.math.max(bounds.width(), bounds.height()) / 2f
|
||||||
nativeCanvas.drawRoundRect(bounds, rad, rad, paint)
|
nativeCanvas.drawRoundRect(bounds, rad, rad, paint)
|
||||||
} else {
|
} else {
|
||||||
// No notch - draw small circle at status bar center
|
// No notch fallback: full-width black bar at top
|
||||||
nativeCanvas.drawCircle(
|
// Like Telegram's ProfileGooeyView when notchInfo == null
|
||||||
screenWidthPx / 2f,
|
nativeCanvas.drawRect(0f, 0f, screenWidthPx, blackBarHeightPx, paint)
|
||||||
statusBarHeightPx / 2f,
|
|
||||||
notchRadiusPx,
|
|
||||||
paint
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -699,8 +705,10 @@ fun ProfileMetaballOverlayCompat(
|
|||||||
val avatarSizeMinPx = with(density) { ProfileMetaballConstants.AVATAR_SIZE_MIN.toPx() }
|
val avatarSizeMinPx = with(density) { ProfileMetaballConstants.AVATAR_SIZE_MIN.toPx() }
|
||||||
|
|
||||||
// Fallback notch values for compat mode
|
// Fallback notch values for compat mode
|
||||||
val notchCenterX = screenWidthPx / 2f // Center of screen for compat mode
|
// Use black bar center as target (like Telegram's BLACK_KING_BAR)
|
||||||
val notchCenterY = statusBarHeightPx / 2f
|
val blackBarHeightPx = with(density) { BLACK_BAR_HEIGHT_DP.dp.toPx() }
|
||||||
|
val notchCenterX = screenWidthPx / 2f
|
||||||
|
val notchCenterY = blackBarHeightPx / 2f
|
||||||
val notchRadiusPx = with(density) { ProfileMetaballConstants.FALLBACK_CAMERA_SIZE.toPx() }
|
val notchRadiusPx = with(density) { ProfileMetaballConstants.FALLBACK_CAMERA_SIZE.toPx() }
|
||||||
|
|
||||||
// Telegram thresholds in pixels
|
// Telegram thresholds in pixels
|
||||||
@@ -804,7 +812,432 @@ fun ProfileMetaballOverlayCompat(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Auto-selecting wrapper based on Android version
|
* CPU alpha threshold matrix — from Telegram's ProfileGooeyView.CPUImpl.
|
||||||
|
* Operates on downscaled black shapes:
|
||||||
|
* Alpha row: newAlpha = oldAlpha * 60 - 7500
|
||||||
|
* Threshold at alpha ~125/255. RGB forced to 0 (black mask).
|
||||||
|
*/
|
||||||
|
private val CPU_ALPHA_THRESHOLD_MATRIX = floatArrayOf(
|
||||||
|
0f, 0f, 0f, 0f, 0f,
|
||||||
|
0f, 0f, 0f, 0f, 0f,
|
||||||
|
0f, 0f, 0f, 0f, 0f,
|
||||||
|
0f, 0f, 0f, 51f, -6375f // Match GPU threshold: cutoff at alpha ~125/255
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* CPU path for metaball effect — works on any Android version.
|
||||||
|
* Matches Telegram's ProfileGooeyView.CPUImpl:
|
||||||
|
* 1. Draw black shapes (target + avatar + connector) into a downscaled bitmap
|
||||||
|
* 2. Apply stack blur on CPU
|
||||||
|
* 3. Draw with ColorMatrixColorFilter for alpha threshold
|
||||||
|
* 4. Composite with SRC_ATOP
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun ProfileMetaballOverlayCpu(
|
||||||
|
collapseProgress: Float,
|
||||||
|
expansionProgress: Float,
|
||||||
|
statusBarHeight: Dp,
|
||||||
|
headerHeight: Dp,
|
||||||
|
hasAvatar: Boolean,
|
||||||
|
@Suppress("UNUSED_PARAMETER") avatarColor: ComposeColor,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
avatarContent: @Composable BoxScope.() -> Unit = {},
|
||||||
|
) {
|
||||||
|
val context = LocalContext.current
|
||||||
|
val configuration = LocalConfiguration.current
|
||||||
|
val screenWidth = configuration.screenWidthDp.dp
|
||||||
|
val density = LocalDensity.current
|
||||||
|
|
||||||
|
val screenWidthPx = with(density) { screenWidth.toPx() }
|
||||||
|
val statusBarHeightPx = with(density) { statusBarHeight.toPx() }
|
||||||
|
val headerHeightPx = with(density) { headerHeight.toPx() }
|
||||||
|
val avatarSizeExpandedPx = with(density) { ProfileMetaballConstants.AVATAR_SIZE_EXPANDED.toPx() }
|
||||||
|
val avatarSizeMinPx = with(density) { ProfileMetaballConstants.AVATAR_SIZE_MIN.toPx() }
|
||||||
|
|
||||||
|
// Get notch info (debug override supported)
|
||||||
|
val notchInfo = remember { NotchInfoUtils.getInfo(context) }
|
||||||
|
val hasRealNotch = !MetaballDebug.forceNoNotch &&
|
||||||
|
notchInfo != null && notchInfo.gravity == Gravity.CENTER && notchInfo.bounds.width() > 0
|
||||||
|
val blackBarHeightPx = with(density) { BLACK_BAR_HEIGHT_DP.dp.toPx() }
|
||||||
|
|
||||||
|
val notchRadiusPx = remember(notchInfo) {
|
||||||
|
if (hasRealNotch && notchInfo != null) {
|
||||||
|
if (notchInfo.isLikelyCircle) {
|
||||||
|
min(notchInfo.bounds.width(), notchInfo.bounds.height()) / 2f
|
||||||
|
} else {
|
||||||
|
kotlin.math.max(notchInfo.bounds.width(), notchInfo.bounds.height()) / 2f
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
with(density) { ProfileMetaballConstants.FALLBACK_CAMERA_SIZE.toPx() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val notchCenterX = remember(notchInfo, screenWidthPx) {
|
||||||
|
if (hasRealNotch && notchInfo != null) notchInfo.bounds.centerX() else screenWidthPx / 2f
|
||||||
|
}
|
||||||
|
val notchCenterY = remember(notchInfo) {
|
||||||
|
if (hasRealNotch && notchInfo != null && notchInfo.isLikelyCircle) {
|
||||||
|
notchInfo.bounds.bottom - notchInfo.bounds.width() / 2f
|
||||||
|
} else if (hasRealNotch && notchInfo != null) {
|
||||||
|
notchInfo.bounds.centerY()
|
||||||
|
} else {
|
||||||
|
blackBarHeightPx / 2f
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Thresholds
|
||||||
|
val dp40 = with(density) { ProfileMetaballConstants.THRESHOLD_DRAWING.toPx() }
|
||||||
|
val dp34 = with(density) { ProfileMetaballConstants.THRESHOLD_SHAPE_END.toPx() }
|
||||||
|
val dp32 = with(density) { ProfileMetaballConstants.THRESHOLD_NEAR.toPx() }
|
||||||
|
val dp18 = with(density) { ProfileMetaballConstants.THRESHOLD_ALPHA_END.toPx() }
|
||||||
|
val dp22 = with(density) { ProfileMetaballConstants.ROUNDED_RECT_RADIUS.toPx() }
|
||||||
|
|
||||||
|
val avatarState by remember(screenWidthPx, statusBarHeightPx, headerHeightPx, notchCenterX, notchCenterY, notchRadiusPx) {
|
||||||
|
derivedStateOf {
|
||||||
|
computeAvatarState(
|
||||||
|
collapseProgress = collapseProgress,
|
||||||
|
expansionProgress = expansionProgress,
|
||||||
|
screenWidthPx = screenWidthPx,
|
||||||
|
statusBarHeightPx = statusBarHeightPx,
|
||||||
|
headerHeightPx = headerHeightPx,
|
||||||
|
avatarSizeExpandedPx = avatarSizeExpandedPx,
|
||||||
|
avatarSizeMinPx = avatarSizeMinPx,
|
||||||
|
hasAvatar = hasAvatar,
|
||||||
|
notchCenterX = notchCenterX,
|
||||||
|
notchCenterY = notchCenterY,
|
||||||
|
notchRadiusPx = notchRadiusPx,
|
||||||
|
dp40 = dp40, dp34 = dp34, dp32 = dp32, dp18 = dp18, dp22 = dp22,
|
||||||
|
fullCornerRadius = avatarSizeExpandedPx / 2f
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reusable objects
|
||||||
|
val metaballPath = remember { android.graphics.Path() }
|
||||||
|
val c1 = remember { Point() }
|
||||||
|
val c2 = remember { Point() }
|
||||||
|
val rectTmp = remember { RectF() }
|
||||||
|
|
||||||
|
// Connector calculations
|
||||||
|
val distance = avatarState.centerY - notchCenterY
|
||||||
|
val maxDist = avatarSizeExpandedPx
|
||||||
|
val cParam = (distance / maxDist).coerceIn(-1f, 1f)
|
||||||
|
val baseV = ((1f - cParam / 1.3f) / 2f).coerceIn(0f, 0.8f)
|
||||||
|
val near = if (avatarState.isNear) 1f else 1f - (avatarState.radius - dp32) / (dp40 - dp32)
|
||||||
|
val v = if (!avatarState.isNear) min(lerpFloat(0f, 0.2f, near), baseV) else baseV
|
||||||
|
|
||||||
|
val showConnector = hasAvatar && expansionProgress == 0f && avatarState.isDrawing && avatarState.showBlob && distance < maxDist * 1.5f
|
||||||
|
val showMetaballLayer = hasAvatar && expansionProgress == 0f
|
||||||
|
|
||||||
|
// CPU-specific: downscaled bitmap for blur
|
||||||
|
// Reduced from 5 to 3 for higher resolution — smoother edges after alpha threshold
|
||||||
|
val scaleConst = 3f
|
||||||
|
val optimizedW = min(with(density) { 120.dp.toPx() }.toInt(), screenWidthPx.toInt())
|
||||||
|
val optimizedH = min(with(density) { 220.dp.toPx() }.toInt(), headerHeightPx.toInt() + blackBarHeightPx.toInt())
|
||||||
|
val bitmapW = (optimizedW / scaleConst).toInt().coerceAtLeast(1)
|
||||||
|
val bitmapH = (optimizedH / scaleConst).toInt().coerceAtLeast(1)
|
||||||
|
|
||||||
|
val offscreenBitmap = remember(bitmapW, bitmapH) {
|
||||||
|
android.graphics.Bitmap.createBitmap(bitmapW, bitmapH, android.graphics.Bitmap.Config.ARGB_8888)
|
||||||
|
}
|
||||||
|
val offscreenCanvas = remember(offscreenBitmap) {
|
||||||
|
android.graphics.Canvas(offscreenBitmap)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reusable paints
|
||||||
|
val blackPaint = remember {
|
||||||
|
android.graphics.Paint(android.graphics.Paint.ANTI_ALIAS_FLAG).apply {
|
||||||
|
color = android.graphics.Color.BLACK
|
||||||
|
style = android.graphics.Paint.Style.FILL
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val filterPaint = remember {
|
||||||
|
android.graphics.Paint(android.graphics.Paint.FILTER_BITMAP_FLAG or android.graphics.Paint.ANTI_ALIAS_FLAG).apply {
|
||||||
|
isFilterBitmap = true
|
||||||
|
colorFilter = ColorMatrixColorFilter(CPU_ALPHA_THRESHOLD_MATRIX)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cleanup bitmap on dispose
|
||||||
|
DisposableEffect(offscreenBitmap) {
|
||||||
|
onDispose {
|
||||||
|
if (!offscreenBitmap.isRecycled) {
|
||||||
|
offscreenBitmap.recycle()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Box(modifier = modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.clip(RectangleShape)
|
||||||
|
) {
|
||||||
|
// LAYER 1: CPU-rendered metaball shapes
|
||||||
|
if (showMetaballLayer) {
|
||||||
|
Canvas(modifier = Modifier.fillMaxSize()) {
|
||||||
|
drawIntoCanvas { canvas ->
|
||||||
|
val nativeCanvas = canvas.nativeCanvas
|
||||||
|
val optimizedOffsetX = (screenWidthPx - optimizedW) / 2f
|
||||||
|
|
||||||
|
// Clear offscreen bitmap
|
||||||
|
offscreenBitmap.eraseColor(0)
|
||||||
|
|
||||||
|
// Draw shapes into downscaled bitmap
|
||||||
|
offscreenCanvas.save()
|
||||||
|
offscreenCanvas.scale(
|
||||||
|
offscreenBitmap.width.toFloat() / optimizedW,
|
||||||
|
offscreenBitmap.height.toFloat() / optimizedH
|
||||||
|
)
|
||||||
|
offscreenCanvas.translate(-optimizedOffsetX, 0f)
|
||||||
|
|
||||||
|
// Draw target (notch or black bar)
|
||||||
|
if (showConnector) {
|
||||||
|
if (hasRealNotch && notchInfo != null && notchInfo.isLikelyCircle) {
|
||||||
|
val rad = min(notchInfo.bounds.width(), notchInfo.bounds.height()) / 2f
|
||||||
|
offscreenCanvas.drawCircle(
|
||||||
|
notchInfo.bounds.centerX(),
|
||||||
|
notchInfo.bounds.bottom - notchInfo.bounds.width() / 2f,
|
||||||
|
rad, blackPaint
|
||||||
|
)
|
||||||
|
} else if (hasRealNotch && notchInfo != null && notchInfo.isAccurate && notchInfo.path != null) {
|
||||||
|
offscreenCanvas.drawPath(notchInfo.path, blackPaint)
|
||||||
|
} else if (hasRealNotch && notchInfo != null) {
|
||||||
|
val bounds = notchInfo.bounds
|
||||||
|
val rad = kotlin.math.max(bounds.width(), bounds.height()) / 2f
|
||||||
|
offscreenCanvas.drawRoundRect(bounds, rad, rad, blackPaint)
|
||||||
|
} else {
|
||||||
|
// No notch: draw black bar
|
||||||
|
offscreenCanvas.drawRect(0f, 0f, screenWidthPx, blackBarHeightPx, blackPaint)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Draw avatar shape
|
||||||
|
if (avatarState.showBlob) {
|
||||||
|
val cx = avatarState.centerX
|
||||||
|
val cy = avatarState.centerY
|
||||||
|
val r = avatarState.radius
|
||||||
|
val cornerR = avatarState.cornerRadius
|
||||||
|
|
||||||
|
if (cornerR >= r * 0.95f) {
|
||||||
|
offscreenCanvas.drawCircle(cx, cy, r, blackPaint)
|
||||||
|
} else {
|
||||||
|
rectTmp.set(cx - r, cy - r, cx + r, cy + r)
|
||||||
|
offscreenCanvas.drawRoundRect(rectTmp, cornerR, cornerR, blackPaint)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Draw metaball connector
|
||||||
|
if (showConnector && avatarState.showBlob) {
|
||||||
|
c1.x = notchCenterX
|
||||||
|
c1.y = notchCenterY
|
||||||
|
c2.x = avatarState.centerX
|
||||||
|
c2.y = avatarState.centerY
|
||||||
|
|
||||||
|
if (createMetaballPath(c1, c2, notchRadiusPx, avatarState.radius, v, metaballPath)) {
|
||||||
|
offscreenCanvas.drawPath(metaballPath, blackPaint)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
offscreenCanvas.restore()
|
||||||
|
|
||||||
|
// Apply stack blur on CPU
|
||||||
|
val blurRadius = (ProfileMetaballConstants.BLUR_RADIUS * 2 / scaleConst).toInt().coerceAtLeast(1)
|
||||||
|
stackBlurBitmapInPlace(offscreenBitmap, blurRadius)
|
||||||
|
|
||||||
|
// Draw blurred bitmap with color matrix filter (alpha threshold)
|
||||||
|
nativeCanvas.save()
|
||||||
|
nativeCanvas.translate(optimizedOffsetX, 0f)
|
||||||
|
nativeCanvas.scale(
|
||||||
|
optimizedW.toFloat() / offscreenBitmap.width,
|
||||||
|
optimizedH.toFloat() / offscreenBitmap.height
|
||||||
|
)
|
||||||
|
nativeCanvas.drawBitmap(offscreenBitmap, 0f, 0f, filterPaint)
|
||||||
|
nativeCanvas.restore()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// LAYER 2: Avatar content (same as GPU path)
|
||||||
|
if (avatarState.showBlob) {
|
||||||
|
val baseSizeDp = ProfileMetaballConstants.AVATAR_SIZE_EXPANDED
|
||||||
|
val baseSizePx = with(density) { baseSizeDp.toPx() }
|
||||||
|
|
||||||
|
val baseScale = (avatarState.radius * 2f) / baseSizePx
|
||||||
|
val targetExpansionScale = screenWidthPx / baseSizePx
|
||||||
|
val uniformScale = if (expansionProgress > 0f) {
|
||||||
|
lerpFloat(baseScale, targetExpansionScale, expansionProgress)
|
||||||
|
} else {
|
||||||
|
baseScale
|
||||||
|
}.coerceAtLeast(0.01f)
|
||||||
|
|
||||||
|
val avatarCenterX = avatarState.centerX
|
||||||
|
val avatarCenterY = avatarState.centerY
|
||||||
|
val targetCenterX = screenWidthPx / 2f
|
||||||
|
val targetCenterY = headerHeightPx / 2f
|
||||||
|
val currentCenterX = if (expansionProgress > 0f) lerpFloat(avatarCenterX, targetCenterX, expansionProgress) else avatarCenterX
|
||||||
|
val currentCenterY = if (expansionProgress > 0f) lerpFloat(avatarCenterY, targetCenterY, expansionProgress) else avatarCenterY
|
||||||
|
|
||||||
|
val cornerRadiusPx: Float = if (expansionProgress > 0f) {
|
||||||
|
lerpFloat(baseSizePx / 2f, 0f, expansionProgress)
|
||||||
|
} else {
|
||||||
|
(avatarState.cornerRadius / baseScale).coerceAtMost(baseSizePx / 2f)
|
||||||
|
}
|
||||||
|
|
||||||
|
val offsetX = with(density) { (currentCenterX - baseSizePx / 2f).toDp() }
|
||||||
|
val offsetY = with(density) { (currentCenterY - baseSizePx / 2f).toDp() }
|
||||||
|
val cornerRadiusDp = with(density) { cornerRadiusPx.toDp() }
|
||||||
|
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.offset(x = offsetX, y = offsetY)
|
||||||
|
.width(baseSizeDp)
|
||||||
|
.height(baseSizeDp)
|
||||||
|
.graphicsLayer {
|
||||||
|
scaleX = uniformScale
|
||||||
|
scaleY = uniformScale
|
||||||
|
alpha = avatarState.opacity
|
||||||
|
}
|
||||||
|
.clip(RoundedCornerShape(cornerRadiusDp)),
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
content = avatarContent
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DEBUG: Temporary toggle to force a specific rendering path.
|
||||||
|
* Set forceMode to test different paths on your device:
|
||||||
|
* - null: auto-detect (default production behavior)
|
||||||
|
* - "gpu": force GPU path (requires API 31+)
|
||||||
|
* - "cpu": force CPU bitmap path
|
||||||
|
* - "compat": force compat/noop path
|
||||||
|
*
|
||||||
|
* Set forceNoNotch = true to simulate no-notch device (black bar fallback).
|
||||||
|
*
|
||||||
|
* TODO: Remove before release!
|
||||||
|
*/
|
||||||
|
object MetaballDebug {
|
||||||
|
var forceMode: String? = null // "gpu", "cpu", "compat", or null
|
||||||
|
var forceNoNotch: Boolean = false // true = pretend no notch exists
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DEBUG: Floating panel with buttons to switch metaball rendering path.
|
||||||
|
* Place inside a Box (e.g. profile header) — it aligns to bottom-center.
|
||||||
|
* TODO: Remove before release!
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun MetaballDebugPanel(modifier: Modifier = Modifier) {
|
||||||
|
var currentMode by remember { mutableStateOf(MetaballDebug.forceMode) }
|
||||||
|
var noNotch by remember { mutableStateOf(MetaballDebug.forceNoNotch) }
|
||||||
|
|
||||||
|
val context = LocalContext.current
|
||||||
|
val perfClass = remember { DevicePerformanceClass.get(context) }
|
||||||
|
|
||||||
|
Column(
|
||||||
|
modifier = modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 12.dp)
|
||||||
|
.background(
|
||||||
|
ComposeColor.Black.copy(alpha = 0.75f),
|
||||||
|
RoundedCornerShape(12.dp)
|
||||||
|
)
|
||||||
|
.padding(12.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||||
|
) {
|
||||||
|
// Title
|
||||||
|
Text(
|
||||||
|
text = "Metaball Debug | API ${Build.VERSION.SDK_INT} | $perfClass",
|
||||||
|
color = ComposeColor.White,
|
||||||
|
fontSize = 11.sp,
|
||||||
|
fontWeight = FontWeight.Bold
|
||||||
|
)
|
||||||
|
|
||||||
|
// Mode buttons row
|
||||||
|
Row(
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
) {
|
||||||
|
val modes = listOf(null to "Auto", "gpu" to "GPU", "cpu" to "CPU", "compat" to "Compat")
|
||||||
|
modes.forEach { (mode, label) ->
|
||||||
|
val isSelected = currentMode == mode
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.weight(1f)
|
||||||
|
.clip(RoundedCornerShape(8.dp))
|
||||||
|
.background(
|
||||||
|
if (isSelected) ComposeColor(0xFF4CAF50) else ComposeColor.White.copy(alpha = 0.15f)
|
||||||
|
)
|
||||||
|
.border(
|
||||||
|
width = 1.dp,
|
||||||
|
color = if (isSelected) ComposeColor(0xFF4CAF50) else ComposeColor.White.copy(alpha = 0.3f),
|
||||||
|
shape = RoundedCornerShape(8.dp)
|
||||||
|
)
|
||||||
|
.clickable {
|
||||||
|
MetaballDebug.forceMode = mode
|
||||||
|
currentMode = mode
|
||||||
|
}
|
||||||
|
.padding(vertical = 8.dp),
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = label,
|
||||||
|
color = ComposeColor.White,
|
||||||
|
fontSize = 12.sp,
|
||||||
|
fontWeight = if (isSelected) FontWeight.Bold else FontWeight.Normal
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// No-notch toggle
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = "Force no-notch (black bar)",
|
||||||
|
color = ComposeColor.White,
|
||||||
|
fontSize = 12.sp
|
||||||
|
)
|
||||||
|
Switch(
|
||||||
|
checked = noNotch,
|
||||||
|
onCheckedChange = {
|
||||||
|
MetaballDebug.forceNoNotch = it
|
||||||
|
noNotch = it
|
||||||
|
},
|
||||||
|
colors = SwitchDefaults.colors(
|
||||||
|
checkedThumbColor = ComposeColor(0xFF4CAF50),
|
||||||
|
checkedTrackColor = ComposeColor(0xFF4CAF50).copy(alpha = 0.5f)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Current active path info
|
||||||
|
val activePath = when (currentMode) {
|
||||||
|
"gpu" -> if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) "GPU (forced)" else "GPU needs API 31!"
|
||||||
|
"cpu" -> "CPU (forced)"
|
||||||
|
"compat" -> "Compat (forced)"
|
||||||
|
else -> when {
|
||||||
|
Build.VERSION.SDK_INT >= Build.VERSION_CODES.S && perfClass >= PerformanceClass.AVERAGE -> "GPU (auto)"
|
||||||
|
perfClass >= PerformanceClass.HIGH -> "CPU (auto)"
|
||||||
|
else -> "Compat (auto)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Text(
|
||||||
|
text = "Active: $activePath" + if (noNotch) " + no-notch" else "",
|
||||||
|
color = ComposeColor(0xFF4CAF50),
|
||||||
|
fontSize = 11.sp,
|
||||||
|
fontWeight = FontWeight.Medium
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Auto-selecting wrapper — 3-tier architecture matching Telegram's ProfileGooeyView:
|
||||||
|
* 1. GPU path (Android 12+, average+ performance): RenderEffect blur + ColorMatrixColorFilter
|
||||||
|
* 2. CPU path (any Android, high performance): Bitmap blur + ColorMatrixColorFilter
|
||||||
|
* 3. Noop path (low-end devices): Simple scale/opacity animation
|
||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
fun ProfileMetaballEffect(
|
fun ProfileMetaballEffect(
|
||||||
@@ -817,27 +1250,76 @@ fun ProfileMetaballEffect(
|
|||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
avatarContent: @Composable BoxScope.() -> Unit = {},
|
avatarContent: @Composable BoxScope.() -> Unit = {},
|
||||||
) {
|
) {
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
val context = LocalContext.current
|
||||||
ProfileMetaballOverlay(
|
val performanceClass = remember { DevicePerformanceClass.get(context) }
|
||||||
collapseProgress = collapseProgress,
|
|
||||||
expansionProgress = expansionProgress,
|
// Debug: log which path is selected
|
||||||
statusBarHeight = statusBarHeight,
|
val selectedPath = when (MetaballDebug.forceMode) {
|
||||||
headerHeight = headerHeight,
|
"gpu" -> "GPU (forced)"
|
||||||
hasAvatar = hasAvatar,
|
"cpu" -> "CPU (forced)"
|
||||||
avatarColor = avatarColor,
|
"compat" -> "Compat (forced)"
|
||||||
modifier = modifier,
|
else -> when {
|
||||||
avatarContent = avatarContent
|
Build.VERSION.SDK_INT >= Build.VERSION_CODES.S && performanceClass >= PerformanceClass.AVERAGE -> "GPU (auto)"
|
||||||
)
|
performanceClass >= PerformanceClass.HIGH -> "CPU (auto)"
|
||||||
} else {
|
else -> "Compat (auto)"
|
||||||
ProfileMetaballOverlayCompat(
|
}
|
||||||
collapseProgress = collapseProgress,
|
}
|
||||||
expansionProgress = expansionProgress,
|
LaunchedEffect(selectedPath) {
|
||||||
statusBarHeight = statusBarHeight,
|
Log.d("MetaballDebug", "Rendering path: $selectedPath, forceNoNotch: ${MetaballDebug.forceNoNotch}, perf: $performanceClass, API: ${Build.VERSION.SDK_INT}")
|
||||||
headerHeight = headerHeight,
|
}
|
||||||
hasAvatar = hasAvatar,
|
|
||||||
avatarColor = avatarColor,
|
// Resolve actual mode
|
||||||
modifier = modifier,
|
val useGpu = when (MetaballDebug.forceMode) {
|
||||||
avatarContent = avatarContent
|
"gpu" -> Build.VERSION.SDK_INT >= Build.VERSION_CODES.S // still need API 31
|
||||||
)
|
"cpu" -> false
|
||||||
|
"compat" -> false
|
||||||
|
else -> Build.VERSION.SDK_INT >= Build.VERSION_CODES.S && performanceClass >= PerformanceClass.AVERAGE
|
||||||
|
}
|
||||||
|
val useCpu = when (MetaballDebug.forceMode) {
|
||||||
|
"gpu" -> false
|
||||||
|
"cpu" -> true
|
||||||
|
"compat" -> false
|
||||||
|
else -> !useGpu && performanceClass >= PerformanceClass.HIGH
|
||||||
|
}
|
||||||
|
|
||||||
|
when {
|
||||||
|
useGpu -> {
|
||||||
|
val factorMult = if (performanceClass == PerformanceClass.HIGH) 1f else 1.5f
|
||||||
|
ProfileMetaballOverlay(
|
||||||
|
collapseProgress = collapseProgress,
|
||||||
|
expansionProgress = expansionProgress,
|
||||||
|
statusBarHeight = statusBarHeight,
|
||||||
|
headerHeight = headerHeight,
|
||||||
|
hasAvatar = hasAvatar,
|
||||||
|
avatarColor = avatarColor,
|
||||||
|
factorMult = factorMult,
|
||||||
|
modifier = modifier,
|
||||||
|
avatarContent = avatarContent
|
||||||
|
)
|
||||||
|
}
|
||||||
|
useCpu -> {
|
||||||
|
ProfileMetaballOverlayCpu(
|
||||||
|
collapseProgress = collapseProgress,
|
||||||
|
expansionProgress = expansionProgress,
|
||||||
|
statusBarHeight = statusBarHeight,
|
||||||
|
headerHeight = headerHeight,
|
||||||
|
hasAvatar = hasAvatar,
|
||||||
|
avatarColor = avatarColor,
|
||||||
|
modifier = modifier,
|
||||||
|
avatarContent = avatarContent
|
||||||
|
)
|
||||||
|
}
|
||||||
|
else -> {
|
||||||
|
ProfileMetaballOverlayCompat(
|
||||||
|
collapseProgress = collapseProgress,
|
||||||
|
expansionProgress = expansionProgress,
|
||||||
|
statusBarHeight = statusBarHeight,
|
||||||
|
headerHeight = headerHeight,
|
||||||
|
hasAvatar = hasAvatar,
|
||||||
|
avatarColor = avatarColor,
|
||||||
|
modifier = modifier,
|
||||||
|
avatarContent = avatarContent
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -55,6 +55,7 @@ import com.rosetta.messenger.biometric.BiometricAvailability
|
|||||||
import com.rosetta.messenger.biometric.BiometricPreferences
|
import com.rosetta.messenger.biometric.BiometricPreferences
|
||||||
import com.rosetta.messenger.repository.AvatarRepository
|
import com.rosetta.messenger.repository.AvatarRepository
|
||||||
import com.rosetta.messenger.ui.components.BlurredAvatarBackground
|
import com.rosetta.messenger.ui.components.BlurredAvatarBackground
|
||||||
|
|
||||||
import com.rosetta.messenger.ui.components.metaball.ProfileMetaballEffect
|
import com.rosetta.messenger.ui.components.metaball.ProfileMetaballEffect
|
||||||
import com.rosetta.messenger.utils.AvatarFileManager
|
import com.rosetta.messenger.utils.AvatarFileManager
|
||||||
import com.rosetta.messenger.utils.ImageCropHelper
|
import com.rosetta.messenger.utils.ImageCropHelper
|
||||||
|
|||||||
Reference in New Issue
Block a user