chatgui-android: видеосообщения — запись, выбор камеры, качество, подсветка
- C-слой: video_sender (content_type video/mp4, media_video=0 без FFmpeg) - chat_msg_submit: duration/width/height для метаданных видео в теле сообщения - JNI: nativeVideoSend + NativeLib/Repository/ViewModel - VideoRecordScreen: CameraX VideoCapture, 4 пресета качества (разрешение+битрейт), выбор камеры (селфи/широкая/обычная/макро/теле по фокусу), тумблер подсветки, запись/пауза/отправить, лимит 60с - InputBar: единая кнопка — короткий тап переключает voice/video, удержание — запись - режим камеры/микрофона сохраняется в ViewModel
This commit is contained in:
1 parent
fe1726ebea
commit
73e81032bc
16 files changed
+788
-33
No files matched your search
@@ -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")
|
||||
}
|
||||
@@ -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())
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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) =
|
||||
|
||||
@@ -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
|
||||
|
||||
+72
-30
@@ -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()
|
||||
|
||||
onPttPressed()
|
||||
var locked = false
|
||||
var totalDragY = 0f
|
||||
var lastY = ch.position.y
|
||||
.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 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 } ?: return@withTimeoutOrNull
|
||||
if (!c.pressed) { released = true; return@withTimeoutOrNull }
|
||||
c.consume()
|
||||
}
|
||||
onPttReleased()
|
||||
}
|
||||
|
||||
if (released) {
|
||||
onToggleMode()
|
||||
continue
|
||||
}
|
||||
|
||||
if (isVideoMode) {
|
||||
onVideoRecordOpen()
|
||||
while (true) {
|
||||
val ev = awaitPointerEvent()
|
||||
val c = ev.changes.firstOrNull { it.id == downId } ?: break
|
||||
c.consume()
|
||||
if (!c.pressed) break
|
||||
}
|
||||
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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -40,9 +40,11 @@ fun ChatScreen(
|
||||
onMembersClick: () -> Unit,
|
||||
onInvite: () -> Unit,
|
||||
onBack: () -> Unit,
|
||||
onPlayVideo: (Message) -> Unit = {}
|
||||
onPlayVideo: (Message) -> Unit = {},
|
||||
onRecordVideo: () -> Unit = {}
|
||||
) {
|
||||
var text by remember { mutableStateOf("") }
|
||||
val isVideoMode by viewModel.isVideoMode.collectAsState()
|
||||
val listState = rememberLazyListState()
|
||||
val isRecording by viewModel.isRecording.collectAsState()
|
||||
val isRecordingLocked by viewModel.isRecordingLocked.collectAsState()
|
||||
@@ -183,6 +185,9 @@ fun ChatScreen(
|
||||
pendingAttachName = ""
|
||||
pendingAttachSize = 0L
|
||||
},
|
||||
isVideoMode = isVideoMode,
|
||||
onToggleMode = { viewModel.toggleVideoMode() },
|
||||
onVideoRecordOpen = onRecordVideo,
|
||||
modifier = Modifier.navigationBarsPadding()
|
||||
)
|
||||
}
|
||||
|
||||
+412
@@ -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)
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
package com.utun.chat.viewmodel
|
||||
|
||||
import android.content.Context
|
||||
import android.media.MediaMetadataRetriever
|
||||
import android.net.Uri
|
||||
import android.util.Log
|
||||
import androidx.lifecycle.ViewModel
|
||||
@@ -94,6 +95,11 @@ class ChatViewModel : ViewModel() {
|
||||
private val _recordingDurationMs = MutableStateFlow(0)
|
||||
val recordingDurationMs: StateFlow<Int> = _recordingDurationMs
|
||||
|
||||
private val _isVideoMode = MutableStateFlow(false)
|
||||
val isVideoMode: StateFlow<Boolean> = _isVideoMode
|
||||
|
||||
fun toggleVideoMode() { _isVideoMode.value = !_isVideoMode.value }
|
||||
|
||||
private var recordingStartTimeMs = 0L
|
||||
|
||||
@Volatile private var realPlaybackElapsedMs = 0
|
||||
@@ -610,6 +616,33 @@ class ChatViewModel : ViewModel() {
|
||||
}
|
||||
}
|
||||
|
||||
fun sendVideoMessage(filePath: String) {
|
||||
val r = repo ?: return
|
||||
val ch = _currentChannel.value ?: return
|
||||
viewModelScope.launch {
|
||||
val meta = withContext(Dispatchers.IO) {
|
||||
var dur = 0; var w = 0; var h = 0
|
||||
val mmr = MediaMetadataRetriever()
|
||||
try {
|
||||
mmr.setDataSource(filePath)
|
||||
dur = mmr.extractMetadata(MediaMetadataRetriever.METADATA_KEY_DURATION)?.toIntOrNull() ?: 0
|
||||
w = mmr.extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_WIDTH)?.toIntOrNull() ?: 0
|
||||
h = mmr.extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_HEIGHT)?.toIntOrNull() ?: 0
|
||||
} catch (e: Exception) {
|
||||
LogManager.addLog("WARN", "VM", "video metadata failed: ${e.message}")
|
||||
} finally {
|
||||
try { mmr.release() } catch (_: Exception) {}
|
||||
}
|
||||
Triple(dur, w, h)
|
||||
}
|
||||
LogManager.addLog("INFO", "VM", "sendVideoMessage ch=${ch.id} dur=${meta.first} ${meta.second}x${meta.third}")
|
||||
r.sendVideo(ch.id, filePath, meta.first, meta.second, meta.third)
|
||||
delay(500)
|
||||
refreshMessages(ch.id)
|
||||
refreshChannels()
|
||||
}
|
||||
}
|
||||
|
||||
fun downloadAttachment(msg: Message) {
|
||||
val ch = _currentChannel.value ?: return
|
||||
NativeLib.attachmentDownload(ch.id, msg.id)
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
|
||||
|
||||
@@ -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
|
||||
Reference in new issue
Block a user