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 d9cc22f5..15abedd5 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,11 +181,19 @@ 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 + private var currentPlayPath: String? = null - fun playVoiceFile(filePath: String, onProgress: (Float) -> Unit = {}, onComplete: () -> Unit = {}) { - if (playing) stopPlayback() - playing = true + fun playVoiceFile(filePath: String, onProgress: ((Float) -> Unit)? = null, onComplete: () -> Unit = {}) { + playFromPosition(filePath, 0, onProgress, onComplete) + } + + fun playFromPosition(filePath: String, startMs: Int, onProgress: ((Float) -> Unit)? = null, onComplete: () -> Unit = {}) { + stopPlayback() + isPaused = false; elapsedMs = startMs; playing = true; currentPlayPath = filePath playThread = Thread { try { @@ -193,10 +201,21 @@ class AudioRecorderManager { val handle = NativeLib.voiceDecodeOpen(filePath, info) if (handle == 0L) { LogManager.addLog("ERROR", "AudioPlayer", "decode open failed: $filePath") - playing = false; return@Thread + playing = false; onComplete(); return@Thread } val sampleRate = info[0]; val channels = info[1]; val totalSamples = info[2] + /* skip to startMs by decoding and discarding frames */ + val frameSamples = FRAME_SAMPLES * channels + val skipFrames = startMs / FRAME_MS + val skipBuf = ShortArray(frameSamples) + var skippedSamples = 0L + for (f in 0 until skipFrames) { + val dec = NativeLib.voiceDecodeRead(handle, skipBuf) + if (dec <= 0) break + skippedSamples += dec + } + val bufSize = AudioTrack.getMinBufferSize( sampleRate, if (channels == 2) AudioFormat.CHANNEL_OUT_STEREO else AudioFormat.CHANNEL_OUT_MONO, @@ -204,16 +223,13 @@ class AudioRecorderManager { ) val track = AudioTrack( - AudioManager.STREAM_MUSIC, - sampleRate, + 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(FRAME_SAMPLES * channels) + val pcm = ShortArray(frameSamples) var readTotal = 0L var read: Int while (playing) { @@ -221,22 +237,28 @@ class AudioRecorderManager { if (read <= 0) break track.write(pcm, 0, read) readTotal += read - if (totalSamples > 0) onProgress(readTotal.toFloat() / totalSamples.toFloat()) + val total = (skippedSamples + readTotal).toFloat() / (totalSamples * channels).coerceAtLeast(1).toFloat() + elapsedMs = startMs + (readTotal * FRAME_MS / frameSamples).toInt() + onProgress?.invoke(total) } - track.stop() - track.release() + track.stop(); track.release() NativeLib.voiceDecodeClose(handle) } catch (e: Exception) { LogManager.addLog("ERROR", "AudioPlayer", "play error: ${e.message}") } - playing = false + playing = false; isPaused = false; currentPlayPath = null onComplete() }.apply { start() } } + fun pausePlayback() { + pausedElapsedMs = elapsedMs + isPaused = true; playing = false + playThread?.join(500); playThread = null + } + fun stopPlayback() { - playing = false - playThread?.join(500) - playThread = null + playing = false; isPaused = false; pausedElapsedMs = 0 + 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 a1cbec90..b557d5b0 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 @@ -20,6 +20,7 @@ import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Size import androidx.compose.ui.graphics.Color import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.foundation.gestures.detectTapGestures import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalDensity @@ -31,7 +32,8 @@ import com.utun.chat.data.NativeLib import kotlinx.coroutines.delay @Composable -fun MessageBubble(message: Message, onPlayVoice: (String, () -> Unit) -> Unit = { _, _ -> }) { +fun MessageBubble(message: Message, onPlayVoice: (String, () -> Unit, () -> Unit) -> Unit = { _, _, _ -> }, + onSeekVoice: (String, Int, () -> Unit) -> 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 @@ -56,7 +58,7 @@ fun MessageBubble(message: Message, onPlayVoice: (String, () -> Unit) -> Unit = Spacer(Modifier.height(2.dp)) when { - isVoice -> VoiceBubble(message, textColor, onPlayVoice) + isVoice -> VoiceBubble(message, textColor, 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)) @@ -78,23 +80,26 @@ fun MessageBubble(message: Message, onPlayVoice: (String, () -> Unit) -> Unit = } } @Composable -private fun VoiceBubble(message: Message, textColor: Color, onPlay: (String, () -> Unit) -> Unit) { +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 wfCount = wf.size val durMs = message.voiceDurationMs var playing by remember { mutableStateOf(false) } + var paused by remember { mutableStateOf(false) } var elapsedMs by remember { mutableStateOf(0) } var progress by remember { mutableStateOf(0f) } - /* auto-reset on complete */ - val onDone = remember { { playing = false; elapsedMs = 0; progress = 0f } } + val onDone = remember { { playing = false; paused = false; elapsedMs = 0; progress = 0f } } + val onPaused = remember { { paused = true; playing = false } } /* elapsed timer while playing */ LaunchedEffect(playing) { - if (playing) { - val start = System.currentTimeMillis() - while (playing) { - elapsedMs = (System.currentTimeMillis() - start).toInt() + if (playing && !paused) { + val start = System.currentTimeMillis() - elapsedMs + while (playing && !paused) { + elapsedMs = (System.currentTimeMillis() - start).toInt().coerceIn(0, durMs) if (durMs > 0) progress = (elapsedMs.toFloat() / durMs.toFloat()).coerceIn(0f, 1f) delay(80) } @@ -108,47 +113,52 @@ private fun VoiceBubble(message: Message, textColor: Color, onPlay: (String, () modifier = Modifier.size(32.dp).background(Color.White, CircleShape) .clickable { if (message.filePath.isNotEmpty()) { - playing = true; elapsedMs = 0; progress = 0f - onPlay(message.filePath, onDone) + if (playing && !paused) { + onPlay(message.filePath, onDone, onPaused) + } else { + playing = true; paused = false + onPlay(message.filePath, onDone, onPaused) + } } }, contentAlignment = Alignment.Center ) { - Text(if (playing) "\u23F8" else "\u25B6", fontSize = 14.sp, color = Color.Black) + Text(if (playing && !paused) "\u23F8" else "\u25B6", fontSize = 14.sp, color = Color.Black) } Spacer(Modifier.width(8.dp)) - /* Waveform */ + /* Waveform with seek on tap */ if (wfCount >= 2) { - Canvas(modifier = Modifier.weight(1f).height(28.dp)) { + Canvas(modifier = Modifier.weight(1f).height(28.dp) + .pointerInput(message.filePath) { + detectTapGestures { offset -> + if (message.filePath.isEmpty()) return@detectTapGestures + val seekFrac = (offset.x / size.width).coerceIn(0f, 1f) + val seekMs = (seekFrac * durMs).toInt() + playing = true; paused = false; elapsedMs = seekMs; progress = seekFrac + onSeek(message.filePath, seekMs, onDone) + } + } + ) { val barW = size.width / wfCount val ph = size.height - val playedIdx = if (playing && progress > 0f) (progress * wfCount).toInt() else 0 + val showCursor = playing && !paused + 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) val x = i.toFloat() * barW val y = ph - barH - val played = playing && i < playedIdx + val played = showCursor && i < playedIdx drawRect( - color = when { - played -> Color.White - playing -> Color(0xFFCCCCD4).copy(alpha = 0.8f) - else -> Color(0xFFCCCCD4).copy(alpha = 0.8f) - }, + color = when { played -> Color.White; else -> Color(0xFFCCCCD4).copy(alpha = 0.8f) }, topLeft = Offset(x, y), size = Size(barW * 0.75f, barH) ) } - /* yellow cursor line */ - if (playing && progress > 0f && progress < 1f) { + if (showCursor && progress > 0f && progress < 1f) { val cursorX = progress * size.width - drawLine( - color = Color(0xFFFFD600), - start = Offset(cursorX, 0f), - end = Offset(cursorX, ph), - strokeWidth = 2.5f - ) + drawLine(Color(0xFFFFD600), Offset(cursorX, 0f), Offset(cursorX, ph), strokeWidth = 2.5f) } } } @@ -163,7 +173,7 @@ private fun VoiceBubble(message: Message, textColor: Color, onPlay: (String, () verticalAlignment = Alignment.Bottom ) { Text( - if (playing) "${formatDurationMs(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 239517d0..b6361178 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 @@ -63,9 +63,13 @@ fun ChatScreen( state = listState ) { items(messages.size) { idx -> - MessageBubble(messages[idx], onPlayVoice = { path, onDone -> - viewModel.playVoiceMessage(path, onDone) - }) + MessageBubble(messages[idx], + onPlayVoice = { path, onDone, onPaused -> + viewModel.playVoiceMessage(path, onDone, onPaused) + }, + onSeekVoice = { path, seekMs, onDone -> + viewModel.seekVoice(path, seekMs, onDone) + }) } 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 9ece7926..d6008248 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 @@ -266,20 +266,26 @@ class ChatViewModel : ViewModel() { private var _currentPlayingPath: String? = null - fun playVoiceMessage(filePath: String, onComplete: () -> Unit) { + fun playVoiceMessage(filePath: String, onDone: () -> Unit, onPaused: () -> Unit) { if (_currentPlayingPath == filePath) { - audioRecorder.stopPlayback() - _currentPlayingPath = null - onComplete() + if (audioRecorder.isPaused) { + audioRecorder.playFromPosition(filePath, audioRecorder.pausedElapsedMs, onComplete = onDone) + } else { + audioRecorder.pausePlayback() + onPaused() + } return } audioRecorder.stopPlayback() _currentPlayingPath = filePath LogManager.addLog("INFO", "VM", "play voice: $filePath") - audioRecorder.playVoiceFile(filePath, onComplete = { - _currentPlayingPath = null - onComplete() - }) + audioRecorder.playVoiceFile(filePath, onComplete = { _currentPlayingPath = null; onDone() }) + } + + fun seekVoice(filePath: String, seekMs: Int, onDone: () -> Unit) { + audioRecorder.stopPlayback() + _currentPlayingPath = filePath + audioRecorder.playFromPosition(filePath, seekMs, onComplete = onDone) } fun clearState() {