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:
null 2026-06-24 18:35:32 -05:00
parent cfea8f0d41
commit 3aa182a466
1 changed files with 31 additions and 4 deletions

View File

@ -39,6 +39,7 @@ import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
@ -284,6 +285,8 @@ private fun EncryptedVoiceMessage(
var tempFile by remember { mutableStateOf<File?>(null) }
var playing by remember { mutableStateOf(false) }
var loading by remember { mutableStateOf(false) }
var positionMs by remember { mutableLongStateOf(0L) }
val totalMs = durationMs.coerceAtLeast(1L)
DisposableEffect(Unit) {
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() {
val p = player
if (p != null) {
@ -310,7 +321,7 @@ private fun EncryptedVoiceMessage(
val mp = MediaPlayer()
runCatching {
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.start()
}.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))
Text(text = formatDuration(durationMs), style = MaterialTheme.typography.bodyMedium, color = tint)
LinearProgressIndicator(
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() }
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) {
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 {
val totalSec = (ms / 1000).toInt()
val m = totalSec / 60