|
|
|
|
@ -20,6 +20,7 @@ import androidx.compose.ui.geometry.Offset
|
|
|
|
|
import androidx.compose.ui.geometry.Size |
|
|
|
|
import androidx.compose.ui.graphics.Color |
|
|
|
|
import androidx.compose.ui.input.pointer.pointerInput |
|
|
|
|
import androidx.compose.foundation.gestures.detectTapGestures |
|
|
|
|
import androidx.compose.ui.platform.LocalConfiguration |
|
|
|
|
import androidx.compose.ui.platform.LocalContext |
|
|
|
|
import androidx.compose.ui.platform.LocalDensity |
|
|
|
|
@ -31,7 +32,8 @@ import com.utun.chat.data.NativeLib
|
|
|
|
|
import kotlinx.coroutines.delay |
|
|
|
|
|
|
|
|
|
@Composable |
|
|
|
|
fun MessageBubble(message: Message, onPlayVoice: (String, () -> Unit) -> Unit = { _, _ -> }) { |
|
|
|
|
fun MessageBubble(message: Message, onPlayVoice: (String, () -> Unit, () -> Unit) -> Unit = { _, _, _ -> }, |
|
|
|
|
onSeekVoice: (String, Int, () -> 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 |
|
|
|
|
@ -56,7 +58,7 @@ fun MessageBubble(message: Message, onPlayVoice: (String, () -> Unit) -> Unit =
|
|
|
|
|
Spacer(Modifier.height(2.dp)) |
|
|
|
|
|
|
|
|
|
when { |
|
|
|
|
isVoice -> VoiceBubble(message, textColor, onPlayVoice) |
|
|
|
|
isVoice -> VoiceBubble(message, textColor, onPlayVoice, onSeekVoice) |
|
|
|
|
isMedia -> Text("\uD83D\uDCC4 ${message.text}", fontSize = 15.sp, color = textColor) |
|
|
|
|
else -> { |
|
|
|
|
Text(message.author, fontSize = 13.sp, fontWeight = FontWeight.Bold, color = textColor.copy(alpha = 0.7f)) |
|
|
|
|
@ -78,23 +80,26 @@ fun MessageBubble(message: Message, onPlayVoice: (String, () -> Unit) -> Unit =
|
|
|
|
|
} |
|
|
|
|
} |
|
|
|
|
@Composable |
|
|
|
|
private fun VoiceBubble(message: Message, textColor: Color, onPlay: (String, () -> Unit) -> Unit) { |
|
|
|
|
private fun VoiceBubble(message: Message, textColor: Color, |
|
|
|
|
onPlay: (String, () -> Unit, () -> Unit) -> Unit, |
|
|
|
|
onSeek: (String, Int, () -> Unit) -> Unit) { |
|
|
|
|
val wf = message.waveform // List<Float> 0..1, 100 elements |
|
|
|
|
val wfCount = wf.size |
|
|
|
|
val durMs = message.voiceDurationMs |
|
|
|
|
var playing by remember { mutableStateOf(false) } |
|
|
|
|
var paused 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 } } |
|
|
|
|
val onDone = remember { { playing = false; paused = false; elapsedMs = 0; progress = 0f } } |
|
|
|
|
val onPaused = remember { { paused = true; playing = false } } |
|
|
|
|
|
|
|
|
|
/* elapsed timer while playing */ |
|
|
|
|
LaunchedEffect(playing) { |
|
|
|
|
if (playing) { |
|
|
|
|
val start = System.currentTimeMillis() |
|
|
|
|
while (playing) { |
|
|
|
|
elapsedMs = (System.currentTimeMillis() - start).toInt() |
|
|
|
|
if (playing && !paused) { |
|
|
|
|
val start = System.currentTimeMillis() - elapsedMs |
|
|
|
|
while (playing && !paused) { |
|
|
|
|
elapsedMs = (System.currentTimeMillis() - start).toInt().coerceIn(0, durMs) |
|
|
|
|
if (durMs > 0) progress = (elapsedMs.toFloat() / durMs.toFloat()).coerceIn(0f, 1f) |
|
|
|
|
delay(80) |
|
|
|
|
} |
|
|
|
|
@ -108,47 +113,52 @@ private fun VoiceBubble(message: Message, textColor: Color, onPlay: (String, ()
|
|
|
|
|
modifier = Modifier.size(32.dp).background(Color.White, CircleShape) |
|
|
|
|
.clickable { |
|
|
|
|
if (message.filePath.isNotEmpty()) { |
|
|
|
|
playing = true; elapsedMs = 0; progress = 0f |
|
|
|
|
onPlay(message.filePath, onDone) |
|
|
|
|
if (playing && !paused) { |
|
|
|
|
onPlay(message.filePath, onDone, onPaused) |
|
|
|
|
} else { |
|
|
|
|
playing = true; paused = false |
|
|
|
|
onPlay(message.filePath, onDone, onPaused) |
|
|
|
|
} |
|
|
|
|
} |
|
|
|
|
}, |
|
|
|
|
contentAlignment = Alignment.Center |
|
|
|
|
) { |
|
|
|
|
Text(if (playing) "\u23F8" else "\u25B6", fontSize = 14.sp, color = Color.Black) |
|
|
|
|
Text(if (playing && !paused) "\u23F8" else "\u25B6", fontSize = 14.sp, color = Color.Black) |
|
|
|
|
} |
|
|
|
|
Spacer(Modifier.width(8.dp)) |
|
|
|
|
|
|
|
|
|
/* Waveform */ |
|
|
|
|
/* Waveform with seek on tap */ |
|
|
|
|
if (wfCount >= 2) { |
|
|
|
|
Canvas(modifier = Modifier.weight(1f).height(28.dp)) { |
|
|
|
|
Canvas(modifier = Modifier.weight(1f).height(28.dp) |
|
|
|
|
.pointerInput(message.filePath) { |
|
|
|
|
detectTapGestures { offset -> |
|
|
|
|
if (message.filePath.isEmpty()) return@detectTapGestures |
|
|
|
|
val seekFrac = (offset.x / size.width).coerceIn(0f, 1f) |
|
|
|
|
val seekMs = (seekFrac * durMs).toInt() |
|
|
|
|
playing = true; paused = false; elapsedMs = seekMs; progress = seekFrac |
|
|
|
|
onSeek(message.filePath, seekMs, onDone) |
|
|
|
|
} |
|
|
|
|
} |
|
|
|
|
) { |
|
|
|
|
val barW = size.width / wfCount |
|
|
|
|
val ph = size.height |
|
|
|
|
val playedIdx = if (playing && progress > 0f) (progress * wfCount).toInt() else 0 |
|
|
|
|
val showCursor = playing && !paused |
|
|
|
|
val playedIdx = if (showCursor && progress > 0f) (progress * wfCount).toInt() else 0 |
|
|
|
|
for (i in 0 until wfCount) { |
|
|
|
|
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 = playing && i < playedIdx |
|
|
|
|
val played = showCursor && i < playedIdx |
|
|
|
|
drawRect( |
|
|
|
|
color = when { |
|
|
|
|
played -> Color.White |
|
|
|
|
playing -> Color(0xFFCCCCD4).copy(alpha = 0.8f) |
|
|
|
|
else -> Color(0xFFCCCCD4).copy(alpha = 0.8f) |
|
|
|
|
}, |
|
|
|
|
color = when { played -> Color.White; else -> Color(0xFFCCCCD4).copy(alpha = 0.8f) }, |
|
|
|
|
topLeft = Offset(x, y), |
|
|
|
|
size = Size(barW * 0.75f, barH) |
|
|
|
|
) |
|
|
|
|
} |
|
|
|
|
/* yellow cursor line */ |
|
|
|
|
if (playing && progress > 0f && progress < 1f) { |
|
|
|
|
if (showCursor && progress > 0f && progress < 1f) { |
|
|
|
|
val cursorX = progress * size.width |
|
|
|
|
drawLine( |
|
|
|
|
color = Color(0xFFFFD600), |
|
|
|
|
start = Offset(cursorX, 0f), |
|
|
|
|
end = Offset(cursorX, ph), |
|
|
|
|
strokeWidth = 2.5f |
|
|
|
|
) |
|
|
|
|
drawLine(Color(0xFFFFD600), Offset(cursorX, 0f), Offset(cursorX, ph), strokeWidth = 2.5f) |
|
|
|
|
} |
|
|
|
|
} |
|
|
|
|
} |
|
|
|
|
@ -163,7 +173,7 @@ private fun VoiceBubble(message: Message, textColor: Color, onPlay: (String, ()
|
|
|
|
|
verticalAlignment = Alignment.Bottom |
|
|
|
|
) { |
|
|
|
|
Text( |
|
|
|
|
if (playing) "${formatDurationMs(elapsedMs)} / ${formatDurationMs(durMs)}" |
|
|
|
|
if (playing && !paused) "${formatDurationMs(elapsedMs)} / ${formatDurationMs(durMs)}" |
|
|
|
|
else formatDurationMs(durMs), |
|
|
|
|
fontSize = 12.sp, |
|
|
|
|
color = textColor.copy(alpha = 0.55f) |
|
|
|
|
|