android: voice messages recording + playback, file attachments
- C99 port of AudioCompressor (libutun_lite/audio_compressor.h/c) - Voice recorder: PCM buffer + compressor + Opus encode + block splitting + uasync send - Attachment sender: file splitting into blocks + uasync send - Bridge API + JNI: voice start/feed/stop/cancel, attachment send, Opus decode for playback - Kotlin: AudioRecorderManager (AudioRecord + JNI feed), AudioPlayer (AudioTrack + JNI decode) - UI: PTT button with press-to-talk, attach button with file picker, recording overlay - Message display: voice messages (play button), file attachments (icon + name) - Settings: Opus preset selector (Low/Standard/High), compressor toggle - AndroidManifest: RECORD_AUDIO, storage permissions
This commit is contained in:
1 parent
e751e6ee69
commit
5b6979da27
18 files changed
+1725
-18
No files matched your search
@@ -67,6 +67,7 @@ class MainActivity : ComponentActivity() {
|
||||
channel = channel,
|
||||
messages = messages,
|
||||
connected = connected,
|
||||
viewModel = vm,
|
||||
onSendMessage = { vm.sendMessage(it) },
|
||||
onBack = { screen = Screen.ChannelList }
|
||||
)
|
||||
|
||||
@@ -0,0 +1,189 @@
|
||||
package com.utun.chat.data
|
||||
|
||||
import android.Manifest
|
||||
import android.content.pm.PackageManager
|
||||
import android.media.AudioFormat
|
||||
import android.media.AudioManager
|
||||
import android.media.AudioRecord
|
||||
import android.media.AudioTrack
|
||||
import android.media.MediaRecorder
|
||||
import androidx.core.content.ContextCompat
|
||||
import com.utun.chat.ChatApplication
|
||||
|
||||
class AudioRecorderManager {
|
||||
companion object {
|
||||
const val SAMPLE_RATE = 48000
|
||||
const val CHANNELS = 1
|
||||
const val FRAME_MS = 20
|
||||
const val FRAME_SAMPLES = SAMPLE_RATE * FRAME_MS / 1000
|
||||
const val MIN_DURATION_MS = 500
|
||||
}
|
||||
|
||||
private var audioRecord: AudioRecord? = null
|
||||
private var recordingThread: Thread? = null
|
||||
@Volatile private var isRecording = false
|
||||
|
||||
fun hasPermission(): Boolean {
|
||||
val ctx = ChatApplication.instance
|
||||
return ContextCompat.checkSelfPermission(ctx, Manifest.permission.RECORD_AUDIO) ==
|
||||
PackageManager.PERMISSION_GRANTED
|
||||
}
|
||||
|
||||
fun startRecording(channelId: String): Boolean {
|
||||
if (isRecording) return false
|
||||
if (!hasPermission()) return false
|
||||
|
||||
val ok = NativeLib.voiceStart(channelId)
|
||||
if (!ok) {
|
||||
LogManager.addLog("ERROR", "AudioRecorder", "native voiceStart failed")
|
||||
return false
|
||||
}
|
||||
|
||||
val bufferSize = AudioRecord.getMinBufferSize(
|
||||
SAMPLE_RATE,
|
||||
AudioFormat.CHANNEL_IN_MONO,
|
||||
AudioFormat.ENCODING_PCM_16BIT
|
||||
).coerceAtLeast(FRAME_SAMPLES * 2)
|
||||
|
||||
audioRecord = try {
|
||||
AudioRecord(
|
||||
MediaRecorder.AudioSource.MIC,
|
||||
SAMPLE_RATE,
|
||||
AudioFormat.CHANNEL_IN_MONO,
|
||||
AudioFormat.ENCODING_PCM_16BIT,
|
||||
bufferSize * 2
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
LogManager.addLog("ERROR", "AudioRecorder", "AudioRecord create failed: ${e.message}")
|
||||
NativeLib.voiceCancel()
|
||||
return false
|
||||
}
|
||||
|
||||
if (audioRecord?.state != AudioRecord.STATE_INITIALIZED) {
|
||||
LogManager.addLog("ERROR", "AudioRecorder", "AudioRecord not initialized")
|
||||
audioRecord?.release()
|
||||
audioRecord = null
|
||||
NativeLib.voiceCancel()
|
||||
return false
|
||||
}
|
||||
|
||||
isRecording = true
|
||||
LogManager.addLog("INFO", "AudioRecorder", "started ch=$channelId bufferSize=$bufferSize")
|
||||
audioRecord?.startRecording()
|
||||
|
||||
recordingThread = Thread {
|
||||
val buffer = ShortArray(FRAME_SAMPLES)
|
||||
var totalRead = 0L
|
||||
|
||||
while (isRecording && audioRecord?.recordingState == AudioRecord.RECORDSTATE_RECORDING) {
|
||||
val read = audioRecord?.read(buffer, 0, FRAME_SAMPLES) ?: break
|
||||
if (read <= 0) break
|
||||
NativeLib.voiceFeed(buffer.copyOf(read))
|
||||
totalRead += read
|
||||
}
|
||||
LogManager.addLog("DEBUG", "AudioRecorder", "thread done totalSamples=$totalRead")
|
||||
}.apply {
|
||||
priority = Thread.MAX_PRIORITY
|
||||
start()
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
fun stopRecording(): Int {
|
||||
if (!isRecording) return 0
|
||||
isRecording = false
|
||||
|
||||
recordingThread?.join(1000)
|
||||
recordingThread = null
|
||||
|
||||
audioRecord?.apply {
|
||||
try { stop() } catch (_: Exception) {}
|
||||
release()
|
||||
}
|
||||
audioRecord = null
|
||||
|
||||
val duration = NativeLib.voiceStop()
|
||||
LogManager.addLog("INFO", "AudioRecorder", "stopped duration=${duration}ms")
|
||||
return duration
|
||||
}
|
||||
|
||||
fun cancelRecording() {
|
||||
if (!isRecording) return
|
||||
isRecording = false
|
||||
|
||||
recordingThread?.join(1000)
|
||||
recordingThread = null
|
||||
|
||||
audioRecord?.apply {
|
||||
try { stop() } catch (_: Exception) {}
|
||||
release()
|
||||
}
|
||||
audioRecord = null
|
||||
|
||||
NativeLib.voiceCancel()
|
||||
LogManager.addLog("INFO", "AudioRecorder", "cancelled")
|
||||
}
|
||||
|
||||
fun isActive(): Boolean = isRecording
|
||||
|
||||
@Volatile private var playing = false
|
||||
private var playThread: Thread? = null
|
||||
|
||||
fun playVoiceFile(filePath: String, onProgress: (Float) -> Unit = {}, onComplete: () -> Unit = {}) {
|
||||
if (playing) stopPlayback()
|
||||
playing = true
|
||||
|
||||
playThread = Thread {
|
||||
try {
|
||||
val info = IntArray(3)
|
||||
val handle = NativeLib.voiceDecodeOpen(filePath, info)
|
||||
if (handle == 0L) {
|
||||
LogManager.addLog("ERROR", "AudioPlayer", "decode open failed: $filePath")
|
||||
playing = false; return@Thread
|
||||
}
|
||||
val sampleRate = info[0]; val channels = info[1]; val totalSamples = info[2]
|
||||
|
||||
val bufSize = AudioTrack.getMinBufferSize(
|
||||
sampleRate,
|
||||
if (channels == 2) AudioFormat.CHANNEL_OUT_STEREO else AudioFormat.CHANNEL_OUT_MONO,
|
||||
AudioFormat.ENCODING_PCM_16BIT
|
||||
)
|
||||
|
||||
val track = AudioTrack(
|
||||
AudioManager.STREAM_MUSIC,
|
||||
sampleRate,
|
||||
if (channels == 2) AudioFormat.CHANNEL_OUT_STEREO else AudioFormat.CHANNEL_OUT_MONO,
|
||||
AudioFormat.ENCODING_PCM_16BIT,
|
||||
bufSize,
|
||||
AudioTrack.MODE_STREAM
|
||||
)
|
||||
track.play()
|
||||
|
||||
val pcm = ShortArray(FRAME_SAMPLES * channels)
|
||||
var readTotal = 0L
|
||||
var read: Int
|
||||
while (playing) {
|
||||
read = NativeLib.voiceDecodeRead(handle, pcm)
|
||||
if (read <= 0) break
|
||||
track.write(pcm, 0, read)
|
||||
readTotal += read
|
||||
if (totalSamples > 0) onProgress(readTotal.toFloat() / totalSamples.toFloat())
|
||||
}
|
||||
track.stop()
|
||||
track.release()
|
||||
NativeLib.voiceDecodeClose(handle)
|
||||
} catch (e: Exception) {
|
||||
LogManager.addLog("ERROR", "AudioPlayer", "play error: ${e.message}")
|
||||
}
|
||||
playing = false
|
||||
onComplete()
|
||||
}.apply { start() }
|
||||
}
|
||||
|
||||
fun stopPlayback() {
|
||||
playing = false
|
||||
playThread?.join(500)
|
||||
playThread = null
|
||||
}
|
||||
}
|
||||
@@ -6,7 +6,8 @@ import org.json.JSONObject
|
||||
|
||||
data class Channel(val id: String, val name: String, val lastMsgAt: Long = 0, val lastMsg: String = "",
|
||||
val peersOnline: Int = 0)
|
||||
data class Message(val id: Long, val author: String, val text: String, val ts: Long, val isOutgoing: Boolean = false)
|
||||
data class Message(val id: Long, val author: String, val text: String, val ts: Long,
|
||||
val isOutgoing: Boolean = false, val contentType: String = "text/plain")
|
||||
|
||||
class ChatRepository {
|
||||
private val myNodeId: Long = NativeLib.getMyNodeId()
|
||||
@@ -51,7 +52,8 @@ class ChatRepository {
|
||||
author = obj.getString("author"),
|
||||
text = obj.getString("text"),
|
||||
ts = obj.getLong("ts"),
|
||||
isOutgoing = obj.optBoolean("isOutgoing", false)
|
||||
isOutgoing = obj.optBoolean("isOutgoing", false),
|
||||
contentType = obj.optString("contentType", "text/plain")
|
||||
))
|
||||
}
|
||||
} catch (e: Exception) { Log.w("utun-gui", "getMessages error", e) }
|
||||
@@ -59,6 +61,7 @@ class ChatRepository {
|
||||
}
|
||||
|
||||
fun sendMessage(channelId: String, text: String) = NativeLib.sendMessage(channelId, text)
|
||||
fun sendAttachment(channelId: String, filePath: String) = NativeLib.attachmentSend(channelId, filePath)
|
||||
fun createChannel(name: String, channelId: String) = NativeLib.createChannel(name, channelId)
|
||||
fun connectNode(address: String, port: Int, pubkeyHex: String) = NativeLib.connectNode(address, port, pubkeyHex)
|
||||
fun joinChannel(channelId: Long, nodeId: Long, pubkey: ByteArray, addrs: ByteArray, addrCount: Int) =
|
||||
|
||||
@@ -77,6 +77,25 @@ object NativeLib {
|
||||
fun setUdpLogTarget(ip: String, port: Int) { nativeSetUdpLogTarget(ip, port) }
|
||||
fun regenerateKeys() { nativeRegenerateKeys() }
|
||||
|
||||
/* ── Voice recording ── */
|
||||
fun voiceStart(channelId: String): Boolean = nativeVoiceStart(channelId)
|
||||
fun voiceFeed(samples: ShortArray): Boolean = nativeVoiceFeed(samples)
|
||||
fun voiceStop(): Int = nativeVoiceStop()
|
||||
fun voiceCancel() { nativeVoiceCancel() }
|
||||
fun voiceIsActive(): Boolean = nativeVoiceIsActive()
|
||||
fun voiceSetPreset(preset: Int) { nativeVoiceSetPreset(preset) }
|
||||
fun voiceGetPreset(): Int = nativeVoiceGetPreset()
|
||||
fun voiceSetCompressor(enabled: Boolean) { nativeVoiceSetCompressor(enabled) }
|
||||
fun voiceGetCompressor(): Boolean = nativeVoiceGetCompressor()
|
||||
|
||||
/* ── Attachment ── */
|
||||
fun attachmentSend(channelId: String, filePath: String): Boolean = nativeAttachmentSend(channelId, filePath)
|
||||
|
||||
/* ── Voice playback ── */
|
||||
fun voiceDecodeOpen(filePath: String, outInfo: IntArray): Long = nativeVoiceDecodeOpen(filePath, outInfo)
|
||||
fun voiceDecodeRead(handle: Long, buf: ShortArray): Int = nativeVoiceDecodeRead(handle, buf)
|
||||
fun voiceDecodeClose(handle: Long) { nativeVoiceDecodeClose(handle) }
|
||||
|
||||
private external fun nativeInit(dbPath: String, controlPort: Int, config: Any, logCb: Any): Boolean
|
||||
private external fun nativeStart(configText: String): Boolean
|
||||
private external fun nativeStop()
|
||||
@@ -98,4 +117,23 @@ object NativeLib {
|
||||
private external fun nativeRestart(configText: String): Boolean
|
||||
private external fun nativePing()
|
||||
private external fun nativeIsResponsive(): Boolean
|
||||
|
||||
/* ── Voice recording JNI ── */
|
||||
private external fun nativeVoiceStart(channelId: String): Boolean
|
||||
private external fun nativeVoiceFeed(samples: ShortArray): Boolean
|
||||
private external fun nativeVoiceStop(): Int
|
||||
private external fun nativeVoiceCancel()
|
||||
private external fun nativeVoiceIsActive(): Boolean
|
||||
private external fun nativeVoiceSetPreset(preset: Int)
|
||||
private external fun nativeVoiceGetPreset(): Int
|
||||
private external fun nativeVoiceSetCompressor(enabled: Boolean)
|
||||
private external fun nativeVoiceGetCompressor(): Boolean
|
||||
|
||||
/* ── Attachment JNI ── */
|
||||
private external fun nativeAttachmentSend(channelId: String, filePath: String): Boolean
|
||||
|
||||
/* ── Voice playback JNI ── */
|
||||
private external fun nativeVoiceDecodeOpen(filePath: String, outInfo: IntArray): Long
|
||||
private external fun nativeVoiceDecodeRead(handle: Long, buf: ShortArray): Int
|
||||
private external fun nativeVoiceDecodeClose(handle: Long)
|
||||
}
|
||||
+119
-11
@@ -1,19 +1,28 @@
|
||||
package com.utun.chat.ui.components
|
||||
|
||||
import android.Manifest
|
||||
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.layout.*
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
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.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 kotlinx.coroutines.delay
|
||||
|
||||
@Composable
|
||||
fun MessageBubble(message: Message) {
|
||||
@@ -22,6 +31,9 @@ fun MessageBubble(message: Message) {
|
||||
val alignment = if (message.isOutgoing) Arrangement.End else Arrangement.Start
|
||||
val minBubbleWidth = (LocalConfiguration.current.screenWidthDp / 3).dp
|
||||
|
||||
val isVoice = message.contentType == "audio/opus"
|
||||
val isMedia = message.contentType.isNotEmpty() && !isVoice && message.contentType != "text/plain"
|
||||
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp, vertical = 2.dp),
|
||||
horizontalArrangement = alignment
|
||||
@@ -35,7 +47,23 @@ fun MessageBubble(message: Message) {
|
||||
) {
|
||||
Text(message.author, fontSize = 13.sp, fontWeight = FontWeight.Bold, color = textColor.copy(alpha = 0.7f))
|
||||
Spacer(Modifier.height(2.dp))
|
||||
Text(message.text, fontSize = 17.sp, color = textColor)
|
||||
|
||||
when {
|
||||
isVoice -> {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text("\u25B6", fontSize = 20.sp, color = textColor)
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Text("Voice message", fontSize = 15.sp, color = textColor)
|
||||
}
|
||||
}
|
||||
isMedia -> {
|
||||
Text("\uD83D\uDCC4 ${message.text}", fontSize = 15.sp, color = textColor)
|
||||
}
|
||||
else -> {
|
||||
Text(message.text, fontSize = 17.sp, color = textColor)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(2.dp))
|
||||
Text(
|
||||
formatTime(message.ts),
|
||||
@@ -53,27 +81,107 @@ fun InputBar(
|
||||
onTextChange: (String) -> Unit,
|
||||
onSend: () -> Unit,
|
||||
enabled: Boolean = true,
|
||||
isRecording: Boolean = false,
|
||||
recordingDurationMs: Int = 0,
|
||||
onPttPressed: () -> Unit = {},
|
||||
onPttReleased: () -> Unit = {},
|
||||
onAttachClicked: () -> Unit = {},
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val ctx = LocalContext.current
|
||||
|
||||
var hasMicPerm by remember {
|
||||
mutableStateOf(
|
||||
ContextCompat.checkSelfPermission(ctx, Manifest.permission.RECORD_AUDIO) ==
|
||||
PackageManager.PERMISSION_GRANTED
|
||||
)
|
||||
}
|
||||
val permLauncher = rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) { granted ->
|
||||
hasMicPerm = granted
|
||||
}
|
||||
|
||||
val filePicker = rememberLauncherForActivityResult(ActivityResultContracts.GetContent()) { uri ->
|
||||
uri?.let { onAttachClicked() }
|
||||
}
|
||||
|
||||
Row(
|
||||
modifier = modifier.fillMaxWidth().padding(8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
OutlinedTextField(
|
||||
value = text,
|
||||
onValueChange = onTextChange,
|
||||
enabled = enabled,
|
||||
modifier = Modifier.weight(1f),
|
||||
placeholder = { Text("Message...") },
|
||||
maxLines = 4
|
||||
)
|
||||
Spacer(Modifier.width(8.dp))
|
||||
/* Attach button */
|
||||
IconButton(onClick = { filePicker.launch("*/*") }, enabled = enabled) {
|
||||
Text("\uD83D\uDCCE", fontSize = 20.sp)
|
||||
}
|
||||
|
||||
if (isRecording) {
|
||||
/* Recording overlay */
|
||||
Surface(
|
||||
modifier = Modifier.weight(1f).height(48.dp),
|
||||
shape = RoundedCornerShape(8.dp),
|
||||
color = Color(0xFFE53935)
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxSize().padding(horizontal = 12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.SpaceBetween
|
||||
) {
|
||||
Text(
|
||||
text = "\uD83D\uDD34 ${formatDuration(recordingDurationMs)}",
|
||||
color = Color.White,
|
||||
fontWeight = FontWeight.Bold,
|
||||
fontSize = 16.sp
|
||||
)
|
||||
/* 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,
|
||||
enabled = enabled,
|
||||
modifier = Modifier.weight(1f),
|
||||
placeholder = { Text("Message...") },
|
||||
maxLines = 4
|
||||
)
|
||||
}
|
||||
|
||||
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 = {
|
||||
onPttPressed()
|
||||
tryAwaitRelease()
|
||||
onPttReleased()
|
||||
}
|
||||
)
|
||||
}
|
||||
) {
|
||||
Text("\uD83C\uDFA4", fontSize = 20.sp)
|
||||
}
|
||||
|
||||
/* Send button */
|
||||
Button(onClick = onSend, enabled = enabled && text.isNotBlank()) {
|
||||
Text("Send")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun formatDuration(ms: Int): String {
|
||||
val secs = ms / 1000
|
||||
return "%d:%02d".format(secs / 60, secs % 60)
|
||||
}
|
||||
|
||||
private fun formatTime(ts: Long): String {
|
||||
if (ts == 0L) return ""
|
||||
val s = ts / 1_000_000_000 // nanoseconds -> seconds
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
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
|
||||
@@ -9,11 +12,14 @@ 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.ui.components.InputBar
|
||||
import com.utun.chat.ui.components.MessageBubble
|
||||
import com.utun.chat.viewmodel.ChatViewModel
|
||||
import kotlinx.coroutines.delay
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
@@ -21,11 +27,21 @@ fun ChatScreen(
|
||||
channel: Channel,
|
||||
messages: List<Message>,
|
||||
connected: Boolean,
|
||||
viewModel: ChatViewModel,
|
||||
onSendMessage: (String) -> Unit,
|
||||
onBack: () -> Unit
|
||||
) {
|
||||
var text by remember { mutableStateOf("") }
|
||||
val listState = rememberLazyListState()
|
||||
val isRecording by viewModel.isRecording.collectAsState()
|
||||
|
||||
val filePicker = rememberLauncherForActivityResult(ActivityResultContracts.GetContent()) { uri ->
|
||||
uri?.let {
|
||||
val ctx = LocalContext.current
|
||||
val path = it.path ?: return@let
|
||||
viewModel.sendAttachment(path)
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(messages.size) {
|
||||
if (messages.isNotEmpty()) listState.animateScrollToItem(messages.size - 1)
|
||||
@@ -57,6 +73,14 @@ fun ChatScreen(
|
||||
text = ""
|
||||
},
|
||||
enabled = true,
|
||||
isRecording = isRecording,
|
||||
recordingDurationMs = 0,
|
||||
onPttPressed = { viewModel.startVoiceRecording() },
|
||||
onPttReleased = {
|
||||
val dur = viewModel.stopVoiceRecording()
|
||||
/* duration handled internally */
|
||||
},
|
||||
onAttachClicked = { filePicker.launch("*/*") },
|
||||
modifier = Modifier.navigationBarsPadding()
|
||||
)
|
||||
}
|
||||
|
||||
@@ -294,6 +294,37 @@ fun SettingsScreen(vm: ChatViewModel, onBack: () -> Unit, onExit: () -> Unit = {
|
||||
|
||||
HorizontalDivider(Modifier.padding(vertical = 8.dp))
|
||||
|
||||
// ── Voice Messages ──
|
||||
Text("Voice Messages", style = MaterialTheme.typography.titleMedium)
|
||||
val voicePreset by remember { mutableStateOf(vm.getVoicePreset()) }
|
||||
var presetExpanded by remember { mutableStateOf(false) }
|
||||
val presets = listOf("Low (16 kbps)", "Standard (32 kbps)", "High (64 kbps)")
|
||||
ExposedDropdownMenuBox(expanded = presetExpanded, onExpandedChange = { presetExpanded = it }) {
|
||||
OutlinedTextField(
|
||||
value = presets[voicePreset], onValueChange = {},
|
||||
readOnly = true, singleLine = true, label = { Text("Opus Preset") },
|
||||
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(presetExpanded) },
|
||||
modifier = Modifier.menuAnchor().heightIn(min = 40.dp)
|
||||
)
|
||||
ExposedDropdownMenu(presetExpanded, onDismissRequest = { presetExpanded = false }) {
|
||||
presets.forEachIndexed { i, name ->
|
||||
DropdownMenuItem(text = { Text(name) }, onClick = {
|
||||
vm.setVoicePreset(i); presetExpanded = false
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
val compressorEnabled = remember { mutableStateOf(vm.getVoiceCompressor()) }
|
||||
Row(verticalAlignment = androidx.compose.ui.Alignment.CenterVertically) {
|
||||
Text("Compressor", modifier = Modifier.weight(1f))
|
||||
Switch(
|
||||
checked = compressorEnabled.value,
|
||||
onCheckedChange = { v -> compressorEnabled.value = v; vm.setVoiceCompressor(v) }
|
||||
)
|
||||
}
|
||||
|
||||
HorizontalDivider(Modifier.padding(vertical = 8.dp))
|
||||
|
||||
// ── Debug ──
|
||||
Text("Debug", style = MaterialTheme.typography.titleMedium)
|
||||
DebugLevelDropdown(label = "Global Level", current = debugLevel.uppercase(), onSelect = setDebug)
|
||||
|
||||
@@ -36,6 +36,11 @@ class ChatViewModel : ViewModel() {
|
||||
private val _isStuck = MutableStateFlow(false)
|
||||
val isStuck: StateFlow<Boolean> = _isStuck
|
||||
|
||||
val audioRecorder = AudioRecorderManager()
|
||||
|
||||
private val _isRecording = MutableStateFlow(false)
|
||||
val isRecording: StateFlow<Boolean> = _isRecording
|
||||
|
||||
init {
|
||||
viewModelScope.launch {
|
||||
AppEventHandler.events.collect { (type, data) -> handleEvent(type, data) }
|
||||
@@ -172,6 +177,48 @@ class ChatViewModel : ViewModel() {
|
||||
r.joinChannel(channelId, nodeId, pubkey, addrs, addrCount)
|
||||
}
|
||||
|
||||
fun startVoiceRecording() {
|
||||
val ch = _currentChannel.value ?: return
|
||||
_isRecording.value = true
|
||||
audioRecorder.startRecording(ch.id)
|
||||
}
|
||||
|
||||
fun stopVoiceRecording(): Int {
|
||||
if (!_isRecording.value) return 0
|
||||
val duration = audioRecorder.stopRecording()
|
||||
_isRecording.value = false
|
||||
val ch = _currentChannel.value
|
||||
if (ch != null) {
|
||||
viewModelScope.launch {
|
||||
delay(500)
|
||||
refreshMessages(ch.id)
|
||||
refreshChannels()
|
||||
}
|
||||
}
|
||||
return duration
|
||||
}
|
||||
|
||||
fun cancelVoiceRecording() {
|
||||
audioRecorder.cancelRecording()
|
||||
_isRecording.value = false
|
||||
}
|
||||
|
||||
fun sendAttachment(filePath: String) {
|
||||
val r = repo ?: return
|
||||
val ch = _currentChannel.value ?: return
|
||||
r.sendAttachment(ch.id, filePath)
|
||||
viewModelScope.launch {
|
||||
delay(500)
|
||||
refreshMessages(ch.id)
|
||||
refreshChannels()
|
||||
}
|
||||
}
|
||||
|
||||
fun getVoicePreset(): Int = NativeLib.voiceGetPreset()
|
||||
fun setVoicePreset(preset: Int) { NativeLib.voiceSetPreset(preset) }
|
||||
fun getVoiceCompressor(): Boolean = NativeLib.voiceGetCompressor()
|
||||
fun setVoiceCompressor(enabled: Boolean) { NativeLib.voiceSetCompressor(enabled) }
|
||||
|
||||
fun clearState() {
|
||||
repo?.close()
|
||||
repo = null
|
||||
|
||||
Reference in new issue
Block a user