Browse Source

chatgui-android: видеосообщения — запись, выбор камеры, качество, подсветка

- C-слой: video_sender (content_type video/mp4, media_video=0 без FFmpeg)
- chat_msg_submit: duration/width/height для метаданных видео в теле сообщения
- JNI: nativeVideoSend + NativeLib/Repository/ViewModel
- VideoRecordScreen: CameraX VideoCapture, 4 пресета качества (разрешение+битрейт),
  выбор камеры (селфи/широкая/обычная/макро/теле по фокусу), тумблер подсветки,
  запись/пауза/отправить, лимит 60с
- InputBar: единая кнопка — короткий тап переключает voice/video, удержание — запись
- режим камеры/микрофона сохраняется в ViewModel
v2
evgeny 3 weeks ago
parent
commit
73e81032bc
  1. 3
      src/chat/chat_core.h
  2. 3
      src/chat/chat_msg.c
  3. 1
      tools/chatgui-android/app/build.gradle.kts
  4. 18
      tools/chatgui-android/app/src/main/java/com/utun/chat/MainActivity.kt
  5. 109
      tools/chatgui-android/app/src/main/java/com/utun/chat/data/CameraLensManager.kt
  6. 2
      tools/chatgui-android/app/src/main/java/com/utun/chat/data/ChatRepository.kt
  7. 7
      tools/chatgui-android/app/src/main/java/com/utun/chat/data/NativeLib.kt
  8. 100
      tools/chatgui-android/app/src/main/java/com/utun/chat/ui/components/MessageBubble.kt
  9. 7
      tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/ChatScreen.kt
  10. 412
      tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/VideoRecordScreen.kt
  11. 33
      tools/chatgui-android/app/src/main/java/com/utun/chat/viewmodel/ChatViewModel.kt
  12. 18
      tools/chatgui-android/jni_bridge/android_jni_bridge.c
  13. 4
      tools/chatgui-android/jni_bridge/android_jni_bridge.h
  14. 1
      tools/chatgui-android/libutun_lite/utun_sources.cmake
  15. 86
      tools/chatgui-android/libutun_lite/video_sender.c
  16. 15
      tools/chatgui-android/libutun_lite/video_sender.h

3
src/chat/chat_core.h

