From 73e81032bccadd446cd357474a33bac73a171f47 Mon Sep 17 00:00:00 2001 From: evgeny Date: Tue, 8 Sep 2026 18:38:18 +0300 Subject: [PATCH] =?UTF-8?q?chatgui-android:=20=D0=B2=D0=B8=D0=B4=D0=B5?= =?UTF-8?q?=D0=BE=D1=81=D0=BE=D0=BE=D0=B1=D1=89=D0=B5=D0=BD=D0=B8=D1=8F=20?= =?UTF-8?q?=E2=80=94=20=D0=B7=D0=B0=D0=BF=D0=B8=D1=81=D1=8C,=20=D0=B2?= =?UTF-8?q?=D1=8B=D0=B1=D0=BE=D1=80=20=D0=BA=D0=B0=D0=BC=D0=B5=D1=80=D1=8B?= =?UTF-8?q?,=20=D0=BA=D0=B0=D1=87=D0=B5=D1=81=D1=82=D0=B2=D0=BE,=20=D0=BF?= =?UTF-8?q?=D0=BE=D0=B4=D1=81=D0=B2=D0=B5=D1=82=D0=BA=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- src/chat/chat_core.h | 3 + src/chat/chat_msg.c | 3 + tools/chatgui-android/app/build.gradle.kts | 1 + .../main/java/com/utun/chat/MainActivity.kt | 18 +- .../com/utun/chat/data/CameraLensManager.kt | 109 +++++ .../java/com/utun/chat/data/ChatRepository.kt | 2 + .../main/java/com/utun/chat/data/NativeLib.kt | 7 + .../utun/chat/ui/components/MessageBubble.kt | 100 +++-- .../com/utun/chat/ui/screens/ChatScreen.kt | 7 +- .../utun/chat/ui/screens/VideoRecordScreen.kt | 412 ++++++++++++++++++ .../com/utun/chat/viewmodel/ChatViewModel.kt | 33 ++ .../jni_bridge/android_jni_bridge.c | 18 + .../jni_bridge/android_jni_bridge.h | 4 + .../libutun_lite/utun_sources.cmake | 1 + .../libutun_lite/video_sender.c | 86 ++++ .../libutun_lite/video_sender.h | 15 + 16 files changed, 787 insertions(+), 32 deletions(-) create mode 100644 tools/chatgui-android/app/src/main/java/com/utun/chat/data/CameraLensManager.kt create mode 100644 tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/VideoRecordScreen.kt create mode 100644 tools/chatgui-android/libutun_lite/video_sender.c create mode 100644 tools/chatgui-android/libutun_lite/video_sender.h diff --git a/src/chat/chat_core.h b/src/chat/chat_core.h index 351cf563..ccc10675 100644 --- a/src/chat/chat_core.h +++ b/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; diff --git a/src/chat/chat_msg.c b/src/chat/chat_msg.c index 58e9cef9..882903f1 100644 --- a/src/chat/chat_msg.c +++ b/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); diff --git a/tools/chatgui-android/app/build.gradle.kts b/tools/chatgui-android/app/build.gradle.kts index cb267a95..386540b5 100644 --- a/tools/chatgui-android/app/build.gradle.kts +++ b/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") } diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/MainActivity.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/MainActivity.kt index bdc90e59..087a2c47 100644 --- a/tools/chatgui-android/app/src/main/java/com/utun/chat/MainActivity.kt +++ b/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()) diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/data/CameraLensManager.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/CameraLensManager.kt new file mode 100644 index 00000000..e7a81e56 --- /dev/null +++ b/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 { + val result = mutableListOf() + val backs = mutableListOf() + + 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 + } +} diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/data/ChatRepository.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/ChatRepository.kt index f96871d7..0aa2d7c0 100644 --- a/tools/chatgui-android/app/src/main/java/com/utun/chat/data/ChatRepository.kt +++ b/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) = diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/data/NativeLib.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/NativeLib.kt index c458f3ad..80d1a864 100644 --- a/tools/chatgui-android/app/src/main/java/com/utun/chat/data/NativeLib.kt +++ b/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 diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/components/MessageBubble.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/components/MessageBubble.kt index 7c3cac8f..a3cf488d 100644 --- a/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/components/MessageBubble.kt +++ b/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 + ) } } } diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/ChatScreen.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/ChatScreen.kt index 481dff0c..af8392ba 100644 --- a/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/ChatScreen.kt +++ b/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() ) } diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/VideoRecordScreen.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/VideoRecordScreen.kt new file mode 100644 index 00000000..de17f842 --- /dev/null +++ b/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(null) } + var lenses by remember { mutableStateOf>(emptyList()) } + var selectedLens by remember { mutableStateOf(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(null) } + var outputFile by remember { mutableStateOf(null) } + var elapsedMs by remember { mutableStateOf(0) } + var pendingSend by remember { mutableStateOf(false) } + var sendRequested by remember { mutableStateOf(false) } + var boundCamera by remember { mutableStateOf(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) +} diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/viewmodel/ChatViewModel.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/viewmodel/ChatViewModel.kt index a150acbd..a67ec16f 100644 --- a/tools/chatgui-android/app/src/main/java/com/utun/chat/viewmodel/ChatViewModel.kt +++ b/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 = _recordingDurationMs + private val _isVideoMode = MutableStateFlow(false) + val isVideoMode: StateFlow = _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) diff --git a/tools/chatgui-android/jni_bridge/android_jni_bridge.c b/tools/chatgui-android/jni_bridge/android_jni_bridge.c index 49984f04..104d68d3 100644 --- a/tools/chatgui-android/jni_bridge/android_jni_bridge.c +++ b/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( diff --git a/tools/chatgui-android/jni_bridge/android_jni_bridge.h b/tools/chatgui-android/jni_bridge/android_jni_bridge.h index fd01e249..87044207 100644 --- a/tools/chatgui-android/jni_bridge/android_jni_bridge.h +++ b/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, diff --git a/tools/chatgui-android/libutun_lite/utun_sources.cmake b/tools/chatgui-android/libutun_lite/utun_sources.cmake index 649ceddb..8c2935d8 100644 --- a/tools/chatgui-android/libutun_lite/utun_sources.cmake +++ b/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" ) diff --git a/tools/chatgui-android/libutun_lite/video_sender.c b/tools/chatgui-android/libutun_lite/video_sender.c new file mode 100644 index 00000000..9204e3e2 --- /dev/null +++ b/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 +#include +#include +#include +#include +#include +#include + +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; +} diff --git a/tools/chatgui-android/libutun_lite/video_sender.h b/tools/chatgui-android/libutun_lite/video_sender.h new file mode 100644 index 00000000..e34d0cba --- /dev/null +++ b/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