feat: Adding camera preview functionality

This commit is contained in:
Myzel394 2023-12-03 22:16:09 +01:00
parent 817e9d96d0
commit 40eee79aa3
No known key found for this signature in database
GPG Key ID: 79CC92F37B3E1A2B
6 changed files with 244 additions and 63 deletions

View File

@ -2,6 +2,7 @@ package app.myzel394.alibi
import android.content.Context import android.content.Context
import android.os.Bundle import android.os.Bundle
import android.view.MotionEvent
import androidx.activity.ComponentActivity import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent import androidx.activity.compose.setContent
import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.app.AppCompatActivity

View File

@ -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
}
)
}

View File

@ -1,12 +1,27 @@
package app.myzel394.alibi.ui.components.AudioRecorder.molecules 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.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row 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.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size 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.Icons
import androidx.compose.material.icons.filled.Camera import androidx.compose.material.icons.filled.Camera
import androidx.compose.material.icons.filled.CameraAlt 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.Text
import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable 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.Alignment
import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.Modifier 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.platform.LocalContext
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Popup
import androidx.lifecycle.viewmodel.compose.viewModel import androidx.lifecycle.viewmodel.compose.viewModel
import app.myzel394.alibi.R import app.myzel394.alibi.R
import app.myzel394.alibi.ui.BIG_PRIMARY_BUTTON_SIZE 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.components.atoms.GlobalSwitch
import app.myzel394.alibi.ui.models.VideoRecorderSettingsModel import app.myzel394.alibi.ui.models.VideoRecorderSettingsModel
import app.myzel394.alibi.ui.utils.CameraInfo import app.myzel394.alibi.ui.utils.CameraInfo
@OptIn(ExperimentalMaterial3Api::class) @OptIn(
ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class,
ExperimentalComposeUiApi::class
)
@Composable @Composable
fun VideoRecorderPreparationSheet( fun VideoRecorderPreparationSheet(
onDismiss: () -> Unit, onDismiss: () -> Unit,
videoSettings: VideoRecorderSettingsModel = viewModel() videoSettings: VideoRecorderSettingsModel = viewModel(),
onPreviewVisible: () -> Unit,
onPreviewHidden: () -> Unit,
showPreview: Boolean,
) { ) {
val sheetState = rememberModalBottomSheetState(true) val sheetState = rememberModalBottomSheetState(true)
val context = LocalContext.current val context = LocalContext.current
val cameras = CameraInfo.queryAvailableCameras(context) val cameras = CameraInfo.queryAvailableCameras(context)
ModalBottomSheet( if (showPreview)
onDismissRequest = onDismiss, CameraPreview(
sheetState = sheetState,
) {
Column(
modifier = Modifier modifier = Modifier
.padding(horizontal = 16.dp, vertical = 24.dp), .fillMaxWidth()
horizontalAlignment = Alignment.CenterHorizontally, .fillMaxHeight()
verticalArrangement = Arrangement.spacedBy(30.dp), )
else {
ModalBottomSheet(
onDismissRequest = onDismiss,
sheetState = sheetState,
) { ) {
Column( Box(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(8.dp),
) { ) {
Icon( Column(
Icons.Default.CameraAlt,
contentDescription = null,
modifier = Modifier modifier = Modifier
.size(80.dp), .padding(horizontal = 16.dp, vertical = 24.dp),
) horizontalAlignment = Alignment.CenterHorizontally,
Text( verticalArrangement = Arrangement.spacedBy(30.dp),
stringResource(R.string.ui_videoRecorder_action_start_settings_label), ) {
style = MaterialTheme.typography.labelLarge, Column(
) horizontalAlignment = Alignment.CenterHorizontally,
} verticalArrangement = Arrangement.spacedBy(8.dp),
GlobalSwitch( ) {
label = stringResource(R.string.ui_videoRecorder_action_start_settings_enableAudio_label), Icon(
checked = videoSettings.enableAudio, Icons.Default.CameraAlt,
onCheckedChange = { contentDescription = null,
videoSettings.enableAudio = it 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) Popup(
Button( alignment = Alignment.BottomCenter,
onClick = {}, ) {
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 modifier = Modifier
.padding(16.dp) .padding(16.dp)
.fillMaxWidth() .fillMaxWidth()
.height(BIG_PRIMARY_BUTTON_SIZE) .height(BIG_PRIMARY_BUTTON_SIZE)
.clip(CircleShape)
.background(MaterialTheme.colorScheme.primary)
.padding(16.dp)
.semantics { .semantics {
contentDescription = label 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,
)
} }
} }
} }

View File

@ -36,6 +36,9 @@ import app.myzel394.alibi.ui.models.VideoRecorderModel
fun VideoRecordingStart( fun VideoRecordingStart(
videoRecorder: VideoRecorderModel, videoRecorder: VideoRecorderModel,
appSettings: AppSettings, appSettings: AppSettings,
onHideAudioRecording: () -> Unit,
onShowAudioRecording: () -> Unit,
showPreview: Boolean,
) { ) {
val context = LocalContext.current val context = LocalContext.current
@ -48,6 +51,9 @@ fun VideoRecordingStart(
onDismiss = { onDismiss = {
showSheet = false showSheet = false
}, },
onPreviewVisible = onHideAudioRecording,
onPreviewHidden = onShowAudioRecording,
showPreview = showPreview,
) )
} }

View File

@ -46,6 +46,9 @@ fun StartRecording(
// settings will be used, instead of the actual settings. // settings will be used, instead of the actual settings.
appSettings: AppSettings, appSettings: AppSettings,
onSaveLastRecording: () -> Unit, onSaveLastRecording: () -> Unit,
onHideTopBar: () -> Unit,
onShowTopBar: () -> Unit,
showAudioRecorder: Boolean,
) { ) {
val context = LocalContext.current val context = LocalContext.current
@ -56,13 +59,17 @@ fun StartRecording(
) { ) {
Spacer(modifier = Modifier.weight(1f)) Spacer(modifier = Modifier.weight(1f))
AudioRecordingStart( if (showAudioRecorder)
audioRecorder = audioRecorder, AudioRecordingStart(
appSettings = appSettings, audioRecorder = audioRecorder,
) appSettings = appSettings,
)
VideoRecordingStart( VideoRecordingStart(
videoRecorder = videoRecorder, videoRecorder = videoRecorder,
appSettings = appSettings, appSettings = appSettings,
onHideAudioRecording = onHideTopBar,
onShowAudioRecording = onShowTopBar,
showPreview = !showAudioRecorder,
) )
Text( Text(

View File

@ -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( Scaffold(
snackbarHost = { snackbarHost = {
SnackbarHost( SnackbarHost(
@ -270,23 +278,24 @@ fun RecorderScreen(
) )
}, },
topBar = { topBar = {
TopAppBar( if (topBarVisible)
title = { return@Scaffold TopAppBar(
Text(stringResource(R.string.app_name)) title = {
}, Text(stringResource(R.string.app_name))
actions = { },
IconButton( actions = {
onClick = { IconButton(
navController.navigate(Screen.Settings.route) onClick = {
}, navController.navigate(Screen.Settings.route)
) { },
Icon( ) {
Icons.Default.Settings, Icon(
contentDescription = null Icons.Default.Settings,
) contentDescription = null
)
}
} }
} )
)
}, },
) { padding -> ) { padding ->
Box( Box(
@ -305,6 +314,13 @@ fun RecorderScreen(
videoRecorder = videoRecorder, videoRecorder = videoRecorder,
appSettings = appSettings, appSettings = appSettings,
onSaveLastRecording = ::saveRecording, onSaveLastRecording = ::saveRecording,
showAudioRecorder = topBarVisible,
onHideTopBar = {
topBarVisible = false
},
onShowTopBar = {
topBarVisible = true
},
) )
} }
} }