From 40eee79aa3ab1fe1daea8f6d73586bdb3dcf88b3 Mon Sep 17 00:00:00 2001 From: Myzel394 <50424412+Myzel394@users.noreply.github.com> Date: Sun, 3 Dec 2023 22:16:09 +0100 Subject: [PATCH] feat: Adding camera preview functionality --- .../java/app/myzel394/alibi/MainActivity.kt | 1 + .../AudioRecorder/atoms/CameraPreview.kt | 57 ++++++ .../VideoRecorderPreparationSheet.kt | 180 +++++++++++++----- .../molecules/VideoRecordingStart.kt | 6 + .../AudioRecorder/organisms/StartRecording.kt | 15 +- .../alibi/ui/screens/RecorderScreen.kt | 48 +++-- 6 files changed, 244 insertions(+), 63 deletions(-) create mode 100644 app/src/main/java/app/myzel394/alibi/ui/components/AudioRecorder/atoms/CameraPreview.kt diff --git a/app/src/main/java/app/myzel394/alibi/MainActivity.kt b/app/src/main/java/app/myzel394/alibi/MainActivity.kt index 5b81729..664b96e 100644 --- a/app/src/main/java/app/myzel394/alibi/MainActivity.kt +++ b/app/src/main/java/app/myzel394/alibi/MainActivity.kt @@ -2,6 +2,7 @@ package app.myzel394.alibi import android.content.Context import android.os.Bundle +import android.view.MotionEvent import androidx.activity.ComponentActivity import androidx.activity.compose.setContent import androidx.appcompat.app.AppCompatActivity diff --git a/app/src/main/java/app/myzel394/alibi/ui/components/AudioRecorder/atoms/CameraPreview.kt b/app/src/main/java/app/myzel394/alibi/ui/components/AudioRecorder/atoms/CameraPreview.kt new file mode 100644 index 0000000..494db91 --- /dev/null +++ b/app/src/main/java/app/myzel394/alibi/ui/components/AudioRecorder/atoms/CameraPreview.kt @@ -0,0 +1,57 @@ +package app.myzel394.alibi.ui.components.AudioRecorder.atoms + +import android.view.MotionEvent +import android.view.ViewGroup +import androidx.camera.core.CameraSelector +import androidx.camera.core.Preview +import androidx.camera.lifecycle.ProcessCameraProvider +import androidx.camera.view.PreviewView +import androidx.compose.runtime.Composable +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalLifecycleOwner +import androidx.compose.ui.viewinterop.AndroidView +import kotlinx.coroutines.launch + +@Composable +fun CameraPreview( + modifier: Modifier = Modifier, + scaleType: PreviewView.ScaleType = PreviewView.ScaleType.FILL_CENTER, + cameraSelector: CameraSelector = CameraSelector.DEFAULT_BACK_CAMERA +) { + val coroutineScope = rememberCoroutineScope() + val lifecycleOwner = LocalLifecycleOwner.current + AndroidView( + modifier = modifier, + factory = { context -> + val previewView = PreviewView(context).apply { + this.scaleType = scaleType + layoutParams = ViewGroup.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.MATCH_PARENT + ) + } + + // CameraX Preview UseCase + val previewUseCase = Preview.Builder() + .build() + .also { + it.setSurfaceProvider(previewView.surfaceProvider) + } + + coroutineScope.launch { + val cameraProvider = ProcessCameraProvider.getInstance(context).get() + try { + // Must unbind the use-cases before rebinding them. + cameraProvider.unbindAll() + cameraProvider.bindToLifecycle( + lifecycleOwner, cameraSelector, previewUseCase + ) + } catch (ex: Exception) { + } + } + + previewView + } + ) +} \ No newline at end of file diff --git a/app/src/main/java/app/myzel394/alibi/ui/components/AudioRecorder/molecules/VideoRecorderPreparationSheet.kt b/app/src/main/java/app/myzel394/alibi/ui/components/AudioRecorder/molecules/VideoRecorderPreparationSheet.kt index eb96d69..46bdb18 100644 --- a/app/src/main/java/app/myzel394/alibi/ui/components/AudioRecorder/molecules/VideoRecorderPreparationSheet.kt +++ b/app/src/main/java/app/myzel394/alibi/ui/components/AudioRecorder/molecules/VideoRecorderPreparationSheet.kt @@ -1,12 +1,27 @@ package app.myzel394.alibi.ui.components.AudioRecorder.molecules +import android.graphics.Point +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.background +import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.gestures.awaitEachGesture +import androidx.compose.foundation.gestures.detectTapGestures +import androidx.compose.foundation.gestures.forEachGesture +import androidx.compose.foundation.gestures.waitForUpOrCancellation +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.collectIsPressedAsState import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.CircleShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Camera import androidx.compose.material.icons.filled.CameraAlt @@ -21,88 +36,167 @@ import androidx.compose.material3.RadioButton import androidx.compose.material3.Text import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment +import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.draw.clip +import androidx.compose.ui.input.pointer.PointerEvent +import androidx.compose.ui.input.pointer.PointerInputChange +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.input.pointer.pointerInteropFilter import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.semantics import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Popup import androidx.lifecycle.viewmodel.compose.viewModel import app.myzel394.alibi.R import app.myzel394.alibi.ui.BIG_PRIMARY_BUTTON_SIZE +import app.myzel394.alibi.ui.components.AudioRecorder.atoms.CameraPreview import app.myzel394.alibi.ui.components.atoms.GlobalSwitch import app.myzel394.alibi.ui.models.VideoRecorderSettingsModel import app.myzel394.alibi.ui.utils.CameraInfo -@OptIn(ExperimentalMaterial3Api::class) +@OptIn( + ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class, + ExperimentalComposeUiApi::class +) @Composable fun VideoRecorderPreparationSheet( onDismiss: () -> Unit, - videoSettings: VideoRecorderSettingsModel = viewModel() + videoSettings: VideoRecorderSettingsModel = viewModel(), + onPreviewVisible: () -> Unit, + onPreviewHidden: () -> Unit, + showPreview: Boolean, ) { val sheetState = rememberModalBottomSheetState(true) val context = LocalContext.current val cameras = CameraInfo.queryAvailableCameras(context) - ModalBottomSheet( - onDismissRequest = onDismiss, - sheetState = sheetState, - ) { - Column( + if (showPreview) + CameraPreview( modifier = Modifier - .padding(horizontal = 16.dp, vertical = 24.dp), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(30.dp), + .fillMaxWidth() + .fillMaxHeight() + ) + else { + ModalBottomSheet( + onDismissRequest = onDismiss, + sheetState = sheetState, ) { - Column( - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(8.dp), + Box( ) { - Icon( - Icons.Default.CameraAlt, - contentDescription = null, + Column( modifier = Modifier - .size(80.dp), - ) - Text( - stringResource(R.string.ui_videoRecorder_action_start_settings_label), - style = MaterialTheme.typography.labelLarge, - ) - } - GlobalSwitch( - label = stringResource(R.string.ui_videoRecorder_action_start_settings_enableAudio_label), - checked = videoSettings.enableAudio, - onCheckedChange = { - videoSettings.enableAudio = it + .padding(horizontal = 16.dp, vertical = 24.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(30.dp), + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + Icon( + Icons.Default.CameraAlt, + contentDescription = null, + modifier = Modifier + .size(80.dp), + ) + Text( + stringResource(R.string.ui_videoRecorder_action_start_settings_label), + style = MaterialTheme.typography.labelLarge, + ) + } + GlobalSwitch( + label = stringResource(R.string.ui_videoRecorder_action_start_settings_enableAudio_label), + checked = videoSettings.enableAudio, + onCheckedChange = { + videoSettings.enableAudio = it + } + ) + + Text( + stringResource(R.string.ui_videoRecorder_action_start_settings_cameraLens_selection_label), + style = MaterialTheme.typography.labelLarge, + textAlign = TextAlign.Start, + modifier = Modifier.fillMaxWidth() + ) + CamerasSelection( + cameras = cameras, + videoSettings = videoSettings, + ) + + Box( + modifier = Modifier + .fillMaxWidth() + .height(BIG_PRIMARY_BUTTON_SIZE) + ) } - ) - Text( - stringResource(R.string.ui_videoRecorder_action_start_settings_cameraLens_selection_label), - style = MaterialTheme.typography.labelLarge, - textAlign = TextAlign.Start, - modifier = Modifier.fillMaxWidth() - ) - CamerasSelection( - cameras = cameras, - videoSettings = videoSettings, - ) + } + } + } - val label = stringResource(R.string.ui_videoRecorder_action_start_settings_start_label) - Button( - onClick = {}, + Popup( + alignment = Alignment.BottomCenter, + ) { + val label = + stringResource(R.string.ui_videoRecorder_action_start_settings_start_label) + + Box( + modifier = Modifier + .pointerInput(Unit) { + awaitEachGesture { + while (true) { + val event = awaitPointerEvent() + // consume all changes + + if (!event.changes.elementAt(0).pressed) { + onPreviewHidden() + break + } + } + } + } + .let { + if (showPreview) it.alpha(0.2f) else it + } + ) { + Row( modifier = Modifier .padding(16.dp) .fillMaxWidth() .height(BIG_PRIMARY_BUTTON_SIZE) + .clip(CircleShape) + .background(MaterialTheme.colorScheme.primary) + .padding(16.dp) .semantics { contentDescription = label } + .pointerInput(Unit) { + detectTapGestures( + onLongPress = { + onPreviewVisible() + } + ) + }, + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically, ) { - Text(label) + Text( + label, + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.onPrimary, + ) } } } diff --git a/app/src/main/java/app/myzel394/alibi/ui/components/AudioRecorder/molecules/VideoRecordingStart.kt b/app/src/main/java/app/myzel394/alibi/ui/components/AudioRecorder/molecules/VideoRecordingStart.kt index 5e2be62..6abb1d9 100644 --- a/app/src/main/java/app/myzel394/alibi/ui/components/AudioRecorder/molecules/VideoRecordingStart.kt +++ b/app/src/main/java/app/myzel394/alibi/ui/components/AudioRecorder/molecules/VideoRecordingStart.kt @@ -36,6 +36,9 @@ import app.myzel394.alibi.ui.models.VideoRecorderModel fun VideoRecordingStart( videoRecorder: VideoRecorderModel, appSettings: AppSettings, + onHideAudioRecording: () -> Unit, + onShowAudioRecording: () -> Unit, + showPreview: Boolean, ) { val context = LocalContext.current @@ -48,6 +51,9 @@ fun VideoRecordingStart( onDismiss = { showSheet = false }, + onPreviewVisible = onHideAudioRecording, + onPreviewHidden = onShowAudioRecording, + showPreview = showPreview, ) } diff --git a/app/src/main/java/app/myzel394/alibi/ui/components/AudioRecorder/organisms/StartRecording.kt b/app/src/main/java/app/myzel394/alibi/ui/components/AudioRecorder/organisms/StartRecording.kt index f02e301..77c1b30 100644 --- a/app/src/main/java/app/myzel394/alibi/ui/components/AudioRecorder/organisms/StartRecording.kt +++ b/app/src/main/java/app/myzel394/alibi/ui/components/AudioRecorder/organisms/StartRecording.kt @@ -46,6 +46,9 @@ fun StartRecording( // settings will be used, instead of the actual settings. appSettings: AppSettings, onSaveLastRecording: () -> Unit, + onHideTopBar: () -> Unit, + onShowTopBar: () -> Unit, + showAudioRecorder: Boolean, ) { val context = LocalContext.current @@ -56,13 +59,17 @@ fun StartRecording( ) { Spacer(modifier = Modifier.weight(1f)) - AudioRecordingStart( - audioRecorder = audioRecorder, - appSettings = appSettings, - ) + if (showAudioRecorder) + AudioRecordingStart( + audioRecorder = audioRecorder, + appSettings = appSettings, + ) VideoRecordingStart( videoRecorder = videoRecorder, appSettings = appSettings, + onHideAudioRecording = onHideTopBar, + onShowAudioRecording = onShowTopBar, + showPreview = !showAudioRecorder, ) Text( diff --git a/app/src/main/java/app/myzel394/alibi/ui/screens/RecorderScreen.kt b/app/src/main/java/app/myzel394/alibi/ui/screens/RecorderScreen.kt index 0810bf2..69dc3b1 100644 --- a/app/src/main/java/app/myzel394/alibi/ui/screens/RecorderScreen.kt +++ b/app/src/main/java/app/myzel394/alibi/ui/screens/RecorderScreen.kt @@ -253,6 +253,14 @@ fun RecorderScreen( } } ) + + + // TopAppBar and AudioRecordingStart should be hidden when + // the video preview is visible. + // We need to preview the video inline to + // be able to capture the touch release event. + var topBarVisible by remember { mutableStateOf(true) } + Scaffold( snackbarHost = { SnackbarHost( @@ -270,23 +278,24 @@ fun RecorderScreen( ) }, topBar = { - TopAppBar( - title = { - Text(stringResource(R.string.app_name)) - }, - actions = { - IconButton( - onClick = { - navController.navigate(Screen.Settings.route) - }, - ) { - Icon( - Icons.Default.Settings, - contentDescription = null - ) + if (topBarVisible) + return@Scaffold TopAppBar( + title = { + Text(stringResource(R.string.app_name)) + }, + actions = { + IconButton( + onClick = { + navController.navigate(Screen.Settings.route) + }, + ) { + Icon( + Icons.Default.Settings, + contentDescription = null + ) + } } - } - ) + ) }, ) { padding -> Box( @@ -305,6 +314,13 @@ fun RecorderScreen( videoRecorder = videoRecorder, appSettings = appSettings, onSaveLastRecording = ::saveRecording, + showAudioRecorder = topBarVisible, + onHideTopBar = { + topBarVisible = false + }, + onShowTopBar = { + topBarVisible = true + }, ) } }