android: PTT swipe-to-lock, recording overlay with level bar, cancel/send buttons
- voice_recorder: peak_level tracking in feed(), voice_recorder_get_peak_level() - bridge/JNI: voice_get_peak_level API - ViewModel: isRecordingLocked, recordingDurationMs with 80ms timer, lock/send/cancel - InputBar: 3 states (idle/holding/locked), custom pointerInput gesture with swipe-up lock - ChatScreen: poll peak level 12x/sec during recording
This commit is contained in:
1 parent
0f1e6e0006
commit
9b6dd6476c
8 files changed
+184
-47
No files matched your search
@@ -87,6 +87,7 @@ object NativeLib {
|
||||
fun voiceGetPreset(): Int = nativeVoiceGetPreset()
|
||||
fun voiceSetCompressor(enabled: Boolean) { nativeVoiceSetCompressor(enabled) }
|
||||
fun voiceGetCompressor(): Boolean = nativeVoiceGetCompressor()
|
||||
fun voiceGetPeakLevel(): Float = nativeVoiceGetPeakLevel()
|
||||
|
||||
/* ── Attachment ── */
|
||||
fun attachmentSend(channelId: String, filePath: String): Boolean = nativeAttachmentSend(channelId, filePath)
|
||||
@@ -128,6 +129,7 @@ object NativeLib {
|
||||
private external fun nativeVoiceGetPreset(): Int
|
||||
private external fun nativeVoiceSetCompressor(enabled: Boolean)
|
||||
private external fun nativeVoiceGetCompressor(): Boolean
|
||||
private external fun nativeVoiceGetPeakLevel(): Float
|
||||
|
||||
/* ── Attachment JNI ── */
|
||||
private external fun nativeAttachmentSend(channelId: String, filePath: String): Boolean
|
||||
|
||||
+89
-33
@@ -5,7 +5,7 @@ import android.content.pm.PackageManager
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.gestures.detectTapGestures
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.*
|
||||
@@ -17,11 +17,13 @@ import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.platform.LocalConfiguration
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.core.content.ContextCompat
|
||||
import com.utun.chat.data.Message
|
||||
import com.utun.chat.data.NativeLib
|
||||
import kotlinx.coroutines.delay
|
||||
|
||||
@Composable
|
||||
@@ -75,6 +77,8 @@ fun MessageBubble(message: Message) {
|
||||
}
|
||||
}
|
||||
|
||||
private val LOCK_SWIPE_THRESHOLD_DP = 80f
|
||||
|
||||
@Composable
|
||||
fun InputBar(
|
||||
text: String,
|
||||
@@ -82,13 +86,20 @@ fun InputBar(
|
||||
onSend: () -> Unit,
|
||||
enabled: Boolean = true,
|
||||
isRecording: Boolean = false,
|
||||
isRecordingLocked: Boolean = false,
|
||||
recordingDurationMs: Int = 0,
|
||||
recordingLevel: Float = 0f,
|
||||
onPttPressed: () -> Unit = {},
|
||||
onPttReleased: () -> Unit = {},
|
||||
onPttLock: () -> Unit = {},
|
||||
onVoiceSend: () -> Unit = {},
|
||||
onVoiceCancel: () -> Unit = {},
|
||||
onAttachClicked: () -> Unit = {},
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val ctx = LocalContext.current
|
||||
val density = LocalDensity.current
|
||||
val lockThresholdPx = with(density) { LOCK_SWIPE_THRESHOLD_DP.dp.toPx() }
|
||||
|
||||
var hasMicPerm by remember {
|
||||
mutableStateOf(
|
||||
@@ -100,21 +111,23 @@ fun InputBar(
|
||||
hasMicPerm = granted
|
||||
}
|
||||
|
||||
val filePicker = rememberLauncherForActivityResult(ActivityResultContracts.GetContent()) { uri ->
|
||||
uri?.let { onAttachClicked() }
|
||||
}
|
||||
val filePicker = rememberLauncherForActivityResult(ActivityResultContracts.GetContent()) { _ -> }
|
||||
|
||||
Row(
|
||||
modifier = modifier.fillMaxWidth().padding(8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
/* Attach button */
|
||||
IconButton(onClick = { filePicker.launch("*/*") }, enabled = enabled) {
|
||||
Text("\uD83D\uDCCE", fontSize = 20.sp)
|
||||
if (!isRecording) {
|
||||
IconButton(onClick = { filePicker.launch("*/*") }, enabled = enabled) {
|
||||
Text("\uD83D\uDCCE", fontSize = 20.sp)
|
||||
}
|
||||
} else {
|
||||
Spacer(Modifier.width(4.dp))
|
||||
}
|
||||
|
||||
/* Recording overlay or text input */
|
||||
if (isRecording) {
|
||||
/* Recording overlay */
|
||||
Surface(
|
||||
modifier = Modifier.weight(1f).height(48.dp),
|
||||
shape = RoundedCornerShape(8.dp),
|
||||
@@ -122,23 +135,25 @@ fun InputBar(
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxSize().padding(horizontal = 12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.SpaceBetween
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text(
|
||||
text = "\uD83D\uDD34 ${formatDuration(recordingDurationMs)}",
|
||||
color = Color.White,
|
||||
fontWeight = FontWeight.Bold,
|
||||
fontSize = 16.sp
|
||||
fontSize = 16.sp,
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
val lvlPct = (recordingLevel * 40f).coerceIn(0f, 1f)
|
||||
LinearProgressIndicator(
|
||||
progress = { lvlPct },
|
||||
modifier = Modifier.width(60.dp).height(4.dp).padding(end = 8.dp),
|
||||
color = Color.White,
|
||||
trackColor = Color.White.copy(alpha = 0.3f),
|
||||
)
|
||||
/* Cancel button — tap to cancel recording */
|
||||
IconButton(onClick = onPttReleased) {
|
||||
Text("\u2715", color = Color.White, fontSize = 16.sp)
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
/* Text input */
|
||||
OutlinedTextField(
|
||||
value = text,
|
||||
onValueChange = onTextChange,
|
||||
@@ -151,28 +166,69 @@ fun InputBar(
|
||||
|
||||
Spacer(Modifier.width(4.dp))
|
||||
|
||||
/* PTT button — press to talk */
|
||||
IconButton(
|
||||
onClick = {
|
||||
if (!hasMicPerm) permLauncher.launch(Manifest.permission.RECORD_AUDIO)
|
||||
},
|
||||
enabled = enabled && hasMicPerm,
|
||||
modifier = Modifier.pointerInput(Unit) {
|
||||
detectTapGestures(
|
||||
onPress = {
|
||||
if (isRecording && isRecordingLocked) {
|
||||
/* Locked: cancel + send voice */
|
||||
IconButton(onClick = onVoiceCancel) {
|
||||
Text("\u2715", fontSize = 20.sp, color = Color(0xFFE53935))
|
||||
}
|
||||
IconButton(onClick = onVoiceSend) {
|
||||
Text("\u25B6", fontSize = 20.sp, color = Color(0xFF4CAF50))
|
||||
}
|
||||
} else if (isRecording) {
|
||||
/* Holding: only PTT active */
|
||||
IconButton(
|
||||
onClick = {},
|
||||
enabled = true,
|
||||
modifier = Modifier.background(Color(0xFFE53935), RoundedCornerShape(4.dp))
|
||||
) {
|
||||
Text("\uD83C\uDFA4", fontSize = 20.sp)
|
||||
}
|
||||
} else {
|
||||
/* Idle: PTT + send */
|
||||
IconButton(
|
||||
onClick = {
|
||||
if (!hasMicPerm) { permLauncher.launch(Manifest.permission.RECORD_AUDIO); return@IconButton }
|
||||
},
|
||||
enabled = true,
|
||||
modifier = Modifier.pointerInput(Unit) {
|
||||
awaitPointerEventScope {
|
||||
/* wait for the first down */
|
||||
var event = awaitPointerEvent()
|
||||
var change = event.changes.firstOrNull() ?: return@awaitPointerEventScope
|
||||
if (!change.pressed) return@awaitPointerEventScope
|
||||
change.consume()
|
||||
|
||||
if (!hasMicPerm) return@awaitPointerEventScope
|
||||
onPttPressed()
|
||||
tryAwaitRelease()
|
||||
var locked = false
|
||||
var totalDragY = 0f
|
||||
var lastY = change.position.y
|
||||
|
||||
while (true) {
|
||||
event = awaitPointerEvent()
|
||||
change = event.changes.firstOrNull() ?: break
|
||||
if (change.pressed) {
|
||||
val dy = change.position.y - lastY
|
||||
lastY = change.position.y
|
||||
totalDragY += dy
|
||||
if (totalDragY < -lockThresholdPx && !locked) {
|
||||
locked = true; onPttLock()
|
||||
}
|
||||
change.consume()
|
||||
} else {
|
||||
change.consume(); break
|
||||
}
|
||||
}
|
||||
onPttReleased()
|
||||
}
|
||||
)
|
||||
}
|
||||
) {
|
||||
Text("\uD83C\uDFA4", fontSize = 20.sp)
|
||||
}
|
||||
) {
|
||||
Text("\uD83C\uDFA4", fontSize = 20.sp)
|
||||
}
|
||||
|
||||
/* Send button */
|
||||
Button(onClick = onSend, enabled = enabled && text.isNotBlank()) {
|
||||
Text("Send")
|
||||
Button(onClick = onSend, enabled = enabled && text.isNotBlank()) {
|
||||
Text("Send")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -184,7 +240,7 @@ private fun formatDuration(ms: Int): String {
|
||||
|
||||
private fun formatTime(ts: Long): String {
|
||||
if (ts == 0L) return ""
|
||||
val s = ts / 1_000_000_000 // nanoseconds -> seconds
|
||||
val s = ts / 1_000_000_000
|
||||
val h = (s / 3600) % 24
|
||||
val m = (s / 60) % 60
|
||||
return "%02d:%02d".format(h, m)
|
||||
|
||||
@@ -1,8 +1,5 @@
|
||||
package com.utun.chat.ui.screens
|
||||
|
||||
import android.Manifest
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
@@ -12,10 +9,10 @@ import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.utun.chat.data.Channel
|
||||
import com.utun.chat.data.Message
|
||||
import com.utun.chat.data.NativeLib
|
||||
import com.utun.chat.ui.components.InputBar
|
||||
import com.utun.chat.ui.components.MessageBubble
|
||||
import com.utun.chat.viewmodel.ChatViewModel
|
||||
@@ -34,12 +31,17 @@ fun ChatScreen(
|
||||
var text by remember { mutableStateOf("") }
|
||||
val listState = rememberLazyListState()
|
||||
val isRecording by viewModel.isRecording.collectAsState()
|
||||
val ctx = LocalContext.current
|
||||
val isRecordingLocked by viewModel.isRecordingLocked.collectAsState()
|
||||
val recordingDurationMs by viewModel.recordingDurationMs.collectAsState()
|
||||
var recordingLevel by remember { mutableStateOf(0f) }
|
||||
|
||||
val filePicker = rememberLauncherForActivityResult(ActivityResultContracts.GetContent()) { uri ->
|
||||
uri?.let {
|
||||
val path = it.path ?: return@let
|
||||
viewModel.sendAttachment(path)
|
||||
/* Poll peak level during recording */
|
||||
LaunchedEffect(isRecording) {
|
||||
if (isRecording) {
|
||||
while (true) {
|
||||
recordingLevel = NativeLib.voiceGetPeakLevel()
|
||||
delay(80)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -74,13 +76,20 @@ fun ChatScreen(
|
||||
},
|
||||
enabled = true,
|
||||
isRecording = isRecording,
|
||||
recordingDurationMs = 0,
|
||||
isRecordingLocked = isRecordingLocked,
|
||||
recordingDurationMs = recordingDurationMs,
|
||||
recordingLevel = recordingLevel,
|
||||
onPttPressed = { viewModel.startVoiceRecording() },
|
||||
onPttReleased = {
|
||||
val dur = viewModel.stopVoiceRecording()
|
||||
/* duration handled internally */
|
||||
if (!viewModel.isRecordingLocked.value) viewModel.sendVoiceMessage()
|
||||
},
|
||||
onAttachClicked = { filePicker.launch("*/*") },
|
||||
onPttLock = { viewModel.lockVoiceRecording() },
|
||||
onVoiceSend = { viewModel.sendVoiceMessage() },
|
||||
onVoiceCancel = {
|
||||
viewModel.cancelVoiceRecording()
|
||||
recordingLevel = 0f
|
||||
},
|
||||
onAttachClicked = {},
|
||||
modifier = Modifier.navigationBarsPadding()
|
||||
)
|
||||
}
|
||||
|
||||
@@ -41,6 +41,14 @@ class ChatViewModel : ViewModel() {
|
||||
private val _isRecording = MutableStateFlow(false)
|
||||
val isRecording: StateFlow<Boolean> = _isRecording
|
||||
|
||||
private val _isRecordingLocked = MutableStateFlow(false)
|
||||
val isRecordingLocked: StateFlow<Boolean> = _isRecordingLocked
|
||||
|
||||
private val _recordingDurationMs = MutableStateFlow(0)
|
||||
val recordingDurationMs: StateFlow<Int> = _recordingDurationMs
|
||||
|
||||
private var recordingStartTimeMs = 0L
|
||||
|
||||
init {
|
||||
viewModelScope.launch {
|
||||
AppEventHandler.events.collect { (type, data) -> handleEvent(type, data) }
|
||||
@@ -67,6 +75,14 @@ class ChatViewModel : ViewModel() {
|
||||
_isStuck.value = !NativeLib.isResponsive()
|
||||
}
|
||||
}
|
||||
viewModelScope.launch {
|
||||
while (isActive) {
|
||||
if (_isRecording.value) {
|
||||
_recordingDurationMs.value = (System.currentTimeMillis() - recordingStartTimeMs).toInt()
|
||||
}
|
||||
delay(80)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleEvent(type: Int, data: ByteArray?) {
|
||||
@@ -180,27 +196,49 @@ class ChatViewModel : ViewModel() {
|
||||
fun startVoiceRecording() {
|
||||
val ch = _currentChannel.value ?: return
|
||||
_isRecording.value = true
|
||||
_isRecordingLocked.value = false
|
||||
recordingStartTimeMs = System.currentTimeMillis()
|
||||
_recordingDurationMs.value = 0
|
||||
audioRecorder.startRecording(ch.id)
|
||||
LogManager.addLog("INFO", "VM", "voice recording started ch=${ch.id}")
|
||||
}
|
||||
|
||||
fun lockVoiceRecording() {
|
||||
_isRecordingLocked.value = true
|
||||
LogManager.addLog("DEBUG", "VM", "voice recording locked")
|
||||
}
|
||||
|
||||
fun stopVoiceRecording(): Int {
|
||||
if (!_isRecording.value) return 0
|
||||
val duration = audioRecorder.stopRecording()
|
||||
_isRecording.value = false
|
||||
_isRecordingLocked.value = false
|
||||
_recordingDurationMs.value = 0
|
||||
val ch = _currentChannel.value
|
||||
if (ch != null) {
|
||||
if (ch != null && duration >= 500) {
|
||||
viewModelScope.launch {
|
||||
delay(500)
|
||||
refreshMessages(ch.id)
|
||||
refreshChannels()
|
||||
}
|
||||
}
|
||||
LogManager.addLog("INFO", "VM", "voice recording stopped dur=${duration}ms")
|
||||
return duration
|
||||
}
|
||||
|
||||
fun cancelVoiceRecording() {
|
||||
audioRecorder.cancelRecording()
|
||||
_isRecording.value = false
|
||||
_isRecordingLocked.value = false
|
||||
_recordingDurationMs.value = 0
|
||||
LogManager.addLog("INFO", "VM", "voice recording cancelled")
|
||||
}
|
||||
|
||||
fun sendVoiceMessage() {
|
||||
val duration = stopVoiceRecording()
|
||||
if (duration < 500) {
|
||||
LogManager.addLog("INFO", "VM", "voice too short (${duration}ms), discarded")
|
||||
}
|
||||
}
|
||||
|
||||
fun sendAttachment(filePath: String) {
|
||||
|
||||
Reference in new issue
Block a user