|
|
|
|
@ -2,6 +2,7 @@ package com.utun.chat.ui.components
|
|
|
|
|
|
|
|
|
|
import android.Manifest |
|
|
|
|
import android.content.pm.PackageManager |
|
|
|
|
import androidx.core.content.ContextCompat |
|
|
|
|
import androidx.activity.compose.rememberLauncherForActivityResult |
|
|
|
|
import androidx.activity.result.contract.ActivityResultContracts |
|
|
|
|
import androidx.compose.foundation.Canvas |
|
|
|
|
@ -25,14 +26,12 @@ import androidx.compose.ui.platform.LocalDensity
|
|
|
|
|
import androidx.compose.ui.text.font.FontWeight |
|
|
|
|
import androidx.compose.ui.unit.dp |
|
|
|
|
import androidx.compose.ui.unit.sp |
|
|
|
|
import kotlin.math.log10 |
|
|
|
|
import androidx.core.content.ContextCompat |
|
|
|
|
import com.utun.chat.data.Message |
|
|
|
|
import com.utun.chat.data.NativeLib |
|
|
|
|
import kotlinx.coroutines.delay |
|
|
|
|
|
|
|
|
|
@Composable |
|
|
|
|
fun MessageBubble(message: Message, onPlayVoice: (String) -> Unit = {}) { |
|
|
|
|
fun MessageBubble(message: Message, onPlayVoice: (String, () -> Unit) -> Unit = { _, _ -> }) { |
|
|
|
|
val bgColor = if (message.isOutgoing) Color(0xFF4A90D9) else Color(0xFFE8E8E8) |
|
|
|
|
val textColor = if (message.isOutgoing) Color.White else Color.Black |
|
|
|
|
val alignment = if (message.isOutgoing) Arrangement.End else Arrangement.Start |
|
|
|
|
@ -78,15 +77,29 @@ fun MessageBubble(message: Message, onPlayVoice: (String) -> Unit = {}) {
|
|
|
|
|
} |
|
|
|
|
} |
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
@Composable |
|
|
|
|
private fun VoiceBubble(message: Message, textColor: Color, onPlay: (String) -> Unit) { |
|
|
|
|
val wf = message.waveform |
|
|
|
|
private fun VoiceBubble(message: Message, textColor: Color, onPlay: (String, () -> Unit) -> Unit) { |
|
|
|
|
val wf = message.waveform // List<Float> 0..1, 100 elements |
|
|
|
|
val wfCount = wf.size |
|
|
|
|
val durMs = message.voiceDurationMs |
|
|
|
|
val durSec = durMs / 1000f |
|
|
|
|
var playing by remember { mutableStateOf(false) } |
|
|
|
|
var elapsedMs by remember { mutableStateOf(0) } |
|
|
|
|
var progress by remember { mutableStateOf(0f) } |
|
|
|
|
|
|
|
|
|
/* auto-reset on complete */ |
|
|
|
|
val onDone = remember { { playing = false; elapsedMs = 0; progress = 0f } } |
|
|
|
|
|
|
|
|
|
/* elapsed timer while playing */ |
|
|
|
|
LaunchedEffect(playing) { |
|
|
|
|
if (playing) { |
|
|
|
|
val start = System.currentTimeMillis() |
|
|
|
|
while (playing) { |
|
|
|
|
elapsedMs = (System.currentTimeMillis() - start).toInt() |
|
|
|
|
if (durMs > 0) progress = (elapsedMs.toFloat() / durMs.toFloat()).coerceIn(0f, 1f) |
|
|
|
|
delay(80) |
|
|
|
|
} |
|
|
|
|
} |
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
Column { |
|
|
|
|
Row(verticalAlignment = Alignment.CenterVertically) { |
|
|
|
|
@ -95,9 +108,10 @@ private fun VoiceBubble(message: Message, textColor: Color, onPlay: (String) ->
|
|
|
|
|
modifier = Modifier.size(32.dp).background(Color(0xFF3390EC), CircleShape) |
|
|
|
|
.clickable { |
|
|
|
|
if (!playing && message.filePath.isNotEmpty()) { |
|
|
|
|
playing = true; elapsedMs = 0; onPlay(message.filePath) |
|
|
|
|
playing = true; elapsedMs = 0; progress = 0f |
|
|
|
|
onPlay(message.filePath, onDone) |
|
|
|
|
} else { |
|
|
|
|
playing = false |
|
|
|
|
playing = false; onDone() |
|
|
|
|
} |
|
|
|
|
}, |
|
|
|
|
contentAlignment = Alignment.Center |
|
|
|
|
@ -111,15 +125,19 @@ private fun VoiceBubble(message: Message, textColor: Color, onPlay: (String) ->
|
|
|
|
|
Canvas(modifier = Modifier.weight(1f).height(28.dp)) { |
|
|
|
|
val barW = size.width / wfCount |
|
|
|
|
val ph = size.height |
|
|
|
|
val playedIdx = if (progress > 0f) (progress * wfCount).toInt() else 0 |
|
|
|
|
for (i in 0 until wfCount) { |
|
|
|
|
val v = wf[i] |
|
|
|
|
val db = if (v > 0.00001f) 20f * kotlin.math.log10(v.toDouble()).toFloat() else -100f |
|
|
|
|
val logV = ((db + 60f) / 60f).coerceIn(0f, 1f) |
|
|
|
|
val barH = (2f + logV * (ph - 2f)).coerceAtMost(ph) |
|
|
|
|
val level = wf[i].coerceIn(0f, 1f) |
|
|
|
|
val barH = (2f + level * (ph - 2f)).coerceAtMost(ph) |
|
|
|
|
val x = i.toFloat() * barW |
|
|
|
|
val y = ph - barH |
|
|
|
|
val played = i < playedIdx |
|
|
|
|
drawRect( |
|
|
|
|
color = Color(0xFF3390EC).copy(alpha = 0.35f), |
|
|
|
|
color = if (playing) { |
|
|
|
|
if (played) Color(0xFF3390EC) else Color(0xFF3390EC).copy(alpha = 0.25f) |
|
|
|
|
} else { |
|
|
|
|
Color(0xFF888899).copy(alpha = 0.45f) |
|
|
|
|
}, |
|
|
|
|
topLeft = Offset(x, y), |
|
|
|
|
size = Size(barW * 0.75f, barH) |
|
|
|
|
) |
|
|
|
|
@ -130,14 +148,15 @@ private fun VoiceBubble(message: Message, textColor: Color, onPlay: (String) ->
|
|
|
|
|
|
|
|
|
|
Spacer(Modifier.height(4.dp)) |
|
|
|
|
|
|
|
|
|
/* Duration + timestamp */ |
|
|
|
|
/* Duration / elapsed + timestamp */ |
|
|
|
|
Row( |
|
|
|
|
modifier = Modifier.fillMaxWidth(), |
|
|
|
|
horizontalArrangement = Arrangement.SpaceBetween, |
|
|
|
|
verticalAlignment = Alignment.Bottom |
|
|
|
|
) { |
|
|
|
|
Text( |
|
|
|
|
formatDurationMs(durMs), |
|
|
|
|
if (playing) "${formatDurationMs(elapsedMs)} / ${formatDurationMs(durMs)}" |
|
|
|
|
else formatDurationMs(durMs), |
|
|
|
|
fontSize = 12.sp, |
|
|
|
|
color = textColor.copy(alpha = 0.55f) |
|
|
|
|
) |
|
|
|
|
|