Browse Source

refactor: single source of truth — PlaybackState in ViewModel

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.
topo_upd
evgeny 2 months ago
parent
commit
b7988f0ef8
  1. 32
      tools/chatgui-android/app/src/main/java/com/utun/chat/data/AudioRecorderManager.kt
  2. 68
      tools/chatgui-android/app/src/main/java/com/utun/chat/ui/components/MessageBubble.kt
  3. 12
      tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/ChatScreen.kt
  4. 66
      tools/chatgui-android/app/src/main/java/com/utun/chat/viewmodel/ChatViewModel.kt

32
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
}
}

68
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)

12
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)) }

66
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> = _playbackState
private val _channels = MutableStateFlow<List<Channel>>(emptyList())
val channels: StateFlow<List<Channel>> = _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() {

Loading…
Cancel
Save