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.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
|
||||
|
|
|
|||
Loading…
Reference in New Issue