voice: real pause (saves position), waveform tap seek, resume from position
- AudioRecorderManager: playFromPosition(startMs), pausePlayback, elapsedMs tracking playVoiceFile now delegates to playFromPosition. fade skipped PCM frames on seek. - ViewModel: playVoiceMessage with pause toggle, seekVoice method - VoiceBubble: click tap on waveform → seek, pause state (⏸ keeps cursor visible) Webform canvas with detectTapGestures for seek input
This commit is contained in:
1 parent
887bbdbf84
commit
6980f34df9
4 files changed
+100
-58
No files matched your search
+39
-17
@@ -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
|
||||
}
|
||||
}
|
||||
+40
-30
@@ -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<Float> 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)
|
||||
|
||||
@@ -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)) }
|
||||
}
|
||||
|
||||
@@ -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() {
|
||||
|
||||
Reference in new issue
Block a user