diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/components/MessageBubble.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/components/MessageBubble.kt index abcab169..43d4a529 100644 --- a/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/components/MessageBubble.kt +++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/components/MessageBubble.kt @@ -79,29 +79,30 @@ fun MessageBubble(message: Message, onPlayVoice: (String, () -> Unit, () -> Unit } } } + private data class PlayState(val playId: Int, val elapsedMs: Int, val progress: Float) + @Composable private fun VoiceBubble(message: Message, textColor: Color, onPlay: (String, () -> Unit, () -> Unit) -> Unit, onSeek: (String, Int, () -> Unit) -> Unit) { - val wf = message.waveform // List 0..1, 100 elements + val wf = message.waveform val wfCount = wf.size val durMs = message.voiceDurationMs var playing by remember { mutableStateOf(false) } var paused by remember { mutableStateOf(false) } - var seekVersion by remember { mutableStateOf(0) } - var elapsedMs by remember { mutableStateOf(0) } - var progress by remember { mutableStateOf(0f) } + var state by remember { mutableStateOf(PlayState(0, 0, 0f)) } - val onDone = remember { { playing = false; paused = false; elapsedMs = 0; progress = 0f } } + val onDone = remember { { playing = false; paused = false; state = PlayState(0, 0, 0f) } } val onPaused = remember { { paused = true; playing = false } } - /* elapsed timer while playing */ - LaunchedEffect(playing, seekVersion) { + /* elapsed timer while playing — keyed on atomic playId */ + LaunchedEffect(state.playId) { if (playing && !paused) { - val start = System.currentTimeMillis() - elapsedMs + val start = System.currentTimeMillis() - state.elapsedMs while (playing && !paused) { - elapsedMs = (System.currentTimeMillis() - start).toInt().coerceIn(0, durMs) - if (durMs > 0) progress = (elapsedMs.toFloat() / durMs.toFloat()).coerceIn(0f, 1f) + val ms = (System.currentTimeMillis() - start).toInt().coerceIn(0, durMs) + val frac = if (durMs > 0) ms.toFloat() / durMs.toFloat() else 0f + state = state.copy(elapsedMs = ms, progress = frac.coerceIn(0f, 1f)) delay(80) } } @@ -117,7 +118,7 @@ private fun VoiceBubble(message: Message, textColor: Color, if (playing && !paused) { onPlay(message.filePath, onDone, onPaused) } else { - seekVersion++ + state = state.copy(playId = state.playId + 1) playing = true; paused = false onPlay(message.filePath, onDone, onPaused) } @@ -138,8 +139,8 @@ private fun VoiceBubble(message: Message, textColor: Color, if (message.filePath.isEmpty()) return@detectTapGestures val seekFrac = (offset.x / size.width).coerceIn(0f, 1f) val seekMs = (seekFrac * durMs).toInt() - seekVersion++ - playing = true; paused = false; elapsedMs = seekMs; progress = seekFrac + state = PlayState(state.playId + 1, seekMs, seekFrac) + playing = true; paused = false onSeek(message.filePath, seekMs, onDone) } } @@ -148,7 +149,7 @@ private fun VoiceBubble(message: Message, textColor: Color, val barW = size.width / wfCount val ph = size.height val showCursor = playing || paused - val playedIdx = if (showCursor && progress > 0f) (progress * wfCount).toInt() else 0 + val playedIdx = if (showCursor && state.progress > 0f) (state.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) @@ -161,8 +162,8 @@ private fun VoiceBubble(message: Message, textColor: Color, size = Size(barW * 0.75f, barH) ) } - if (showCursor && progress > 0f && progress < 1f) { - val cursorX = progress * size.width + if (showCursor && state.progress > 0f && state.progress < 1f) { + val cursorX = state.progress * size.width drawLine(Color(0xFFFFD600), Offset(cursorX, 0f), Offset(cursorX, ph), strokeWidth = 2.5f) } } @@ -179,7 +180,7 @@ private fun VoiceBubble(message: Message, textColor: Color, verticalAlignment = Alignment.Bottom ) { Text( - if (playing || paused) "${formatDurationMs(elapsedMs)} / ${formatDurationMs(durMs)}" + if (playing || paused) "${formatDurationMs(state.elapsedMs)} / ${formatDurationMs(durMs)}" else formatDurationMs(durMs), fontSize = 12.sp, color = textColor.copy(alpha = 0.55f)