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