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.
This commit is contained in:
1 parent
9742772880
commit
b7988f0ef8
4 files changed
+91
-87
No files matched your search
+12
-20
@@ -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
|
||||
}
|
||||
}
|
||||
+20
-48
@@ -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)
|
||||
|
||||
@@ -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)) }
|
||||
|
||||
@@ -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() {
|
||||
|
||||
Reference in new issue
Block a user