Browse Source
- 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, удержание — запись - режим камеры/микрофона сохраняется в ViewModelv2
16 changed files with 787 additions and 32 deletions
@ -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 |
||||
} |
||||
} |
||||
@ -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) |
||||
} |
||||
@ -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; |
||||
} |
||||
@ -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…
Reference in new issue