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
|
||||
|
||||
import android.graphics.ColorMatrixColorFilter
|
||||
import android.graphics.RenderEffect
|
||||
import android.graphics.RuntimeShader
|
||||
import android.graphics.Shader
|
||||
import android.os.Build
|
||||
import androidx.annotation.RequiresApi
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.BoxScope
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.asComposeRenderEffect
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import org.intellij.lang.annotations.Language
|
||||
|
||||
/**
|
||||
* AGSL Shader for metaball effect
|
||||
*
|
||||
* This shader creates the "liquid merge" effect by:
|
||||
* 1. Taking already-blurred content as input
|
||||
* 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.
|
||||
* Alpha threshold color matrix for metaball effect.
|
||||
* Same as GPU_ALPHA_THRESHOLD_MATRIX in ProfileMetaballOverlay:
|
||||
* newAlpha = oldAlpha * 51 - 6375 → values above ~125/255 become opaque.
|
||||
* Works on API 31+ (Android 12) — no AGSL/RuntimeShader needed.
|
||||
*/
|
||||
@Language("AGSL")
|
||||
const val MetaballShaderSource = """
|
||||
uniform shader composable;
|
||||
uniform float cutoff;
|
||||
|
||||
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);
|
||||
}
|
||||
"""
|
||||
private val METABALL_ALPHA_THRESHOLD_MATRIX = floatArrayOf(
|
||||
1f, 0f, 0f, 0f, 0f,
|
||||
0f, 1f, 0f, 0f, 0f,
|
||||
0f, 0f, 1f, 0f, 0f,
|
||||
0f, 0f, 0f, 51f, 51f * -125f
|
||||
)
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* 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 cutoff Alpha threshold (0-1). Higher = harder edges. Default 0.5
|
||||
* @param content Composable content (should contain MetaEntity elements)
|
||||
*/
|
||||
@RequiresApi(Build.VERSION_CODES.TIRAMISU)
|
||||
@RequiresApi(Build.VERSION_CODES.S)
|
||||
@Composable
|
||||
fun MetaContainer(
|
||||
modifier: Modifier = Modifier,
|
||||
cutoff: Float = 0.5f,
|
||||
content: @Composable BoxScope.() -> Unit,
|
||||
) {
|
||||
val metaShader = remember { RuntimeShader(MetaballShaderSource) }
|
||||
|
||||
Box(
|
||||
modifier = modifier.graphicsLayer {
|
||||
metaShader.setFloatUniform("cutoff", cutoff)
|
||||
renderEffect = RenderEffect.createRuntimeShaderEffect(
|
||||
metaShader, "composable"
|
||||
renderEffect = RenderEffect.createColorFilterEffect(
|
||||
ColorMatrixColorFilter(METABALL_ALPHA_THRESHOLD_MATRIX)
|
||||
).asComposeRenderEffect()
|
||||
},
|
||||
content = content,
|
||||
@@ -77,10 +55,10 @@ fun MetaContainer(
|
||||
|
||||
/**
|
||||
* Modifier that applies GPU-accelerated blur 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.
|
||||
*
|
||||
*
|
||||
* @param blur Blur radius in pixels. Higher = softer edges, larger merge area
|
||||
*/
|
||||
@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.
|
||||
*
|
||||
*
|
||||
* This creates a layered effect:
|
||||
* - metaContent: The blurred shape that participates in metaball merging
|
||||
* - content: The actual visible content (not blurred)
|
||||
*
|
||||
*
|
||||
* @param modifier Modifier for the entity
|
||||
* @param blur Blur radius for the metaball effect
|
||||
* @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 = {},
|
||||
) {
|
||||
Box(modifier = modifier) {
|
||||
// Blurred layer for metaball effect
|
||||
Box(
|
||||
modifier = Modifier.customBlur(blur),
|
||||
content = metaContent,
|
||||
)
|
||||
// Visible content on top
|
||||
content()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,28 +1,42 @@
|
||||
package com.rosetta.messenger.ui.components.metaball
|
||||
|
||||
import android.graphics.ColorMatrixColorFilter
|
||||
import android.graphics.Path
|
||||
import android.graphics.RectF
|
||||
import android.util.Log
|
||||
import android.graphics.RenderEffect
|
||||
import android.graphics.RuntimeShader
|
||||
import android.graphics.Shader
|
||||
import android.os.Build
|
||||
import android.view.Gravity
|
||||
import androidx.annotation.RequiresApi
|
||||
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.BoxScope
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.offset
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
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.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.derivedStateOf
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
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.LocalContext
|
||||
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 org.intellij.lang.annotations.Language
|
||||
import androidx.compose.ui.unit.sp
|
||||
import kotlin.math.abs
|
||||
import kotlin.math.max
|
||||
import kotlin.math.min
|
||||
@@ -94,7 +109,6 @@ object ProfileMetaballConstants {
|
||||
|
||||
// Blur settings for gooey effect (like Telegram's intensity = 15f)
|
||||
const val BLUR_RADIUS = 15f
|
||||
const val CUTOFF = 0.5f
|
||||
|
||||
// Blur range for avatar (like Telegram: 2 + (1-fraction) * 20)
|
||||
const val BLUR_MIN = 2f
|
||||
@@ -108,28 +122,27 @@ object ProfileMetaballConstants {
|
||||
}
|
||||
|
||||
/**
|
||||
* AGSL Shader for metaball alpha threshold effect
|
||||
* Same as Telegram's ColorMatrixColorFilter with alpha threshold
|
||||
* GPU alpha threshold via ColorMatrixColorFilter (API 31+)
|
||||
* 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 const val ProfileMetaballShaderSource = """
|
||||
uniform shader composable;
|
||||
uniform float cutoff;
|
||||
|
||||
half4 main(float2 fragCoord) {
|
||||
half4 color = composable.eval(fragCoord);
|
||||
float alpha = color.a;
|
||||
|
||||
// Hard threshold for gooey effect
|
||||
if (alpha > cutoff) {
|
||||
alpha = 1.0;
|
||||
} else {
|
||||
alpha = 0.0;
|
||||
}
|
||||
|
||||
return half4(color.r, color.g, color.b, alpha);
|
||||
}
|
||||
"""
|
||||
private val GPU_ALPHA_THRESHOLD_MATRIX = floatArrayOf(
|
||||
1f, 0f, 0f, 0f, 0f,
|
||||
0f, 1f, 0f, 0f, 0f,
|
||||
0f, 0f, 1f, 0f, 0f,
|
||||
0f, 0f, 0f, 51f, 51f * -125f
|
||||
)
|
||||
|
||||
/**
|
||||
* No-notch fallback: black bar height at the top of the screen.
|
||||
* Matches Telegram's BLACK_KING_BAR = 32dp.
|
||||
* When no notch is detected, the avatar merges into this bar instead.
|
||||
*/
|
||||
private const val BLACK_BAR_HEIGHT_DP = 32f
|
||||
|
||||
/**
|
||||
* 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!
|
||||
*/
|
||||
@RequiresApi(Build.VERSION_CODES.TIRAMISU)
|
||||
@RequiresApi(Build.VERSION_CODES.S)
|
||||
@Composable
|
||||
fun ProfileMetaballOverlay(
|
||||
collapseProgress: Float,
|
||||
@@ -345,6 +358,7 @@ fun ProfileMetaballOverlay(
|
||||
headerHeight: Dp,
|
||||
hasAvatar: Boolean,
|
||||
@Suppress("UNUSED_PARAMETER") avatarColor: ComposeColor,
|
||||
factorMult: Float = 1f,
|
||||
modifier: Modifier = Modifier,
|
||||
avatarContent: @Composable BoxScope.() -> Unit = {},
|
||||
) {
|
||||
@@ -445,27 +459,26 @@ fun ProfileMetaballOverlay(
|
||||
}
|
||||
}
|
||||
|
||||
// Metaball shader
|
||||
val metaShader = remember { RuntimeShader(ProfileMetaballShaderSource) }
|
||||
|
||||
// Path for metaball connector
|
||||
val metaballPath = remember { Path() }
|
||||
val c1 = remember { Point() } // Notch center
|
||||
val c2 = remember { Point() } // Avatar center
|
||||
|
||||
// 🔥 Reusable RectF like Telegram's AndroidUtilities.rectTmp
|
||||
// Avoid creating new RectF objects every frame
|
||||
|
||||
// Reusable RectF like Telegram's AndroidUtilities.rectTmp
|
||||
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
|
||||
// Like Telegram: v = clamp((1f - c / 1.3f) / 2f, 0.8f, 0)
|
||||
val distance = avatarState.centerY - notchCenterY
|
||||
val maxDist = avatarSizeExpandedPx
|
||||
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 near = if (avatarState.isNear) 1f else 1f - (avatarState.radius - dp32) / (dp40 - dp32)
|
||||
val v = if (!avatarState.isNear) {
|
||||
@@ -473,37 +486,37 @@ fun ProfileMetaballOverlay(
|
||||
} else {
|
||||
baseV
|
||||
}
|
||||
|
||||
// Show connector only when avatar is small enough (like Telegram isDrawing)
|
||||
// AND not when expanding (no metaball effect when expanded)
|
||||
// AND only when hasAvatar is true (no drop animation for placeholder)
|
||||
// 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
|
||||
val showConnector = hasRealNotch && hasAvatar && expansionProgress == 0f && avatarState.isDrawing && avatarState.showBlob && distance < maxDist * 1.5f
|
||||
|
||||
// Don't show black metaball shapes when expanded or when no avatar or no real notch
|
||||
val showMetaballLayer = hasRealNotch && hasAvatar && expansionProgress == 0f
|
||||
|
||||
|
||||
// Show connector when avatar is small enough (isDrawing) and not expanding
|
||||
// No longer requires hasRealNotch — works with black bar fallback too
|
||||
val showConnector = hasAvatar && expansionProgress == 0f && avatarState.isDrawing && avatarState.showBlob && distance < maxDist * 1.5f
|
||||
|
||||
// Show metaball layer when collapsing with avatar
|
||||
val showMetaballLayer = hasAvatar && expansionProgress == 0f
|
||||
|
||||
// Adjusted blur radius based on device performance (factorMult)
|
||||
val adjustedBlurRadius = ProfileMetaballConstants.BLUR_RADIUS / factorMult
|
||||
|
||||
Box(modifier = modifier
|
||||
.fillMaxSize()
|
||||
.clip(RectangleShape) // Clip to bounds - prevents avatar overflow when expanded
|
||||
.clip(RectangleShape)
|
||||
) {
|
||||
// LAYER 1: Metaball shapes with blur effect (BLACK shapes only)
|
||||
// HIDDEN when expanded - only show avatar content
|
||||
// LAYER 1: Metaball shapes with blur + alpha threshold (BLACK shapes only)
|
||||
if (showMetaballLayer) {
|
||||
Canvas(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.graphicsLayer {
|
||||
metaShader.setFloatUniform("cutoff", ProfileMetaballConstants.CUTOFF)
|
||||
// IMPORTANT: First blur, THEN threshold shader
|
||||
// createChainEffect(outer, inner) - inner is applied first
|
||||
// ColorMatrixColorFilter for alpha threshold (API 31+)
|
||||
// Replaces AGSL RuntimeShader — same gooey effect
|
||||
val blurEffect = RenderEffect.createBlurEffect(
|
||||
ProfileMetaballConstants.BLUR_RADIUS,
|
||||
ProfileMetaballConstants.BLUR_RADIUS,
|
||||
adjustedBlurRadius,
|
||||
adjustedBlurRadius,
|
||||
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)
|
||||
renderEffect = RenderEffect.createChainEffect(thresholdEffect, blurEffect)
|
||||
.asComposeRenderEffect()
|
||||
@@ -513,36 +526,29 @@ fun ProfileMetaballOverlay(
|
||||
color = android.graphics.Color.BLACK
|
||||
style = android.graphics.Paint.Style.FILL
|
||||
}
|
||||
|
||||
|
||||
drawIntoCanvas { canvas ->
|
||||
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 (notchInfo != null && notchInfo.isLikelyCircle) {
|
||||
// Draw circle at actual notch position
|
||||
if (hasRealNotch && notchInfo != null && notchInfo.isLikelyCircle) {
|
||||
nativeCanvas.drawCircle(
|
||||
notchCenterX,
|
||||
notchCenterY,
|
||||
notchRadiusPx,
|
||||
paint
|
||||
)
|
||||
} else if (notchInfo != null && notchInfo.isAccurate && notchInfo.path != null) {
|
||||
// Draw actual notch path
|
||||
} else if (hasRealNotch && notchInfo != null && notchInfo.isAccurate && notchInfo.path != null) {
|
||||
nativeCanvas.drawPath(notchInfo.path, paint)
|
||||
} else if (notchInfo != null) {
|
||||
// Draw rounded rect for non-accurate notch
|
||||
} else if (hasRealNotch && notchInfo != null) {
|
||||
val bounds = notchInfo.bounds
|
||||
val rad = kotlin.math.max(bounds.width(), bounds.height()) / 2f
|
||||
nativeCanvas.drawRoundRect(bounds, rad, rad, paint)
|
||||
} else {
|
||||
// No notch - draw small circle at status bar center
|
||||
nativeCanvas.drawCircle(
|
||||
screenWidthPx / 2f,
|
||||
statusBarHeightPx / 2f,
|
||||
notchRadiusPx,
|
||||
paint
|
||||
)
|
||||
// No notch fallback: full-width black bar at top
|
||||
// Like Telegram's ProfileGooeyView when notchInfo == null
|
||||
nativeCanvas.drawRect(0f, 0f, screenWidthPx, blackBarHeightPx, paint)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -699,8 +705,10 @@ fun ProfileMetaballOverlayCompat(
|
||||
val avatarSizeMinPx = with(density) { ProfileMetaballConstants.AVATAR_SIZE_MIN.toPx() }
|
||||
|
||||
// Fallback notch values for compat mode
|
||||
val notchCenterX = screenWidthPx / 2f // Center of screen for compat mode
|
||||
val notchCenterY = statusBarHeightPx / 2f
|
||||
// Use black bar center as target (like Telegram's BLACK_KING_BAR)
|
||||
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() }
|
||||
|
||||
// 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
|
||||
fun ProfileMetaballEffect(
|
||||
@@ -817,27 +1250,76 @@ fun ProfileMetaballEffect(
|
||||
modifier: Modifier = Modifier,
|
||||
avatarContent: @Composable BoxScope.() -> Unit = {},
|
||||
) {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
ProfileMetaballOverlay(
|
||||
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
|
||||
)
|
||||
val context = LocalContext.current
|
||||
val performanceClass = remember { DevicePerformanceClass.get(context) }
|
||||
|
||||
// Debug: log which path is selected
|
||||
val selectedPath = when (MetaballDebug.forceMode) {
|
||||
"gpu" -> "GPU (forced)"
|
||||
"cpu" -> "CPU (forced)"
|
||||
"compat" -> "Compat (forced)"
|
||||
else -> when {
|
||||
Build.VERSION.SDK_INT >= Build.VERSION_CODES.S && performanceClass >= PerformanceClass.AVERAGE -> "GPU (auto)"
|
||||
performanceClass >= PerformanceClass.HIGH -> "CPU (auto)"
|
||||
else -> "Compat (auto)"
|
||||
}
|
||||
}
|
||||
LaunchedEffect(selectedPath) {
|
||||
Log.d("MetaballDebug", "Rendering path: $selectedPath, forceNoNotch: ${MetaballDebug.forceNoNotch}, perf: $performanceClass, API: ${Build.VERSION.SDK_INT}")
|
||||
}
|
||||
|
||||
// Resolve actual mode
|
||||
val useGpu = when (MetaballDebug.forceMode) {
|
||||
"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.repository.AvatarRepository
|
||||
import com.rosetta.messenger.ui.components.BlurredAvatarBackground
|
||||
|
||||
import com.rosetta.messenger.ui.components.metaball.ProfileMetaballEffect
|
||||
import com.rosetta.messenger.utils.AvatarFileManager
|
||||
import com.rosetta.messenger.utils.ImageCropHelper
|
||||
|
||||
Reference in New Issue
Block a user