Browse Source

fix: atomic PlayState replaces non-atomic seekVersion+elapsed+progress

PlayState data class bundles playId, elapsedMs, progress into one atomic snapshot.
LaunchedEffect keyed on playId — when it changes (seek/resume), the timer reads
elapsedMs and progress from the SAME snapshot, eliminating the race between
seekVersion++ and elapsedMs=seekMs.
topo_upd
evgeny 2 months ago
parent
commit
e4af56856c
  1. 35
      tools/chatgui-android/app/src/main/java/com/utun/chat/ui/components/MessageBubble.kt

35
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 @Composable
private fun VoiceBubble(message: Message, textColor: Color, private fun VoiceBubble(message: Message, textColor: Color,
onPlay: (String, () -> Unit, () -> Unit) -> Unit, onPlay: (String, () -> Unit, () -> Unit) -> Unit,
onSeek: (String, Int, () -> Unit) -> Unit) { onSeek: (String, Int, () -> Unit) -> Unit) {
val wf = message.waveform // List<Float> 0..1, 100 elements val wf = message.waveform
val wfCount = wf.size val wfCount = wf.size
val durMs = message.voiceDurationMs val durMs = message.voiceDurationMs
var playing by remember { mutableStateOf(false) } var playing by remember { mutableStateOf(false) }
var paused by remember { mutableStateOf(false) } var paused by remember { mutableStateOf(false) }
var seekVersion by remember { mutableStateOf(0) } var state by remember { mutableStateOf(PlayState(0, 0, 0f)) }
var elapsedMs by remember { mutableStateOf(0) }
var progress by remember { mutableStateOf(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 } } val onPaused = remember { { paused = true; playing = false } }
/* elapsed timer while playing */ /* elapsed timer while playing — keyed on atomic playId */
LaunchedEffect(playing, seekVersion) { LaunchedEffect(state.playId) {
if (playing && !paused) { if (playing && !paused) {
val start = System.currentTimeMillis() - elapsedMs val start = System.currentTimeMillis() - state.elapsedMs
while (playing && !paused) { while (playing && !paused) {
elapsedMs = (System.currentTimeMillis() - start).toInt().coerceIn(0, durMs) val ms = (System.currentTimeMillis() - start).toInt().coerceIn(0, durMs)
if (durMs > 0) progress = (elapsedMs.toFloat() / durMs.toFloat()).coerceIn(0f, 1f) val frac = if (durMs > 0) ms.toFloat() / durMs.toFloat() else 0f
state = state.copy(elapsedMs = ms, progress = frac.coerceIn(0f, 1f))
delay(80) delay(80)
} }
} }
@ -117,7 +118,7 @@ private fun VoiceBubble(message: Message, textColor: Color,
if (playing && !paused) { if (playing && !paused) {
onPlay(message.filePath, onDone, onPaused) onPlay(message.filePath, onDone, onPaused)
} else { } else {
seekVersion++ state = state.copy(playId = state.playId + 1)
playing = true; paused = false playing = true; paused = false
onPlay(message.filePath, onDone, onPaused) onPlay(message.filePath, onDone, onPaused)
} }
@ -138,8 +139,8 @@ private fun VoiceBubble(message: Message, textColor: Color,
if (message.filePath.isEmpty()) return@detectTapGestures if (message.filePath.isEmpty()) return@detectTapGestures
val seekFrac = (offset.x / size.width).coerceIn(0f, 1f) val seekFrac = (offset.x / size.width).coerceIn(0f, 1f)
val seekMs = (seekFrac * durMs).toInt() val seekMs = (seekFrac * durMs).toInt()
seekVersion++ state = PlayState(state.playId + 1, seekMs, seekFrac)
playing = true; paused = false; elapsedMs = seekMs; progress = seekFrac playing = true; paused = false
onSeek(message.filePath, seekMs, onDone) onSeek(message.filePath, seekMs, onDone)
} }
} }
@ -148,7 +149,7 @@ private fun VoiceBubble(message: Message, textColor: Color,
val barW = size.width / wfCount val barW = size.width / wfCount
val ph = size.height val ph = size.height
val showCursor = playing || paused 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) { for (i in 0 until wfCount) {
val level = wf[i].coerceIn(0f, 1f) val level = wf[i].coerceIn(0f, 1f)
val barH = (2f + level * (ph - 2f)).coerceAtMost(ph) 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) size = Size(barW * 0.75f, barH)
) )
} }
if (showCursor && progress > 0f && progress < 1f) { if (showCursor && state.progress > 0f && state.progress < 1f) {
val cursorX = progress * size.width val cursorX = state.progress * size.width
drawLine(Color(0xFFFFD600), Offset(cursorX, 0f), Offset(cursorX, ph), strokeWidth = 2.5f) 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 verticalAlignment = Alignment.Bottom
) { ) {
Text( Text(
if (playing || paused) "${formatDurationMs(elapsedMs)} / ${formatDurationMs(durMs)}" if (playing || paused) "${formatDurationMs(state.elapsedMs)} / ${formatDurationMs(durMs)}"
else formatDurationMs(durMs), else formatDurationMs(durMs),
fontSize = 12.sp, fontSize = 12.sp,
color = textColor.copy(alpha = 0.55f) color = textColor.copy(alpha = 0.55f)

Loading…
Cancel
Save