From b7988f0ef862d46b1b2a5071a06cae7e35f0435d Mon Sep 17 00:00:00 2001 From: evgeny Date: Wed, 29 Jul 2026 02:29:53 +0300 Subject: [PATCH] =?UTF-8?q?refactor:=20single=20source=20of=20truth=20?= =?UTF-8?q?=E2=80=94=20PlaybackState=20in=20ViewModel?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit VoiceBubble is now a pure renderer — no local state, no LaunchedEffect, no timer. All state from ViewModel.playbackState StateFlow: - playing/paused/elapsedMs/progress/filePath — one atomic snapshot - isActive = playbackState.filePath == message.filePath — only active clip shows cursor - onProgress callback from AudioRecorderManager feeds real elapsed into ViewModel - ViewModel timer (80ms) updates progress between onProgress callbacks Removed: VoiceBubble's playing/paused/seekVersion/PlayState/onDone/onPaused. Removed: AudioRecorderManager's isPaused/elapsedMs/pausedElapsedMs. --- .../utun/chat/data/AudioRecorderManager.kt | 32 ++++----- .../utun/chat/ui/components/MessageBubble.kt | 68 ++++++------------- .../com/utun/chat/ui/screens/ChatScreen.kt | 12 ++-- .../com/utun/chat/viewmodel/ChatViewModel.kt | 66 ++++++++++++++---- 4 files changed, 91 insertions(+), 87 deletions(-) diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/data/AudioRecorderManager.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/AudioRecorderManager.kt index 7d9b5367..07776f9f 100644 --- a/tools/chatgui-android/app/src/main/java/com/utun/chat/data/AudioRecorderManager.kt +++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/AudioRecorderManager.kt @@ -181,19 +181,16 @@ class AudioRecorderManager { } @Volatile private var playing = false - @Volatile var isPaused = false; private set - @Volatile var elapsedMs: Int = 0; private set - var pausedElapsedMs: Int = 0; private set private var playThread: Thread? = null @Volatile private var playGeneration = 0 - fun playVoiceFile(filePath: String, onProgress: ((Float) -> Unit)? = null, onComplete: () -> Unit = {}) { + fun playVoiceFile(filePath: String, onProgress: ((Float, Int) -> Unit)? = null, onComplete: () -> Unit = {}) { playFromPosition(filePath, 0, onProgress, onComplete) } - fun playFromPosition(filePath: String, startMs: Int, onProgress: ((Float) -> Unit)? = null, onComplete: () -> Unit = {}) { + fun playFromPosition(filePath: String, startMs: Int, onProgress: ((Float, Int) -> Unit)? = null, onComplete: () -> Unit = {}) { stopPlayback() - isPaused = false; elapsedMs = startMs; playing = true + playing = true playThread = Thread { val myGen = playGeneration @@ -217,17 +214,13 @@ class AudioRecorderManager { skippedSamples += dec } - val bufSize = AudioTrack.getMinBufferSize( - sampleRate, + val bufSize = AudioTrack.getMinBufferSize(sampleRate, if (channels == 2) AudioFormat.CHANNEL_OUT_STEREO else AudioFormat.CHANNEL_OUT_MONO, - AudioFormat.ENCODING_PCM_16BIT - ) + AudioFormat.ENCODING_PCM_16BIT) - val track = AudioTrack( - AudioManager.STREAM_MUSIC, sampleRate, + val track = AudioTrack(AudioManager.STREAM_MUSIC, sampleRate, if (channels == 2) AudioFormat.CHANNEL_OUT_STEREO else AudioFormat.CHANNEL_OUT_MONO, - AudioFormat.ENCODING_PCM_16BIT, bufSize, AudioTrack.MODE_STREAM - ) + AudioFormat.ENCODING_PCM_16BIT, bufSize, AudioTrack.MODE_STREAM) track.play() val pcm = ShortArray(frameSamples) @@ -239,8 +232,8 @@ class AudioRecorderManager { track.write(pcm, 0, read) readTotal += read val total = (skippedSamples + readTotal).toFloat() / (totalSamples * channels).coerceAtLeast(1).toFloat() - elapsedMs = startMs + (readTotal * FRAME_MS / frameSamples).toInt() - onProgress?.invoke(total) + val eMs = startMs + (readTotal * FRAME_MS / frameSamples).toInt() + onProgress?.invoke(total, eMs) } track.stop(); track.release() NativeLib.voiceDecodeClose(handle) @@ -248,19 +241,18 @@ class AudioRecorderManager { LogManager.addLog("ERROR", "AudioPlayer", "play error: ${e.message}") } if (playGeneration == myGen) { - playing = false; isPaused = false; onComplete() + playing = false; onComplete() } }.apply { start() } } fun pausePlayback() { - pausedElapsedMs = elapsedMs - playGeneration++; isPaused = true; playing = false + playGeneration++; playing = false playThread?.join(500); playThread = null } fun stopPlayback() { - playGeneration++; playing = false; isPaused = false; pausedElapsedMs = 0 + playGeneration++; playing = false playThread?.join(500); playThread = null } } 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 43d4a529..ebc62b15 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 @@ -29,11 +29,13 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import com.utun.chat.data.Message import com.utun.chat.data.NativeLib +import com.utun.chat.viewmodel.ChatViewModel import kotlinx.coroutines.delay @Composable -fun MessageBubble(message: Message, onPlayVoice: (String, () -> Unit, () -> Unit) -> Unit = { _, _, _ -> }, - onSeekVoice: (String, Int, () -> Unit) -> Unit = { _, _, _ -> }) { +fun MessageBubble(message: Message, playbackState: ChatViewModel.PlaybackState = ChatViewModel.PlaybackState(), + onPlayVoice: (String, Int) -> Unit = { _, _ -> }, + onSeekVoice: (String, Int, Int) -> 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 @@ -42,6 +44,7 @@ fun MessageBubble(message: Message, onPlayVoice: (String, () -> Unit, () -> Unit val isVoice = message.contentType == "audio/opus" val isMedia = message.contentType.isNotEmpty() && !isVoice && message.contentType != "text/plain" + val isActive = isVoice && playbackState.filePath == message.filePath Row( modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp, vertical = 2.dp), @@ -58,7 +61,7 @@ fun MessageBubble(message: Message, onPlayVoice: (String, () -> Unit, () -> Unit Spacer(Modifier.height(2.dp)) when { - isVoice -> VoiceBubble(message, textColor, onPlayVoice, onSeekVoice) + isVoice -> VoiceBubble(message, textColor, isActive, playbackState, 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)) @@ -79,58 +82,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) { +private fun VoiceBubble(message: Message, textColor: Color, isActive: Boolean, + ps: ChatViewModel.PlaybackState, + onPlay: (String, Int) -> Unit, + onSeek: (String, Int, Int) -> Unit) { 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 state by remember { mutableStateOf(PlayState(0, 0, 0f)) } - - val onDone = remember { { playing = false; paused = false; state = PlayState(0, 0, 0f) } } - val onPaused = remember { { paused = true; playing = false } } - - /* elapsed timer while playing — keyed on atomic playId */ - LaunchedEffect(state.playId) { - if (playing && !paused) { - val start = System.currentTimeMillis() - state.elapsedMs - while (playing && !paused) { - 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) - } - } - } + val playing = isActive && ps.playing + val paused = isActive && ps.paused + val elapsedMs = ps.elapsedMs + val progress = ps.progress Column { Row(verticalAlignment = Alignment.CenterVertically) { - /* Play/Pause button */ Box( modifier = Modifier.size(32.dp).background(Color.White, CircleShape) - .clickable { - if (message.filePath.isNotEmpty()) { - if (playing && !paused) { - onPlay(message.filePath, onDone, onPaused) - } else { - state = state.copy(playId = state.playId + 1) - playing = true; paused = false - onPlay(message.filePath, onDone, onPaused) - } - } - }, + .clickable { if (message.filePath.isNotEmpty()) onPlay(message.filePath, durMs) }, contentAlignment = Alignment.Center ) { Text(if (playing && !paused) "\u23F8" else "\u25B6", fontSize = 14.sp, color = Color.Black) } Spacer(Modifier.width(8.dp)) - /* Waveform with seek on tap */ if (wfCount >= 2) { Box( modifier = Modifier.weight(1f).height(28.dp) @@ -139,9 +114,7 @@ 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() - state = PlayState(state.playId + 1, seekMs, seekFrac) - playing = true; paused = false - onSeek(message.filePath, seekMs, onDone) + onSeek(message.filePath, seekMs, durMs) } } ) { @@ -149,7 +122,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 && state.progress > 0f) (state.progress * wfCount).toInt() else 0 + 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) @@ -162,8 +135,8 @@ private fun VoiceBubble(message: Message, textColor: Color, size = Size(barW * 0.75f, barH) ) } - if (showCursor && state.progress > 0f && state.progress < 1f) { - val cursorX = state.progress * size.width + if (showCursor && progress > 0f && progress < 1f) { + val cursorX = progress * size.width drawLine(Color(0xFFFFD600), Offset(cursorX, 0f), Offset(cursorX, ph), strokeWidth = 2.5f) } } @@ -173,14 +146,13 @@ private fun VoiceBubble(message: Message, textColor: Color, Spacer(Modifier.height(4.dp)) - /* Duration / elapsed + timestamp */ Row( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.Bottom ) { Text( - if (playing || paused) "${formatDurationMs(state.elapsedMs)} / ${formatDurationMs(durMs)}" + if (playing || paused) "${formatDurationMs(elapsedMs)} / ${formatDurationMs(durMs)}" else formatDurationMs(durMs), fontSize = 12.sp, color = textColor.copy(alpha = 0.55f) diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/ChatScreen.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/ChatScreen.kt index b6361178..95782215 100644 --- a/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/ChatScreen.kt +++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/ChatScreen.kt @@ -45,6 +45,8 @@ fun ChatScreen( } } + val playbackState by viewModel.playbackState.collectAsState() + LaunchedEffect(messages.size) { if (messages.isNotEmpty()) listState.animateScrollToItem(messages.size - 1) } @@ -63,12 +65,12 @@ fun ChatScreen( state = listState ) { items(messages.size) { idx -> - MessageBubble(messages[idx], - onPlayVoice = { path, onDone, onPaused -> - viewModel.playVoiceMessage(path, onDone, onPaused) + MessageBubble(messages[idx], playbackState = playbackState, + onPlayVoice = { path, durMs -> + viewModel.playVoiceMessage(path, durMs) }, - onSeekVoice = { path, seekMs, onDone -> - viewModel.seekVoice(path, seekMs, onDone) + onSeekVoice = { path, seekMs, durMs -> + viewModel.seekVoice(path, seekMs, durMs) }) } item { Spacer(Modifier.height(8.dp)) } diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/viewmodel/ChatViewModel.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/viewmodel/ChatViewModel.kt index 7b7f9ce9..06ac1a8f 100644 --- a/tools/chatgui-android/app/src/main/java/com/utun/chat/viewmodel/ChatViewModel.kt +++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/viewmodel/ChatViewModel.kt @@ -15,9 +15,21 @@ import kotlinx.coroutines.withContext class ChatViewModel : ViewModel() { + data class PlaybackState( + val filePath: String? = null, + val playing: Boolean = false, + val paused: Boolean = false, + val elapsedMs: Int = 0, + val progress: Float = 0f, + val durMs: Int = 0 + ) + private var repo: ChatRepository? = null private var dbReady = false + private val _playbackState = MutableStateFlow(PlaybackState()) + val playbackState: StateFlow = _playbackState + private val _channels = MutableStateFlow>(emptyList()) val channels: StateFlow> = _channels @@ -219,7 +231,19 @@ class ChatViewModel : ViewModel() { _recordingDurationMs.value = 0 val ch = _currentChannel.value if (ch != null && duration >= 500) { - viewModelScope.launch { + viewModelScope.launch { + while (isActive) { + val s = _playbackState.value + if (s.playing && !s.paused) { + _playbackState.value = s.copy( + elapsedMs = (s.elapsedMs + 80).coerceIn(0, s.durMs), + progress = if (s.durMs > 0) s.elapsedMs.toFloat().coerceIn(0f, s.durMs.toFloat()) / s.durMs.toFloat() else 0f + ) + } + delay(80) + } + } + viewModelScope.launch { delay(500) refreshMessages(ch.id) refreshChannels() @@ -264,28 +288,42 @@ class ChatViewModel : ViewModel() { NativeLib.voiceSetCompressorConfig(maxGainDb, lookbackMs, lookaheadMs, riseRate, targetLevelDb) } - private var _currentPlayingPath: String? = null - - fun playVoiceMessage(filePath: String, onDone: () -> Unit, onPaused: () -> Unit) { - if (_currentPlayingPath == filePath) { - if (audioRecorder.isPaused) { - audioRecorder.playFromPosition(filePath, audioRecorder.pausedElapsedMs, onComplete = onDone) + fun playVoiceMessage(filePath: String, durMs: Int) { + val cur = _playbackState.value + if (cur.filePath == filePath) { + if (cur.paused) { + audioRecorder.playFromPosition(filePath, cur.elapsedMs, + onProgress = { prog, elapsed -> + _playbackState.value = _playbackState.value.copy(elapsedMs = elapsed, progress = prog) + }, + onComplete = { + _playbackState.value = PlaybackState() + }) + _playbackState.value = cur.copy(playing = true, paused = false) } else { audioRecorder.pausePlayback() - onPaused() + _playbackState.value = cur.copy(playing = false, paused = true) } return } audioRecorder.stopPlayback() - _currentPlayingPath = filePath - LogManager.addLog("INFO", "VM", "play voice: $filePath") - audioRecorder.playVoiceFile(filePath, onComplete = { _currentPlayingPath = null; onDone() }) + _playbackState.value = PlaybackState(filePath, playing = true, durMs = durMs) + audioRecorder.playVoiceFile(filePath, + onProgress = { prog, elapsed -> + _playbackState.value = _playbackState.value.copy(elapsedMs = elapsed, progress = prog) + }, + onComplete = { _playbackState.value = PlaybackState() }) } - fun seekVoice(filePath: String, seekMs: Int, onDone: () -> Unit) { + fun seekVoice(filePath: String, seekMs: Int, durMs: Int) { audioRecorder.stopPlayback() - _currentPlayingPath = filePath - audioRecorder.playFromPosition(filePath, seekMs, onComplete = { _currentPlayingPath = null; onDone() }) + _playbackState.value = PlaybackState(filePath, playing = true, durMs = durMs, elapsedMs = seekMs, + progress = if (durMs > 0) seekMs.toFloat() / durMs.toFloat() else 0f) + audioRecorder.playFromPosition(filePath, seekMs, + onProgress = { prog, elapsed -> + _playbackState.value = _playbackState.value.copy(elapsedMs = elapsed, progress = prog) + }, + onComplete = { _playbackState.value = PlaybackState() }) } fun clearState() {