android: add in-chat circle recorder with live camera preview
This commit is contained in:
@@ -106,6 +106,11 @@ dependencies {
|
||||
implementation("androidx.media3:media3-exoplayer:1.4.1")
|
||||
implementation("androidx.media3:media3-datasource:1.4.1")
|
||||
implementation("androidx.media3:media3-datasource-okhttp:1.4.1")
|
||||
implementation("androidx.camera:camera-core:1.4.2")
|
||||
implementation("androidx.camera:camera-camera2:1.4.2")
|
||||
implementation("androidx.camera:camera-lifecycle:1.4.2")
|
||||
implementation("androidx.camera:camera-video:1.4.2")
|
||||
implementation("androidx.camera:camera-view:1.4.2")
|
||||
|
||||
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.9.0")
|
||||
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.7.3")
|
||||
|
||||
@@ -115,6 +115,7 @@ import androidx.compose.ui.layout.onSizeChanged
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.lifecycle.compose.LocalLifecycleOwner
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.core.content.FileProvider
|
||||
import androidx.core.view.WindowCompat
|
||||
@@ -183,6 +184,19 @@ import java.util.Locale
|
||||
import java.net.URLEncoder
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlin.math.roundToInt
|
||||
import androidx.camera.core.CameraSelector
|
||||
import androidx.camera.core.Preview
|
||||
import androidx.camera.lifecycle.ProcessCameraProvider
|
||||
import androidx.camera.video.FileOutputOptions
|
||||
import androidx.camera.video.PendingRecording
|
||||
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.ui.window.Dialog
|
||||
|
||||
@Composable
|
||||
fun ChatRoute(
|
||||
@@ -191,7 +205,9 @@ fun ChatRoute(
|
||||
) {
|
||||
val state by viewModel.uiState.collectAsStateWithLifecycle()
|
||||
val context = LocalContext.current
|
||||
val lifecycleOwner = LocalLifecycleOwner.current
|
||||
val voiceRecorder = remember(context) { VoiceRecorder(context) }
|
||||
var showCircleRecorder by remember { mutableStateOf(false) }
|
||||
var hasAudioPermission by remember {
|
||||
mutableStateOf(
|
||||
ContextCompat.checkSelfPermission(
|
||||
@@ -200,7 +216,16 @@ fun ChatRoute(
|
||||
) == PackageManager.PERMISSION_GRANTED
|
||||
)
|
||||
}
|
||||
var hasCameraPermission by remember {
|
||||
mutableStateOf(
|
||||
ContextCompat.checkSelfPermission(
|
||||
context,
|
||||
Manifest.permission.CAMERA,
|
||||
) == PackageManager.PERMISSION_GRANTED
|
||||
)
|
||||
}
|
||||
var startRecordingAfterPermissionGrant by remember { mutableStateOf(false) }
|
||||
var openCircleAfterPermissionGrant by remember { mutableStateOf(false) }
|
||||
val startVoiceRecording: () -> Unit = {
|
||||
val started = voiceRecorder.start()
|
||||
if (started) {
|
||||
@@ -219,6 +244,18 @@ fun ChatRoute(
|
||||
}
|
||||
startRecordingAfterPermissionGrant = false
|
||||
}
|
||||
val circlePermissionLauncher = rememberLauncherForActivityResult(
|
||||
contract = ActivityResultContracts.RequestMultiplePermissions(),
|
||||
) { grants ->
|
||||
hasCameraPermission = grants[Manifest.permission.CAMERA] == true ||
|
||||
ContextCompat.checkSelfPermission(context, Manifest.permission.CAMERA) == PackageManager.PERMISSION_GRANTED
|
||||
hasAudioPermission = grants[Manifest.permission.RECORD_AUDIO] == true ||
|
||||
ContextCompat.checkSelfPermission(context, Manifest.permission.RECORD_AUDIO) == PackageManager.PERMISSION_GRANTED
|
||||
if (hasCameraPermission && openCircleAfterPermissionGrant) {
|
||||
showCircleRecorder = true
|
||||
}
|
||||
openCircleAfterPermissionGrant = false
|
||||
}
|
||||
DisposableEffect(voiceRecorder) {
|
||||
onDispose {
|
||||
voiceRecorder.cancel()
|
||||
@@ -247,26 +284,18 @@ fun ChatRoute(
|
||||
}
|
||||
var pendingCaptureVideoUri by remember { mutableStateOf<Uri?>(null) }
|
||||
var pendingCaptureVideoFile by remember { mutableStateOf<File?>(null) }
|
||||
var pendingCaptureCircle by remember { mutableStateOf(false) }
|
||||
val captureVideoLauncher = rememberLauncherForActivityResult(
|
||||
contract = ActivityResultContracts.CaptureVideo(),
|
||||
) { success ->
|
||||
val uri = pendingCaptureVideoUri
|
||||
val file = pendingCaptureVideoFile
|
||||
val asCircle = pendingCaptureCircle
|
||||
pendingCaptureVideoUri = null
|
||||
pendingCaptureVideoFile = null
|
||||
pendingCaptureCircle = false
|
||||
if (success && uri != null) {
|
||||
val picked = uri.readMediaPayload(context)
|
||||
if (picked != null) {
|
||||
val fileName = if (asCircle) {
|
||||
"circle_${System.currentTimeMillis()}.mp4"
|
||||
} else {
|
||||
"camera_video_${System.currentTimeMillis()}.mp4"
|
||||
}
|
||||
viewModel.onMediaPicked(
|
||||
fileName = fileName,
|
||||
fileName = "camera_video_${System.currentTimeMillis()}.mp4",
|
||||
mimeType = if (picked.mimeType.startsWith("video/")) picked.mimeType else "video/mp4",
|
||||
bytes = picked.bytes,
|
||||
)
|
||||
@@ -302,22 +331,22 @@ fun ChatRoute(
|
||||
"${context.packageName}.fileprovider",
|
||||
file,
|
||||
)
|
||||
pendingCaptureCircle = false
|
||||
pendingCaptureVideoFile = file
|
||||
pendingCaptureVideoUri = uri
|
||||
captureVideoLauncher.launch(uri)
|
||||
},
|
||||
onCaptureCircleVideo = {
|
||||
val file = createTempCaptureFile(context = context, prefix = "circle_", suffix = ".mp4")
|
||||
val uri = FileProvider.getUriForFile(
|
||||
context,
|
||||
"${context.packageName}.fileprovider",
|
||||
file,
|
||||
)
|
||||
pendingCaptureCircle = true
|
||||
pendingCaptureVideoFile = file
|
||||
pendingCaptureVideoUri = uri
|
||||
captureVideoLauncher.launch(uri)
|
||||
if (hasCameraPermission) {
|
||||
showCircleRecorder = true
|
||||
} else {
|
||||
openCircleAfterPermissionGrant = true
|
||||
circlePermissionLauncher.launch(
|
||||
arrayOf(
|
||||
Manifest.permission.CAMERA,
|
||||
Manifest.permission.RECORD_AUDIO,
|
||||
),
|
||||
)
|
||||
}
|
||||
},
|
||||
onSendRemoteMedia = { fileName, mimeType, bytes ->
|
||||
viewModel.onMediaPicked(
|
||||
@@ -372,6 +401,20 @@ fun ChatRoute(
|
||||
onTransferOwnership = viewModel::transferOwnership,
|
||||
onUnbanMember = viewModel::unbanMember,
|
||||
)
|
||||
if (showCircleRecorder) {
|
||||
CircleVideoRecorderDialog(
|
||||
lifecycleOwner = lifecycleOwner,
|
||||
onDismiss = { showCircleRecorder = false },
|
||||
onSend = { payload ->
|
||||
viewModel.onMediaPicked(
|
||||
fileName = payload.fileName,
|
||||
mimeType = payload.mimeType,
|
||||
bytes = payload.bytes,
|
||||
)
|
||||
showCircleRecorder = false
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@@ -2234,6 +2277,266 @@ private data class PickedMediaPayload(
|
||||
val bytes: ByteArray,
|
||||
)
|
||||
|
||||
private enum class CircleFinalizeAction {
|
||||
Send,
|
||||
Cancel,
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun CircleVideoRecorderDialog(
|
||||
lifecycleOwner: androidx.lifecycle.LifecycleOwner,
|
||||
onDismiss: () -> Unit,
|
||||
onSend: (PickedMediaPayload) -> Unit,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val mainExecutor = remember(context) { ContextCompat.getMainExecutor(context) }
|
||||
val previewView = remember {
|
||||
PreviewView(context).apply {
|
||||
implementationMode = PreviewView.ImplementationMode.COMPATIBLE
|
||||
scaleType = PreviewView.ScaleType.FILL_CENTER
|
||||
}
|
||||
}
|
||||
var videoCapture by remember { mutableStateOf<VideoCapture<Recorder>?>(null) }
|
||||
var activeRecording by remember { mutableStateOf<Recording?>(null) }
|
||||
var activeFile by remember { mutableStateOf<File?>(null) }
|
||||
var isRecording by remember { mutableStateOf(false) }
|
||||
var isLocked by remember { mutableStateOf(false) }
|
||||
var durationMs by remember { mutableStateOf(0L) }
|
||||
var finalizeAction by remember { mutableStateOf<CircleFinalizeAction?>(null) }
|
||||
|
||||
LaunchedEffect(isRecording) {
|
||||
while (isRecording) {
|
||||
delay(100L)
|
||||
durationMs += 100L
|
||||
}
|
||||
}
|
||||
|
||||
DisposableEffect(lifecycleOwner, previewView) {
|
||||
val cameraProviderFuture = ProcessCameraProvider.getInstance(context)
|
||||
val listener = Runnable {
|
||||
val provider = runCatching { cameraProviderFuture.get() }.getOrNull() ?: return@Runnable
|
||||
val preview = Preview.Builder().build().apply {
|
||||
setSurfaceProvider(previewView.surfaceProvider)
|
||||
}
|
||||
val recorder = Recorder.Builder()
|
||||
.setQualitySelector(QualitySelector.from(Quality.SD))
|
||||
.build()
|
||||
val capture = VideoCapture.withOutput(recorder)
|
||||
runCatching {
|
||||
provider.unbindAll()
|
||||
provider.bindToLifecycle(
|
||||
lifecycleOwner,
|
||||
CameraSelector.DEFAULT_FRONT_CAMERA,
|
||||
preview,
|
||||
capture,
|
||||
)
|
||||
videoCapture = capture
|
||||
}
|
||||
}
|
||||
cameraProviderFuture.addListener(listener, mainExecutor)
|
||||
onDispose {
|
||||
runCatching { activeRecording?.stop() }
|
||||
activeRecording = null
|
||||
isRecording = false
|
||||
val provider = runCatching { cameraProviderFuture.get() }.getOrNull()
|
||||
runCatching { provider?.unbindAll() }
|
||||
runCatching { activeFile?.delete() }
|
||||
activeFile = null
|
||||
}
|
||||
}
|
||||
|
||||
fun stopWith(action: CircleFinalizeAction) {
|
||||
val recording = activeRecording ?: return
|
||||
finalizeAction = action
|
||||
recording.stop()
|
||||
activeRecording = null
|
||||
isRecording = false
|
||||
}
|
||||
|
||||
fun startRecording() {
|
||||
if (isRecording) return
|
||||
val capture = videoCapture ?: return
|
||||
val file = createTempCaptureFile(context = context, prefix = "circle_", suffix = ".mp4")
|
||||
activeFile = file
|
||||
durationMs = 0L
|
||||
isLocked = false
|
||||
val output = FileOutputOptions.Builder(file).build()
|
||||
var pending = capture.output.prepareRecording(context, output)
|
||||
if (
|
||||
ContextCompat.checkSelfPermission(context, Manifest.permission.RECORD_AUDIO) ==
|
||||
PackageManager.PERMISSION_GRANTED
|
||||
) {
|
||||
pending = pending.withAudioEnabled()
|
||||
}
|
||||
activeRecording = pending.start(mainExecutor) { event ->
|
||||
when (event) {
|
||||
is VideoRecordEvent.Start -> {
|
||||
isRecording = true
|
||||
}
|
||||
is VideoRecordEvent.Finalize -> {
|
||||
isRecording = false
|
||||
val completedFile = activeFile
|
||||
val action = finalizeAction
|
||||
finalizeAction = null
|
||||
activeFile = null
|
||||
activeRecording = null
|
||||
val hasError = event.hasError()
|
||||
if (hasError || completedFile == null || !completedFile.exists()) {
|
||||
runCatching { completedFile?.delete() }
|
||||
return@start
|
||||
}
|
||||
when (action) {
|
||||
CircleFinalizeAction.Send -> {
|
||||
val bytes = runCatching { completedFile.readBytes() }.getOrNull()
|
||||
if (bytes != null && bytes.isNotEmpty()) {
|
||||
onSend(
|
||||
PickedMediaPayload(
|
||||
fileName = "circle_${System.currentTimeMillis()}.mp4",
|
||||
mimeType = "video/mp4",
|
||||
bytes = bytes,
|
||||
),
|
||||
)
|
||||
}
|
||||
runCatching { completedFile.delete() }
|
||||
onDismiss()
|
||||
}
|
||||
CircleFinalizeAction.Cancel, null -> {
|
||||
runCatching { completedFile.delete() }
|
||||
}
|
||||
}
|
||||
}
|
||||
else -> Unit
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Dialog(onDismissRequest = {
|
||||
if (!isRecording) {
|
||||
onDismiss()
|
||||
} else {
|
||||
stopWith(CircleFinalizeAction.Cancel)
|
||||
}
|
||||
}) {
|
||||
Surface(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(16.dp),
|
||||
shape = RoundedCornerShape(24.dp),
|
||||
color = MaterialTheme.colorScheme.surface,
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(16.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(260.dp)
|
||||
.clip(CircleShape)
|
||||
.background(Color.Black),
|
||||
) {
|
||||
AndroidView(
|
||||
factory = { previewView },
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
)
|
||||
}
|
||||
Text(
|
||||
text = if (isRecording) "Recording ${formatDuration(durationMs.toInt())}" else "Circle video",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
)
|
||||
if (!isRecording) {
|
||||
CircleHoldToRecordButton(
|
||||
onStart = { startRecording() },
|
||||
onLock = { isLocked = true },
|
||||
onCancel = { stopWith(CircleFinalizeAction.Cancel) },
|
||||
onRelease = { stopWith(CircleFinalizeAction.Send) },
|
||||
)
|
||||
TextButton(onClick = onDismiss) { Text(stringResource(id = R.string.common_cancel)) }
|
||||
} else {
|
||||
if (isLocked) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(10.dp, Alignment.CenterHorizontally),
|
||||
) {
|
||||
TextButton(onClick = { stopWith(CircleFinalizeAction.Cancel) }) {
|
||||
Text(stringResource(id = R.string.common_cancel))
|
||||
}
|
||||
Button(onClick = { stopWith(CircleFinalizeAction.Send) }) {
|
||||
Text(stringResource(id = R.string.common_send))
|
||||
}
|
||||
}
|
||||
} else {
|
||||
CircleHoldToRecordButton(
|
||||
onStart = {},
|
||||
onLock = { isLocked = true },
|
||||
onCancel = { stopWith(CircleFinalizeAction.Cancel) },
|
||||
onRelease = { stopWith(CircleFinalizeAction.Send) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun CircleHoldToRecordButton(
|
||||
onStart: () -> Unit,
|
||||
onLock: () -> Unit,
|
||||
onCancel: () -> Unit,
|
||||
onRelease: () -> Unit,
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.pointerInput(Unit) {
|
||||
awaitEachGesture {
|
||||
val down = awaitFirstDown(requireUnconsumed = false)
|
||||
onStart()
|
||||
var cancelled = false
|
||||
var locked = false
|
||||
while (true) {
|
||||
val event = awaitPointerEvent()
|
||||
val change = event.changes.first()
|
||||
val delta = change.position - down.position
|
||||
if (!locked && delta.y < -120f) {
|
||||
locked = true
|
||||
onLock()
|
||||
break
|
||||
}
|
||||
if (!locked && delta.x < -150f) {
|
||||
cancelled = true
|
||||
onCancel()
|
||||
break
|
||||
}
|
||||
if (!change.pressed) {
|
||||
if (!cancelled && !locked) onRelease()
|
||||
break
|
||||
}
|
||||
if (change.positionChange() != Offset.Zero) {
|
||||
change.consume()
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
) {
|
||||
Surface(
|
||||
shape = CircleShape,
|
||||
color = MaterialTheme.colorScheme.primary.copy(alpha = 0.18f),
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(horizontal = 20.dp, vertical = 10.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
Icon(imageVector = Icons.Filled.RadioButtonChecked, contentDescription = null)
|
||||
Text("Slide up to lock, left to cancel")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun Bitmap.toJpegPayload(quality: Int = 92): PickedMediaPayload? {
|
||||
val stream = java.io.ByteArrayOutputStream()
|
||||
val ok = compress(Bitmap.CompressFormat.JPEG, quality.coerceIn(1, 100), stream)
|
||||
|
||||
Reference in New Issue
Block a user