@ -33,6 +33,9 @@ struct chat_msg_submit {
char media_dest[1024];
uint8_t media_copy;
uint8_t media_video; /* 1 = подготовить как видео (пробинг+транскод) */
int duration_ms; /* видео: длительность (0 = неизвестно) */
int width; /* видео: ширина */
int height; /* видео: высота */
uint8_t* data;
uint32_t data_len;
uint64_t timestamp;

3
src/chat/chat_msg.c

@ -269,6 +269,9 @@ void chat_core_submit_media_message(struct chat_msg_submit* req) {
snprintf(mctx->content_type, sizeof(mctx->content_type), "%s", req->content_type);
snprintf(mctx->media_dest, sizeof(mctx->media_dest), "%s", req->media_dest);
mctx->timestamp = req->timestamp;
mctx->duration_ms = req->duration_ms;
mctx->width = req->width;
mctx->height = req->height;
if (req->data && req->data_len > 0) {
mctx->msg_data_len = req->data_len < sizeof(mctx->msg_data) ? req->data_len : sizeof(mctx->msg_data) - 1;
memcpy(mctx->msg_data, req->data, mctx->msg_data_len);

1
tools/chatgui-android/app/build.gradle.kts

@ -73,4 +73,5 @@ dependencies {
implementation("androidx.camera:camera-camera2:1.4.0")
implementation("androidx.camera:camera-lifecycle:1.4.0")
implementation("androidx.camera:camera-view:1.4.0")
implementation("androidx.camera:camera-video:1.4.0")
}

18
tools/chatgui-android/app/src/main/java/com/utun/chat/MainActivity.kt

@ -29,7 +29,7 @@ import com.utun.chat.viewmodel.ChatViewModel
import kotlinx.coroutines.launch
class MainActivity : ComponentActivity() {
enum class Screen { ChannelList, Chat, MemberList, Settings, Logs, QrScan }
enum class Screen { ChannelList, Chat, MemberList, Settings, Logs, QrScan, VideoRecord }
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
@ -78,6 +78,7 @@ class MainActivity : ComponentActivity() {
BackHandler(enabled = screen != Screen.ChannelList) {
when (screen) {
Screen.MemberList -> screen = Screen.Chat
Screen.VideoRecord -> screen = Screen.Chat
else -> screen = Screen.ChannelList
}
}
@ -125,7 +126,8 @@ class MainActivity : ComponentActivity() {
onMembersClick = { screen = Screen.MemberList },
onInvite = { showInvite(channel) },
onBack = { screen = Screen.ChannelList },
onPlayVideo = { msg -> videoMessage = msg }
onPlayVideo = { msg -> videoMessage = msg },
onRecordVideo = { screen = Screen.VideoRecord }
)
}
}
@ -154,6 +156,18 @@ class MainActivity : ComponentActivity() {
onBack = { screen = Screen.ChannelList }
)
}
Screen.VideoRecord -> {
val channel = vm.currentChannel.collectAsState().value
if (channel != null) {
VideoRecordScreen(
onSend = { path ->
vm.sendVideoMessage(path)
screen = Screen.Chat
},
onClose = { screen = Screen.Chat }
)
}
}
}
SnackbarHost(snackbarHostState, Modifier.align(Alignment.BottomCenter).navigationBarsPadding())

109
tools/chatgui-android/app/src/main/java/com/utun/chat/data/CameraLensManager.kt

@ -0,0 +1,109 @@
package com.utun.chat.data
import android.hardware.camera2.CameraCharacteristics
import android.hardware.camera2.CameraMetadata
import androidx.camera.camera2.interop.Camera2CameraInfo
import androidx.camera.core.CameraSelector
import androidx.camera.lifecycle.ProcessCameraProvider
import kotlin.math.abs
import kotlin.math.round
import kotlin.math.sqrt
enum class LensType { SELFIE, MAIN, ULTRA_WIDE, MACRO, TELE }
data class LensOption(
val type: LensType,
val label: String,
val mm: String,
val cameraId: String,
val eqFocal: Float
) {
fun selector(): CameraSelector = CameraSelector.Builder()
.addCameraFilter { infos -> infos.filter { Camera2CameraInfo.from(it).cameraId == cameraId } }
.build()
}
object CameraLensManager {
private const val MAIN_REF_FOCAL = 26f
private data class BackLens(
val cameraId: String,
val eqFocal: Float,
val fixedFocus: Boolean
)
fun enumerate(provider: ProcessCameraProvider): List<LensOption> {
val result = mutableListOf<LensOption>()
val backs = mutableListOf<BackLens>()
for (info in provider.availableCameraInfos) {
val c2 = Camera2CameraInfo.from(info)
val cameraId = c2.cameraId
val facing = c2.getCameraCharacteristic(CameraCharacteristics.LENS_FACING)
?: CameraMetadata.LENS_FACING_BACK
if (facing == CameraMetadata.LENS_FACING_FRONT) {
val eq = eqFocal35(c2)
result.add(LensOption(LensType.SELFIE, "Селфи", mmLabel(eq), cameraId, eq))
continue
}
if (facing != CameraMetadata.LENS_FACING_BACK) continue
val eq = eqFocal35(c2)
val minFocus = c2.getCameraCharacteristic(CameraCharacteristics.LENS_INFO_MINIMUM_FOCUS_DISTANCE) ?: 0f
val fixedFocus = minFocus == 0f
backs.add(BackLens(cameraId, eq, fixedFocus))
LogManager.addLog("INFO", "Lens", "back cam id=$cameraId eq=${"%.1f".format(eq)} fixedFocus=$fixedFocus")
}
if (backs.isEmpty()) {
LogManager.addLog("WARN", "Lens", "no back cameras found")
return result
}
/* main = камера с автофокусом, ближайшая к 26мм; fallback — ближайшая к 26мм */
val afBacks = backs.filter { !it.fixedFocus }
val main = (if (afBacks.isNotEmpty()) afBacks else backs)
.minByOrNull { abs(it.eqFocal - MAIN_REF_FOCAL) } ?: backs[0]
fun typeOf(l: BackLens): LensType = when {
l === main -> LensType.MAIN
main.eqFocal <= 0f -> LensType.MAIN
l.eqFocal < main.eqFocal * 0.7f -> LensType.ULTRA_WIDE
l.eqFocal > main.eqFocal * 1.4f -> LensType.TELE
l.fixedFocus -> LensType.MACRO
else -> LensType.MAIN
}
fun typeLabel(type: LensType): String = when (type) {
LensType.SELFIE -> "Селфи"
LensType.MAIN -> "Обычная"
LensType.ULTRA_WIDE -> "Широкая"
LensType.MACRO -> "Макро"
LensType.TELE -> "Теле"
}
val backOptions = backs.sortedBy { it.eqFocal }.map { l ->
val type = typeOf(l)
LensOption(type, typeLabel(type), mmLabel(l.eqFocal), l.cameraId, l.eqFocal)
}
result.addAll(backOptions)
LogManager.addLog("INFO", "Lens", "enumerated: ${result.joinToString { "${it.label} ${it.mm}".trim() }}")
return result
}
private fun mmLabel(eqFocal: Float): String =
if (eqFocal > 0f) "${round(eqFocal).toInt()}мм" else ""
private fun eqFocal35(c2: Camera2CameraInfo): Float {
val focal = (c2.getCameraCharacteristic(CameraCharacteristics.LENS_INFO_AVAILABLE_FOCAL_LENGTHS)
?: floatArrayOf()).firstOrNull() ?: 0f
val sensor = c2.getCameraCharacteristic(CameraCharacteristics.SENSOR_INFO_PHYSICAL_SIZE)
if (focal <= 0f || sensor == null || sensor.width <= 0f) return focal
val diag = sqrt(sensor.width * sensor.width + sensor.height * sensor.height)
if (diag <= 0f) return focal
return focal * 43.27f / diag
}
}

2
tools/chatgui-android/app/src/main/java/com/utun/chat/data/ChatRepository.kt

@ -341,6 +341,8 @@ class ChatRepository {
fun sendMessage(channelId: String, text: String) = NativeLib.sendMessage(channelId, text)
fun sendAttachment(channelId: String, filePath: String) = NativeLib.attachmentSend(channelId, filePath)
fun sendVideo(channelId: String, filePath: String, durationMs: Int, width: Int, height: Int) =
NativeLib.videoSend(channelId, filePath, durationMs, width, height)
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) =

7
tools/chatgui-android/app/src/main/java/com/utun/chat/data/NativeLib.kt

@ -119,6 +119,10 @@ object NativeLib {
fun attachmentSend(channelId: String, filePath: String): Boolean = nativeAttachmentSend(channelId, filePath)
fun attachmentDownload(channelId: String, msgId: Long): Boolean = nativeAttachmentDownload(channelId, msgId)
/* ── Video message ── */
fun videoSend(channelId: String, filePath: String, durationMs: Int, width: Int, height: Int): Boolean =
nativeVideoSend(channelId, filePath, durationMs, width, height)
/* ── Voice playback ── */
fun voiceDecodeOpen(filePath: String, outInfo: IntArray): Long = nativeVoiceDecodeOpen(filePath, outInfo)
fun voiceDecodeRead(handle: Long, buf: ShortArray): Int = nativeVoiceDecodeRead(handle, buf)
@ -187,6 +191,9 @@ object NativeLib {
private external fun nativeAttachmentSend(channelId: String, filePath: String): Boolean
private external fun nativeAttachmentDownload(channelId: String, msgId: Long): Boolean
/* ── Video message JNI ── */
private external fun nativeVideoSend(channelId: String, filePath: String, durationMs: Int, width: Int, height: Int): Boolean
/* ── Voice playback JNI ── */
private external fun nativeVoiceDecodeOpen(filePath: String, outInfo: IntArray): Long
private external fun nativeVoiceDecodeRead(handle: Long, buf: ShortArray): Int

100
tools/chatgui-android/app/src/main/java/com/utun/chat/ui/components/MessageBubble.kt

@ -24,6 +24,7 @@ import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.foundation.gestures.awaitFirstDown
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalConfiguration
@ -39,6 +40,7 @@ import com.utun.chat.viewmodel.ChatViewModel
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.withContext
import kotlinx.coroutines.withTimeoutOrNull
@Composable
fun MessageBubble(message: Message, playbackState: ChatViewModel.PlaybackState = ChatViewModel.PlaybackState(),
@ -352,6 +354,7 @@ private fun formatDurationMs(ms: Int): String {
}
private val LOCK_SWIPE_THRESHOLD_DP = 80f
private const val HOLD_THRESHOLD_MS = 250L
@Composable
fun InputBar(
@ -372,6 +375,9 @@ fun InputBar(
pendingAttachmentName: String = "",
pendingAttachmentSize: Long = 0L,
onClearAttachment: () -> Unit = {},
isVideoMode: Boolean = false,
onToggleMode: () -> Unit = {},
onVideoRecordOpen: () -> Unit = {},
modifier: Modifier = Modifier
) {
val ctx = LocalContext.current
@ -500,49 +506,85 @@ fun InputBar(
Text("\u27A4", fontSize = 22.sp, color = Color.White)
}
} else {
/* PTT Box — podcast/recording, same identity in idle and holding */
/* Unified mode button — short tap toggles voice/video, hold activates */
val pttBg = if (isRecording) Color(0xFFE53935) else Color.Transparent
Box(
modifier = Modifier
.size(48.dp)
.background(pttBg, RoundedCornerShape(4.dp))
.then(if (!hasMicPerm) {
Modifier.clickable { permLauncher.launch(Manifest.permission.RECORD_AUDIO) }
} else {
Modifier.pointerInput(Unit) {
awaitPointerEventScope {
while (true) {
val down = awaitPointerEvent()
val ch = down.changes.firstOrNull() ?: continue
if (!ch.pressed) continue
ch.consume()
.pointerInput(isVideoMode, hasMicPerm) {
awaitPointerEventScope {
while (true) {
val down = awaitFirstDown(requireUnconsumed = false)
val downId = down.id
down.consume()
/* wait: release before threshold = tap, else hold */
var released = false
withTimeoutOrNull(HOLD_THRESHOLD_MS) {
while (true) {
val ev = awaitPointerEvent()
val c = ev.changes.firstOrNull { it.id == downId } ?: return@withTimeoutOrNull
if (!c.pressed) { released = true; return@withTimeoutOrNull }
c.consume()
}
}
onPttPressed()
var locked = false
var totalDragY = 0f
var lastY = ch.position.y
if (released) {
onToggleMode()
continue
}
if (isVideoMode) {
onVideoRecordOpen()
while (true) {
val event = awaitPointerEvent()
val 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
}
val ev = awaitPointerEvent()
val c = ev.changes.firstOrNull { it.id == downId } ?: break
c.consume()
if (!c.pressed) break
}
onPttReleased()
continue
}
if (!hasMicPerm) {
permLauncher.launch(Manifest.permission.RECORD_AUDIO)
while (true) {
val ev = awaitPointerEvent()
val c = ev.changes.firstOrNull { it.id == downId } ?: break
c.consume()
if (!c.pressed) break
}
continue
}
onPttPressed()
var locked = false
var totalDragY = 0f
var lastY = down.position.y
while (true) {
val ev = awaitPointerEvent()
val c = ev.changes.firstOrNull { it.id == downId } ?: break
if (c.pressed) {
val dy = c.position.y - lastY
lastY = c.position.y
totalDragY += dy
if (totalDragY < -lockThresholdPx && !locked) { locked = true; onPttLock() }
c.consume()
} else {
c.consume(); break
}
}
onPttReleased()
}
}
}),
},
contentAlignment = Alignment.Center
) {
Text("\uD83C\uDFA4", fontSize = 20.sp, color = if (isRecording) Color.White else Color.Unspecified)
Text(
if (isVideoMode) "\uD83D\uDCF7" else "\uD83C\uDFA4",
fontSize = 20.sp,
color = if (isRecording) Color.White else Color.Unspecified
)
}
}
}

7
tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/ChatScreen.kt

@ -40,9 +40,11 @@ fun ChatScreen(
onMembersClick: () -> Unit,
onInvite: () -> Unit,
onBack: () -> Unit,
onPlayVideo: (Message) -> Unit = {}
onPlayVideo: (Message) -> Unit = {},
onRecordVideo: () -> Unit = {}
) {
var text by remember { mutableStateOf("") }
val isVideoMode by viewModel.isVideoMode.collectAsState()
val listState = rememberLazyListState()
val isRecording by viewModel.isRecording.collectAsState()
val isRecordingLocked by viewModel.isRecordingLocked.collectAsState()
@ -183,6 +185,9 @@ fun ChatScreen(
pendingAttachName = ""
pendingAttachSize = 0L
},
isVideoMode = isVideoMode,
onToggleMode = { viewModel.toggleVideoMode() },
onVideoRecordOpen = onRecordVideo,
modifier = Modifier.navigationBarsPadding()
)
}

412
tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/VideoRecordScreen.kt

@ -0,0 +1,412 @@
package com.utun.chat.ui.screens
import android.Manifest
import android.content.pm.PackageManager
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.camera.core.Camera
import androidx.camera.core.CameraSelector
import androidx.camera.core.Preview
import androidx.camera.lifecycle.ProcessCameraProvider
import androidx.camera.video.FileOutputOptions
import androidx.camera.video.Quality
import androidx.camera.video.QualitySelector
import androidx.camera.video.Recorder
import androidx.camera.video.Recording
import androidx.camera.video.VideoCapture
import androidx.camera.video.VideoRecordEvent
import androidx.camera.view.PreviewView
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.Send
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.FlashOff
import androidx.compose.material.icons.filled.FlashOn
import androidx.compose.material.icons.filled.Pause
import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalLifecycleOwner
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.viewinterop.AndroidView
import androidx.core.content.ContextCompat
import com.utun.chat.data.CameraLensManager
import com.utun.chat.data.LensOption
import com.utun.chat.data.LogManager
import kotlinx.coroutines.delay
import kotlinx.coroutines.isActive
import java.io.File
import java.util.concurrent.Executors
private const val MAX_VIDEO_MS = 60_000
private enum class Phase { IDLE, RECORDING, PAUSED, STOPPED }
private enum class VideoQuality(val label: String, val quality: Quality, val bitrate: Int) {
P480_512K("480p 512k", Quality.SD, 512_000),
P480_1M("480p 1M", Quality.SD, 1_000_000),
P720_2M("720p 2M", Quality.HD, 2_000_000),
P1080_45M("1080p 4.5M", Quality.FHD, 4_500_000);
companion object { val DEFAULT = P720_2M }
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun VideoRecordScreen(
onSend: (String) -> Unit,
onClose: () -> Unit
) {
val context = LocalContext.current
val lifecycleOwner = LocalLifecycleOwner.current
val cameraExecutor = remember { Executors.newSingleThreadExecutor() }
var hasCamera by remember {
mutableStateOf(
ContextCompat.checkSelfPermission(context, Manifest.permission.CAMERA) == PackageManager.PERMISSION_GRANTED
)
}
var hasAudio by remember {
mutableStateOf(
ContextCompat.checkSelfPermission(context, Manifest.permission.RECORD_AUDIO) == PackageManager.PERMISSION_GRANTED
)
}
val camLauncher = rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) { hasCamera = it }
val audLauncher = rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) { hasAudio = it }
LaunchedEffect(Unit) { if (!hasCamera) camLauncher.launch(Manifest.permission.CAMERA) }
LaunchedEffect(hasCamera) { if (hasCamera && !hasAudio) audLauncher.launch(Manifest.permission.RECORD_AUDIO) }
val providerRef = remember { mutableStateOf<ProcessCameraProvider?>(null) }
var lenses by remember { mutableStateOf<List<LensOption>>(emptyList()) }
var selectedLens by remember { mutableStateOf<LensOption?>(null) }
var quality by remember { mutableStateOf(VideoQuality.DEFAULT) }
val recorder = remember(quality) {
Recorder.Builder()
.setQualitySelector(QualitySelector.from(quality.quality))
.setTargetVideoEncodingBitRate(quality.bitrate)
.build()
}
val videoCapture = remember(quality) { VideoCapture.withOutput(recorder) }
val preview = remember { Preview.Builder().build() }
val currentPreview by rememberUpdatedState(preview)
val currentVideoCapture by rememberUpdatedState(videoCapture)
var phase by remember { mutableStateOf(Phase.IDLE) }
var recording by remember { mutableStateOf<Recording?>(null) }
var outputFile by remember { mutableStateOf<File?>(null) }
var elapsedMs by remember { mutableStateOf(0) }
var pendingSend by remember { mutableStateOf(false) }
var sendRequested by remember { mutableStateOf(false) }
var boundCamera by remember { mutableStateOf<Camera?>(null) }
var torchOn by remember { mutableStateOf(false) }
fun bindCamera(selector: CameraSelector) {
val provider = providerRef.value ?: return
try {
provider.unbindAll()
boundCamera = provider.bindToLifecycle(lifecycleOwner, selector, currentPreview, currentVideoCapture)
torchOn = false
LogManager.addLog("INFO", "VideoRecord", "camera bound")
} catch (e: Exception) {
LogManager.addLog("ERROR", "VideoRecord", "bind failed: ${e.message}")
}
}
fun startRecording() {
val file = File(context.cacheDir, "video_msg_${System.currentTimeMillis()}.mp4")
outputFile = file
elapsedMs = 0
try {
val pending = recorder.prepareRecording(context, FileOutputOptions.Builder(file).build())
.withAudioEnabled()
recording = pending.start(ContextCompat.getMainExecutor(context)) { event ->
when (event) {
is VideoRecordEvent.Start -> { phase = Phase.RECORDING }
is VideoRecordEvent.Finalize -> {
recording = null
if (event.hasError()) {
LogManager.addLog("ERROR", "VideoRecord", "finalize error: ${event.error}")
file.delete()
phase = Phase.IDLE
} else {
LogManager.addLog("INFO", "VideoRecord", "finalized: ${file.absolutePath} (${file.length()} bytes)")
phase = Phase.STOPPED
if (pendingSend) { pendingSend = false; sendRequested = true }
}
}
else -> {}
}
}
LogManager.addLog("INFO", "VideoRecord", "recording started -> ${file.absolutePath}")
} catch (e: Exception) {
LogManager.addLog("ERROR", "VideoRecord", "start failed: ${e.message}")
phase = Phase.IDLE
}
}
fun discardAndReset() {
recording?.let {
try { it.close() } catch (_: Exception) {}
}
recording = null
outputFile?.delete()
outputFile = null
elapsedMs = 0
pendingSend = false
phase = Phase.IDLE
LogManager.addLog("INFO", "VideoRecord", "discarded")
}
/* timer + 60s auto-stop */
LaunchedEffect(phase) {
while (phase == Phase.RECORDING && isActive) {
delay(100)
elapsedMs += 100
if (elapsedMs >= MAX_VIDEO_MS) {
LogManager.addLog("INFO", "VideoRecord", "auto-stop at ${MAX_VIDEO_MS}ms")
recording?.stop()
break
}
}
}
/* send after finalize */
LaunchedEffect(sendRequested) {
if (sendRequested) {
sendRequested = false
val file = outputFile
if (file != null && file.length() > 0) onSend(file.absolutePath) else onClose()
}
}
/* rebind camera after quality change (recorder/videoCapture are rebuilt) */
LaunchedEffect(quality) {
if (providerRef.value != null) {
selectedLens?.let { bindCamera(it.selector()) }
LogManager.addLog("INFO", "VideoRecord", "quality switched to ${quality.label} ${quality.bitrate / 1000}k")
}
}
/* init provider + enumerate lenses + bind front */
DisposableEffect(hasCamera && hasAudio) {
if (hasCamera && hasAudio) {
val future = ProcessCameraProvider.getInstance(context)
future.addListener({
try {
val provider = future.get()
providerRef.value = provider
val list = CameraLensManager.enumerate(provider)
lenses = list
val default = list.firstOrNull { it.type.name == "SELFIE" } ?: list.firstOrNull()
if (default != null) {
selectedLens = default
bindCamera(default.selector())
}
} catch (e: Exception) {
LogManager.addLog("ERROR", "VideoRecord", "provider init failed: ${e.message}")
}
}, ContextCompat.getMainExecutor(context))
}
onDispose {
providerRef.value?.unbindAll()
providerRef.value = null
cameraExecutor.shutdown()
}
}
Scaffold(
topBar = {
TopAppBar(
title = { Text("Видеосообщение") },
navigationIcon = {
IconButton(onClick = { discardAndReset(); onClose() }) { Icon(Icons.Filled.Close, "Закрыть") }
},
actions = {
if (boundCamera?.cameraInfo?.hasFlashUnit() == true) {
IconButton(onClick = {
val next = !torchOn
try { boundCamera?.cameraControl?.enableTorch(next) } catch (_: Exception) {}
torchOn = next
}) {
Icon(if (torchOn) Icons.Filled.FlashOn else Icons.Filled.FlashOff, "Подсветка", tint = if (torchOn) Color(0xFFFFD600) else Color.White)
}
}
}
)
}
) { padding ->
Box(Modifier.fillMaxSize().background(Color.Black).padding(padding)) {
if (!hasCamera || !hasAudio) {
Column(Modifier.align(Alignment.Center), horizontalAlignment = Alignment.CenterHorizontally) {
Text("Нужны разрешения камеры и микрофона", color = Color.White, fontSize = 16.sp)
Spacer(Modifier.height(12.dp))
Row {
if (!hasCamera) Button(onClick = { camLauncher.launch(Manifest.permission.CAMERA) }) { Text("Камера") }
if (hasCamera && !hasAudio) Button(onClick = { audLauncher.launch(Manifest.permission.RECORD_AUDIO) }) { Text("Микрофон") }
}
}
} else {
AndroidView(
factory = { ctx ->
PreviewView(ctx).also { pv -> preview.surfaceProvider = pv.surfaceProvider }
},
modifier = Modifier.fillMaxSize()
)
/* lens + quality chips */
if (phase == Phase.IDLE) {
Column(
modifier = Modifier.align(Alignment.TopCenter).padding(top = 8.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
lenses.forEach { lens ->
val selected = selectedLens?.cameraId == lens.cameraId
Surface(
shape = RoundedCornerShape(16.dp),
color = if (selected) Color(0xFFFFD600) else Color(0x66000000),
modifier = Modifier.clickable {
selectedLens = lens
bindCamera(lens.selector())
}
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp)
) {
Text(
lens.label,
color = if (selected) Color.Black else Color.White,
fontSize = 13.sp
)
if (lens.mm.isNotEmpty()) {
Text(
lens.mm,
color = (if (selected) Color.Black else Color.White).copy(alpha = 0.7f),
fontSize = 11.sp
)
}
}
}
}
}
Spacer(Modifier.height(8.dp))
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
VideoQuality.values().forEach { q ->
val selected = q == quality
Surface(
shape = RoundedCornerShape(16.dp),
color = if (selected) Color(0xFFFFD600) else Color(0x66000000),
modifier = Modifier.clickable { quality = q }
) {
Text(
q.label,
color = if (selected) Color.Black else Color.White,
fontSize = 13.sp,
modifier = Modifier.padding(horizontal = 12.dp, vertical = 6.dp)
)
}
}
}
}
}
/* bottom controls */
Column(
modifier = Modifier.align(Alignment.BottomCenter).fillMaxWidth()
.navigationBarsPadding().padding(16.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
Text(
when (phase) {
Phase.RECORDING -> "\uD83D\uDD34 ${formatDuration(elapsedMs)}"
Phase.PAUSED -> "\u23F8 ${formatDuration(elapsedMs)}"
Phase.STOPPED -> "\u2705 ${formatDuration(elapsedMs)}"
Phase.IDLE -> "0:00"
},
color = Color.White, fontSize = 16.sp, fontWeight = androidx.compose.ui.text.font.FontWeight.Bold
)
Spacer(Modifier.height(16.dp))
Row(verticalAlignment = Alignment.CenterVertically) {
/* discard / close */
Box(
modifier = Modifier.size(52.dp)
.background(Color(0x66000000), CircleShape)
.clickable { if (phase == Phase.IDLE) onClose() else discardAndReset() },
contentAlignment = Alignment.Center
) {
Icon(Icons.Filled.Delete, if (phase == Phase.IDLE) "Закрыть" else "Удалить", tint = Color.White, modifier = Modifier.size(26.dp))
}
Spacer(Modifier.width(28.dp))
/* record/pause/resume */
Box(
modifier = Modifier.size(76.dp)
.background(
when (phase) { Phase.RECORDING -> Color(0xFFE53935); else -> Color(0xFFE53935) },
CircleShape
)
.clickable {
when (phase) {
Phase.IDLE -> startRecording()
Phase.RECORDING -> { recording?.pause(); phase = Phase.PAUSED }
Phase.PAUSED -> { recording?.resume(); phase = Phase.RECORDING }
Phase.STOPPED -> discardAndReset()
}
},
contentAlignment = Alignment.Center
) {
val icon = when (phase) {
Phase.RECORDING -> Icons.Filled.Pause
Phase.PAUSED -> Icons.Filled.PlayArrow
Phase.STOPPED -> Icons.Filled.Close
Phase.IDLE -> Icons.Filled.PlayArrow
}
if (phase == Phase.IDLE || phase == Phase.STOPPED) {
Box(Modifier.size(30.dp).background(Color.White, CircleShape))
} else {
Icon(icon, contentDescription = null, tint = Color.White, modifier = Modifier.size(34.dp))
}
}
Spacer(Modifier.width(28.dp))
/* send */
val canSend = (phase == Phase.RECORDING || phase == Phase.PAUSED || phase == Phase.STOPPED)
Box(
modifier = Modifier.size(52.dp)
.background(if (canSend) Color(0xFF4CAF50) else Color(0x33000000), CircleShape)
.clickable(enabled = canSend) {
if (phase == Phase.STOPPED) {
outputFile?.let { onSend(it.absolutePath) }
} else {
pendingSend = true
recording?.stop()
}
},
contentAlignment = Alignment.Center
) {
Icon(Icons.AutoMirrored.Filled.Send, "Отправить", tint = Color.White, modifier = Modifier.size(26.dp))
}
}
}
}
}
}
}
private fun formatDuration(ms: Int): String {
val secs = (ms.coerceAtLeast(0)) / 1000
return "%d:%02d".format(secs / 60, secs % 60)
}

33
tools/chatgui-android/app/src/main/java/com/utun/chat/viewmodel/ChatViewModel.kt

@ -1,6 +1,7 @@
package com.utun.chat.viewmodel
import android.content.Context
import android.media.MediaMetadataRetriever
import android.net.Uri
import android.util.Log
import androidx.lifecycle.ViewModel
@ -94,6 +95,11 @@ class ChatViewModel : ViewModel() {
private val _recordingDurationMs = MutableStateFlow(0)
val recordingDurationMs: StateFlow<Int> = _recordingDurationMs
private val _isVideoMode = MutableStateFlow(false)
val isVideoMode: StateFlow<Boolean> = _isVideoMode
fun toggleVideoMode() { _isVideoMode.value = !_isVideoMode.value }
private var recordingStartTimeMs = 0L
@Volatile private var realPlaybackElapsedMs = 0
@ -610,6 +616,33 @@ class ChatViewModel : ViewModel() {
}
}
fun sendVideoMessage(filePath: String) {
val r = repo ?: return
val ch = _currentChannel.value ?: return
viewModelScope.launch {
val meta = withContext(Dispatchers.IO) {
var dur = 0; var w = 0; var h = 0
val mmr = MediaMetadataRetriever()
try {
mmr.setDataSource(filePath)
dur = mmr.extractMetadata(MediaMetadataRetriever.METADATA_KEY_DURATION)?.toIntOrNull() ?: 0
w = mmr.extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_WIDTH)?.toIntOrNull() ?: 0
h = mmr.extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_HEIGHT)?.toIntOrNull() ?: 0
} catch (e: Exception) {
LogManager.addLog("WARN", "VM", "video metadata failed: ${e.message}")
} finally {
try { mmr.release() } catch (_: Exception) {}
}
Triple(dur, w, h)
}
LogManager.addLog("INFO", "VM", "sendVideoMessage ch=${ch.id} dur=${meta.first} ${meta.second}x${meta.third}")
r.sendVideo(ch.id, filePath, meta.first, meta.second, meta.third)
delay(500)
refreshMessages(ch.id)
refreshChannels()
}
}
fun downloadAttachment(msg: Message) {
val ch = _currentChannel.value ?: return
NativeLib.attachmentDownload(ch.id, msg.id)

18
tools/chatgui-android/jni_bridge/android_jni_bridge.c

@ -13,6 +13,7 @@
#include "../libutun_lite/instance_lite.h"
#include "../libutun_lite/voice_recorder.h"
#include "../libutun_lite/attachment_sender.h"
#include "../libutun_lite/video_sender.h"
#include "../libutun_lite/standby.h"
#include "../../../lib/debug_config.h"
#include "../../../lib/u_async.h"
@ -1087,6 +1088,11 @@ int utun_bridge_attachment_download(const char* channel_id, int64_t msg_id) {
return 0;
}
int utun_bridge_video_send(const char* channel_id, const char* file_path, const char* db_path,
int duration_ms, int width, int height) {
return video_send(channel_id, file_path, db_path, duration_ms, width, height);
}
/* ──────────────────────────────────────────────────────────────────
* Voice playback (Opus decode)
* ────────────────────────────────────────────────────────────────── */
@ -1700,6 +1706,18 @@ JNIEXPORT jboolean JNICALL Java_com_utun_chat_data_NativeLib_nativeAttachmentDow
return (rc == 0) ? JNI_TRUE : JNI_FALSE;
}
JNIEXPORT jboolean JNICALL Java_com_utun_chat_data_NativeLib_nativeVideoSend(
JNIEnv* env, jobject thiz, jstring channelId, jstring filePath,
jint durationMs, jint width, jint height) {
(void)thiz;
const char* ch = (*env)->GetStringUTFChars(env, channelId, NULL);
const char* fp = (*env)->GetStringUTFChars(env, filePath, NULL);
int rc = utun_bridge_video_send(ch, fp, g_db_path, (int)durationMs, (int)width, (int)height);
if (fp) (*env)->ReleaseStringUTFChars(env, filePath, fp);
if (ch) (*env)->ReleaseStringUTFChars(env, channelId, ch);
return (rc == 0) ? JNI_TRUE : JNI_FALSE;
}
/* ── Voice playback JNI ── */
JNIEXPORT jlong JNICALL Java_com_utun_chat_data_NativeLib_nativeVoiceDecodeOpen(

4
tools/chatgui-android/jni_bridge/android_jni_bridge.h

@ -95,6 +95,10 @@ float utun_bridge_voice_get_peak_level(void);
int utun_bridge_attachment_send(const char* channel_id, const char* file_path, const char* db_path);
int utun_bridge_attachment_download(const char* channel_id, int64_t msg_id);
/* Отправка видеосообщения (src — готовый mp4, без транскода) */
int utun_bridge_video_send(const char* channel_id, const char* file_path, const char* db_path,
int duration_ms, int width, int height);
/* ── Voice playback (Opus decode) ── */
void* utun_bridge_voice_decode_open(const char* file_path,

1
tools/chatgui-android/libutun_lite/utun_sources.cmake

@ -37,6 +37,7 @@ function(utun_setup_sources LIB_DIR SRC_DIR CONFIG_DIR TOOLS_DIR)
"${CONFIG_DIR}/instance_lite.c"
"${CONFIG_DIR}/voice_recorder.c"
"${CONFIG_DIR}/attachment_sender.c"
"${CONFIG_DIR}/video_sender.c"
"${CONFIG_DIR}/standby.c"
)

86
tools/chatgui-android/libutun_lite/video_sender.c

@ -0,0 +1,86 @@
#include "video_sender.h"
#include "instance_lite.h"
#include "../../../lib/debug_config.h"
#include "../../../lib/mem.h"
#include "../../../lib/u_async.h"
#include "../../../src/chat/chat_core.h"
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <time.h>
#include <sys/stat.h>
#include <unistd.h>
#include <libgen.h>
int video_send(const char* channel_id, const char* src_file_path, const char* db_path,
int duration_ms, int width, int height) {
if (!channel_id || !src_file_path || !db_path) {
DEBUG_ERROR(DEBUG_CATEGORY_GENERAL, "video_send: invalid args");
return -1;
}
struct UASYNC* ua = instance_lite_get_uasync();
if (!ua) { DEBUG_ERROR(DEBUG_CATEGORY_GENERAL, "video_send: no uasync"); return -1; }
struct stat st; memset(&st, 0, sizeof(st));
if (stat(src_file_path, &st) != 0 || st.st_size <= 0) {
DEBUG_ERROR(DEBUG_CATEGORY_GENERAL, "video_send: source not found or empty: %s", src_file_path);
return -1;
}
char path_copy[1024];
snprintf(path_copy, sizeof(path_copy), "%s", src_file_path);
char* fname = basename(path_copy);
char original_basename[256];
snprintf(original_basename, sizeof(original_basename), "%s", fname);
char* dot = strrchr(fname, '.');
if (dot) *dot = '\0';
time_t t = time(NULL);
struct tm tm_buf; localtime_r(&t, &tm_buf);
char dt[32]; snprintf(dt, sizeof(dt), "%04d%02d%02d-%02d%02d%02d",
tm_buf.tm_year + 1900, tm_buf.tm_mon + 1, tm_buf.tm_mday,
tm_buf.tm_hour, tm_buf.tm_min, tm_buf.tm_sec);
int rnd = rand() & 0xFFFF;
char suffix[8]; snprintf(suffix, sizeof(suffix), "%04x", rnd);
/* ensure media dir exists */
char media_dir[1024];
snprintf(media_dir, sizeof(media_dir), "%s/media/%s", db_path, channel_id);
{
char tmp[1024]; size_t off = 0;
for (size_t i = 0; media_dir[i] && off < sizeof(tmp) - 1; i++) {
tmp[off++] = media_dir[i];
if (media_dir[i] == '/' && off > 1) { tmp[off] = '\0'; mkdir(tmp, 0755); }
}
mkdir(tmp, 0755);
}
char media_file[1024];
snprintf(media_file, sizeof(media_file), "%s/%s_%s_%s.mp4", media_dir, dt, fname, suffix);
size_t datalen = strlen(original_basename);
struct chat_msg_submit* req = u_calloc(1, sizeof(struct chat_msg_submit) + datalen + 1);
if (!req) return -1;
snprintf(req->channel_id, sizeof(req->channel_id), "%s", channel_id);
snprintf(req->content_type, sizeof(req->content_type), "video/mp4");
snprintf(req->media_src, sizeof(req->media_src), "%s", src_file_path);
snprintf(req->media_dest, sizeof(req->media_dest), "%s", media_file);
req->media_copy = 1;
req->media_video = 0;
req->duration_ms = duration_ms;
req->width = width;
req->height = height;
req->data = (uint8_t*)(req + 1);
memcpy(req->data, original_basename, datalen);
req->data_len = (uint32_t)datalen;
req->timestamp = 0;
DEBUG_INFO(DEBUG_CATEGORY_GENERAL, "video_send: ch=%s src=%s dst=%s name=%s size=%lld dur=%d %dx%d",
channel_id, src_file_path, media_file, original_basename, (long long)st.st_size,
duration_ms, width, height);
uasync_post(ua, chat_core_submit_trampoline, req);
return 0;
}

15
tools/chatgui-android/libutun_lite/video_sender.h

@ -0,0 +1,15 @@
#ifndef VIDEO_SENDER_H
#define VIDEO_SENDER_H
#ifdef __cplusplus
extern "C" {
#endif
int video_send(const char* channel_id, const char* src_file_path, const char* db_path,
int duration_ms, int width, int height);
#ifdef __cplusplus
}
#endif
#endif
Loading…
Cancel
Save