diff --git a/app/src/main/java/app/myzel394/alibi/ui/Navigation.kt b/app/src/main/java/app/myzel394/alibi/ui/Navigation.kt index beff2e4..1224257 100644 --- a/app/src/main/java/app/myzel394/alibi/ui/Navigation.kt +++ b/app/src/main/java/app/myzel394/alibi/ui/Navigation.kt @@ -1,5 +1,9 @@ package app.myzel394.alibi.ui +import android.content.Context +import android.hardware.camera2.CameraCharacteristics +import android.hardware.camera2.CameraManager +import androidx.camera.core.CameraX import androidx.compose.animation.core.tween import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut @@ -11,6 +15,7 @@ import androidx.compose.foundation.background import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext @@ -27,6 +32,7 @@ import app.myzel394.alibi.ui.screens.RecorderScreen import app.myzel394.alibi.ui.screens.CustomRecordingNotificationsScreen import app.myzel394.alibi.ui.screens.SettingsScreen import app.myzel394.alibi.ui.screens.WelcomeScreen +import app.myzel394.alibi.ui.utils.CameraInfo const val SCALE_IN = 1.25f diff --git a/app/src/main/java/app/myzel394/alibi/ui/components/AudioRecorder/atoms/CameraSelectionButton.kt b/app/src/main/java/app/myzel394/alibi/ui/components/AudioRecorder/atoms/CameraSelectionButton.kt new file mode 100644 index 0000000..d8a332a --- /dev/null +++ b/app/src/main/java/app/myzel394/alibi/ui/components/AudioRecorder/atoms/CameraSelectionButton.kt @@ -0,0 +1,103 @@ +package app.myzel394.alibi.ui.components.AudioRecorder.atoms + +import androidx.compose.animation.animateColorAsState +import androidx.compose.animation.core.Spring +import androidx.compose.animation.core.spring +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Camera +import androidx.compose.material.icons.filled.Person +import androidx.compose.material.icons.filled.Videocam +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.RadioButton +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import app.myzel394.alibi.R +import app.myzel394.alibi.ui.utils.CameraInfo + +@Composable +fun CameraSelectionButton( + cameraID: CameraInfo.Lens, + selected: Boolean, + onSelected: () -> Unit, + label: String, + description: String? = null, +) { + val backgroundColor by animateColorAsState( + targetValue = if (selected) MaterialTheme.colorScheme.secondaryContainer.copy( + alpha = 0.2f + ) else Color.Transparent, + // Make animation about 0.5x faster than default + animationSpec = spring( + stiffness = Spring.StiffnessLow, + dampingRatio = Spring.DampingRatioNoBouncy, + ), + label = "backgroundColor" + ) + + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + modifier = Modifier + .fillMaxWidth() + .clip(MaterialTheme.shapes.medium) + .clickable(onClick = onSelected) + .background(backgroundColor) + .padding(vertical = 8.dp, horizontal = 12.dp) + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + ) { + RadioButton( + selected = selected, + onClick = onSelected, + ) + if (description == null) { + Text( + label, + style = MaterialTheme.typography.labelLarge, + ) + } else { + Column( + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + Text( + label, + style = MaterialTheme.typography.labelLarge, + ) + Text( + description, + style = MaterialTheme.typography.bodySmall, + ) + } + } + } + Icon( + CAMERA_LENS_ICON_MAP[cameraID]!!, + contentDescription = null, + modifier = Modifier + .size(24.dp), + ) + } +} + +val CAMERA_LENS_ICON_MAP = mapOf( + CameraInfo.Lens.BACK to Icons.Default.Camera, + CameraInfo.Lens.FRONT to Icons.Default.Person, + CameraInfo.Lens.EXTERNAL to Icons.Default.Videocam, +) diff --git a/app/src/main/java/app/myzel394/alibi/ui/components/AudioRecorder/atoms/VideoRecorderPreparationSheet.kt b/app/src/main/java/app/myzel394/alibi/ui/components/AudioRecorder/atoms/VideoRecorderPreparationSheet.kt deleted file mode 100644 index 0cb9b13..0000000 --- a/app/src/main/java/app/myzel394/alibi/ui/components/AudioRecorder/atoms/VideoRecorderPreparationSheet.kt +++ /dev/null @@ -1,11 +0,0 @@ -package app.myzel394.alibi.ui.components.AudioRecorder.atoms - -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.rememberModalBottomSheetState -import androidx.compose.runtime.Composable - -@OptIn(ExperimentalMaterial3Api::class) -@Composable -fun VideoRecorderPreparationSheet() { - val sheetState = rememberModalBottomSheetState() -} \ No newline at end of file diff --git a/app/src/main/java/app/myzel394/alibi/ui/components/AudioRecorder/molecules/CamerasSelection.kt b/app/src/main/java/app/myzel394/alibi/ui/components/AudioRecorder/molecules/CamerasSelection.kt new file mode 100644 index 0000000..527da47 --- /dev/null +++ b/app/src/main/java/app/myzel394/alibi/ui/components/AudioRecorder/molecules/CamerasSelection.kt @@ -0,0 +1,73 @@ +package app.myzel394.alibi.ui.components.AudioRecorder.molecules + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Camera +import androidx.compose.material.icons.filled.Person +import androidx.compose.material.icons.filled.Videocam +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.RadioButton +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import app.myzel394.alibi.R +import app.myzel394.alibi.ui.components.AudioRecorder.atoms.CameraSelectionButton +import app.myzel394.alibi.ui.models.VideoRecorderSettingsModel +import app.myzel394.alibi.ui.utils.CameraInfo + +@Composable +fun CamerasSelection( + cameras: Iterable, + videoSettings: VideoRecorderSettingsModel +) { + val CAMERA_LENS_TEXT_MAP = mapOf( + CameraInfo.Lens.BACK to stringResource(R.string.ui_videoRecorder_action_start_settings_cameraLens_back_label), + CameraInfo.Lens.FRONT to stringResource(R.string.ui_videoRecorder_action_start_settings_cameraLens_front_label), + CameraInfo.Lens.EXTERNAL to stringResource(R.string.ui_videoRecorder_action_start_settings_cameraLens_external_label), + ) + + Column { + if (cameras.count() == 2 && cameras.elementAt(0).id == 0 && cameras.elementAt(1).id == 1) { + CameraSelectionButton( + cameraID = CameraInfo.Lens.BACK, + label = stringResource(R.string.ui_videoRecorder_action_start_settings_cameraLens_back_label), + selected = videoSettings.cameraID == 0, + onSelected = { + videoSettings.cameraID = 0 + }, + ) + CameraSelectionButton( + cameraID = CameraInfo.Lens.FRONT, + label = stringResource(R.string.ui_videoRecorder_action_start_settings_cameraLens_front_label), + selected = videoSettings.cameraID == 1, + onSelected = { + videoSettings.cameraID = 1 + }, + ) + } else { + cameras.forEach { camera -> + CameraSelectionButton( + cameraID = CameraInfo.CAMERA_INT_TO_LENS_MAP[camera.id]!!, + selected = videoSettings.cameraID == camera.id, + onSelected = { + videoSettings.cameraID = camera.id + }, + label = stringResource( + R.string.ui_videoRecorder_action_start_settings_cameraLens_label, + camera.id + ), + description = CAMERA_LENS_TEXT_MAP[camera.lens]!!, + ) + } + } + } +} \ 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 new file mode 100644 index 0000000..eb96d69 --- /dev/null +++ b/app/src/main/java/app/myzel394/alibi/ui/components/AudioRecorder/molecules/VideoRecorderPreparationSheet.kt @@ -0,0 +1,109 @@ +package app.myzel394.alibi.ui.components.AudioRecorder.molecules + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +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.material.icons.Icons +import androidx.compose.material.icons.filled.Camera +import androidx.compose.material.icons.filled.CameraAlt +import androidx.compose.material.icons.filled.Person +import androidx.compose.material.icons.filled.Videocam +import androidx.compose.material3.Button +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.RadioButton +import androidx.compose.material3.Text +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +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.lifecycle.viewmodel.compose.viewModel +import app.myzel394.alibi.R +import app.myzel394.alibi.ui.BIG_PRIMARY_BUTTON_SIZE +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) +@Composable +fun VideoRecorderPreparationSheet( + onDismiss: () -> Unit, + videoSettings: VideoRecorderSettingsModel = viewModel() +) { + val sheetState = rememberModalBottomSheetState(true) + + val context = LocalContext.current + val cameras = CameraInfo.queryAvailableCameras(context) + + ModalBottomSheet( + onDismissRequest = onDismiss, + sheetState = sheetState, + ) { + Column( + modifier = Modifier + .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, + ) + + val label = stringResource(R.string.ui_videoRecorder_action_start_settings_start_label) + Button( + onClick = {}, + modifier = Modifier + .padding(16.dp) + .fillMaxWidth() + .height(BIG_PRIMARY_BUTTON_SIZE) + .semantics { + contentDescription = label + } + ) { + Text(label) + } + } + } +} \ No newline at end of file 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 c1e9f64..5e2be62 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 @@ -15,7 +15,6 @@ import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.saveable.rememberSaveable @@ -31,7 +30,6 @@ import androidx.compose.ui.unit.dp import app.myzel394.alibi.R import app.myzel394.alibi.db.AppSettings import app.myzel394.alibi.ui.components.atoms.PermissionRequester -import app.myzel394.alibi.ui.models.AudioRecorderModel import app.myzel394.alibi.ui.models.VideoRecorderModel @Composable @@ -41,24 +39,24 @@ fun VideoRecordingStart( ) { val context = LocalContext.current - // We can't get the current `notificationDetails` inside the - // `onPermissionAvailable` function. We'll instead use this hack - // with `LaunchedEffect` to get the current value. - var startRecording by rememberSaveable { mutableStateOf(false) } - LaunchedEffect(startRecording) { - if (startRecording) { - startRecording = false + var showSheet by rememberSaveable { + mutableStateOf(false) + } - videoRecorder.startRecording(context, appSettings) - } + if (showSheet) { + VideoRecorderPreparationSheet( + onDismiss = { + showSheet = false + }, + ) } PermissionRequester( permission = Manifest.permission.RECORD_AUDIO, icon = Icons.Default.Mic, onPermissionAvailable = { - startRecording = true - } + showSheet = true + }, ) { trigger -> val label = stringResource(R.string.ui_videoRecorder_action_start_label) diff --git a/app/src/main/java/app/myzel394/alibi/ui/models/VideoRecorderSettingsModel.kt b/app/src/main/java/app/myzel394/alibi/ui/models/VideoRecorderSettingsModel.kt new file mode 100644 index 0000000..fd376d5 --- /dev/null +++ b/app/src/main/java/app/myzel394/alibi/ui/models/VideoRecorderSettingsModel.kt @@ -0,0 +1,15 @@ +package app.myzel394.alibi.ui.models + +import android.graphics.Camera +import android.hardware.camera2.CameraManager +import androidx.camera.core.CameraSelector +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.lifecycle.ViewModel + +class VideoRecorderSettingsModel : ViewModel() { + var enableAudio by mutableStateOf(true) + var cameraID by mutableIntStateOf(0) +} \ No newline at end of file diff --git a/app/src/main/java/app/myzel394/alibi/ui/utils/camera-info.kt b/app/src/main/java/app/myzel394/alibi/ui/utils/camera-info.kt index 1aebb0f..050c467 100644 --- a/app/src/main/java/app/myzel394/alibi/ui/utils/camera-info.kt +++ b/app/src/main/java/app/myzel394/alibi/ui/utils/camera-info.kt @@ -1,2 +1,44 @@ package app.myzel394.alibi.ui.utils +import android.content.Context +import android.hardware.camera2.CameraCharacteristics +import android.hardware.camera2.CameraManager +import android.hardware.camera2.CameraMetadata + +data class CameraInfo( + val lens: Lens, + val id: Int, +) { + enum class Lens(val androidValue: Int) { + BACK(CameraMetadata.LENS_FACING_BACK), + FRONT(CameraMetadata.LENS_FACING_FRONT), + EXTERNAL(CameraMetadata.LENS_FACING_EXTERNAL), + } + + companion object { + val CAMERA_INT_TO_LENS_MAP = mapOf( + 0 to Lens.BACK, + 1 to Lens.FRONT, + 2 to Lens.EXTERNAL, + ) + + fun queryAvailableCameras(context: Context): List { + val camera = context.getSystemService(Context.CAMERA_SERVICE) as CameraManager + + return camera.cameraIdList.map { id -> + val lensFacing = + camera.getCameraCharacteristics(id).get(CameraCharacteristics.LENS_FACING) + ?: return@map null + + fromCameraId(id, lensFacing) + }.filterNotNull() + } + + fun fromCameraId(cameraId: String, lensFacing: Int): CameraInfo { + return CameraInfo( + lens = CAMERA_INT_TO_LENS_MAP[lensFacing]!!, + id = cameraId.toInt(), + ) + } + } +} \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 2f0e955..c8e98dd 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -139,4 +139,12 @@ Standard Lowest Start Video Recording + Record with Audio + Back facing + Front facing + External Camera + Camera %s + Start Recording + Prepare your recording + Select camera \ No newline at end of file