feat(chat): voice playback progress bar + 2-min recording cap
Voice bubbles show a determinate progress bar + elapsed time that advance during playback (polling MediaPlayer position); recording auto-stops and sends at 2 minutes. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
cfea8f0d41
commit
3aa182a466
|
|
@ -39,6 +39,7 @@ import androidx.compose.material.icons.filled.PlayArrow
|
||||||
import androidx.compose.material3.CircularProgressIndicator
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.IconButton
|
import androidx.compose.material3.IconButton
|
||||||
|
import androidx.compose.material3.LinearProgressIndicator
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Surface
|
import androidx.compose.material3.Surface
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
|
|
@ -284,6 +285,8 @@ private fun EncryptedVoiceMessage(
|
||||||
var tempFile by remember { mutableStateOf<File?>(null) }
|
var tempFile by remember { mutableStateOf<File?>(null) }
|
||||||
var playing by remember { mutableStateOf(false) }
|
var playing by remember { mutableStateOf(false) }
|
||||||
var loading by remember { mutableStateOf(false) }
|
var loading by remember { mutableStateOf(false) }
|
||||||
|
var positionMs by remember { mutableLongStateOf(0L) }
|
||||||
|
val totalMs = durationMs.coerceAtLeast(1L)
|
||||||
|
|
||||||
DisposableEffect(Unit) {
|
DisposableEffect(Unit) {
|
||||||
onDispose {
|
onDispose {
|
||||||
|
|
@ -292,6 +295,14 @@ private fun EncryptedVoiceMessage(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Poll the player position so the progress bar advances during playback.
|
||||||
|
LaunchedEffect(playing) {
|
||||||
|
while (playing) {
|
||||||
|
positionMs = player?.currentPosition?.toLong() ?: positionMs
|
||||||
|
delay(100)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fun toggle() {
|
fun toggle() {
|
||||||
val p = player
|
val p = player
|
||||||
if (p != null) {
|
if (p != null) {
|
||||||
|
|
@ -310,7 +321,7 @@ private fun EncryptedVoiceMessage(
|
||||||
val mp = MediaPlayer()
|
val mp = MediaPlayer()
|
||||||
runCatching {
|
runCatching {
|
||||||
mp.setDataSource(f.absolutePath)
|
mp.setDataSource(f.absolutePath)
|
||||||
mp.setOnCompletionListener { playing = false; runCatching { mp.seekTo(0) } }
|
mp.setOnCompletionListener { playing = false; positionMs = 0L; runCatching { mp.seekTo(0) } }
|
||||||
mp.prepare()
|
mp.prepare()
|
||||||
mp.start()
|
mp.start()
|
||||||
}.onSuccess { player = mp; playing = true }.onFailure { mp.release() }
|
}.onSuccess { player = mp; playing = true }.onFailure { mp.release() }
|
||||||
|
|
@ -340,8 +351,17 @@ private fun EncryptedVoiceMessage(
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Icon(Icons.Filled.Mic, contentDescription = null, tint = tint.copy(alpha = 0.7f), modifier = Modifier.size(16.dp))
|
LinearProgressIndicator(
|
||||||
Text(text = formatDuration(durationMs), style = MaterialTheme.typography.bodyMedium, color = tint)
|
progress = { if (totalMs > 0) (positionMs.toFloat() / totalMs).coerceIn(0f, 1f) else 0f },
|
||||||
|
modifier = Modifier.width(92.dp),
|
||||||
|
color = tint,
|
||||||
|
trackColor = tint.copy(alpha = 0.25f)
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = formatDuration(if (playing || positionMs > 0) positionMs else durationMs),
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = tint
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -464,7 +484,11 @@ fun ChatComposer(
|
||||||
) { granted: Boolean -> if (granted) startRecording() }
|
) { granted: Boolean -> if (granted) startRecording() }
|
||||||
|
|
||||||
LaunchedEffect(isRecording) {
|
LaunchedEffect(isRecording) {
|
||||||
while (isRecording) { elapsedMs = System.currentTimeMillis() - recordStart; delay(200) }
|
while (isRecording) {
|
||||||
|
elapsedMs = System.currentTimeMillis() - recordStart
|
||||||
|
if (elapsedMs >= MAX_RECORDING_MS) { finishRecording(send = true); break } // auto-send at the cap
|
||||||
|
delay(200)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
DisposableEffect(Unit) {
|
DisposableEffect(Unit) {
|
||||||
onDispose { runCatching { recorder.value?.release() }; recordFile.value?.delete() }
|
onDispose { runCatching { recorder.value?.release() }; recordFile.value?.delete() }
|
||||||
|
|
@ -549,6 +573,9 @@ fun ChatComposer(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Cap voice notes at 2 minutes; recording auto-stops and sends at this length. */
|
||||||
|
private const val MAX_RECORDING_MS = 120_000L
|
||||||
|
|
||||||
private fun formatDuration(ms: Long): String {
|
private fun formatDuration(ms: Long): String {
|
||||||
val totalSec = (ms / 1000).toInt()
|
val totalSec = (ms / 1000).toInt()
|
||||||
val m = totalSec / 60
|
val m = totalSec / 60
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue