diff --git a/core/camera/src/androidTest/java/com/google/jetpackcamera/core/camera/CameraXCameraUseCaseTest.kt b/core/camera/src/androidTest/java/com/google/jetpackcamera/core/camera/CameraXCameraUseCaseTest.kt index a8e0f3635..02335dee7 100644 --- a/core/camera/src/androidTest/java/com/google/jetpackcamera/core/camera/CameraXCameraUseCaseTest.kt +++ b/core/camera/src/androidTest/java/com/google/jetpackcamera/core/camera/CameraXCameraUseCaseTest.kt @@ -168,7 +168,7 @@ class CameraXCameraUseCaseTest { iODispatcher = Dispatchers.IO, constraintsRepository = constraintsRepository ).apply { - initialize(appSettings, CameraUseCase.UseCaseMode.STANDARD) {} + initialize(appSettings) {} providePreviewSurface() } diff --git a/core/camera/src/main/java/com/google/jetpackcamera/core/camera/CameraSession.kt b/core/camera/src/main/java/com/google/jetpackcamera/core/camera/CameraSession.kt index c13359583..f751e4b5d 100644 --- a/core/camera/src/main/java/com/google/jetpackcamera/core/camera/CameraSession.kt +++ b/core/camera/src/main/java/com/google/jetpackcamera/core/camera/CameraSession.kt @@ -68,6 +68,7 @@ import androidx.core.content.ContextCompat.checkSelfPermission import androidx.lifecycle.asFlow import com.google.jetpackcamera.core.camera.effects.SingleSurfaceForcingEffect import com.google.jetpackcamera.settings.model.AspectRatio +import com.google.jetpackcamera.settings.model.CaptureMode import com.google.jetpackcamera.settings.model.DeviceRotation import com.google.jetpackcamera.settings.model.DynamicRange import com.google.jetpackcamera.settings.model.FlashMode @@ -115,7 +116,6 @@ private val QUALITY_RANGE_MAP = mapOf( context(CameraSessionContext) internal suspend fun runSingleCameraSession( sessionSettings: PerpetualSessionSettings.SingleCamera, - useCaseMode: CameraUseCase.UseCaseMode, // TODO(tm): ImageCapture should go through an event channel like VideoCapture onImageCaptureCreated: (ImageCapture) -> Unit = {} ) = coroutineScope { @@ -124,8 +124,8 @@ internal suspend fun runSingleCameraSession( val initialCameraSelector = transientSettings.filterNotNull().first() .primaryLensFacing.toCameraSelector() - val videoCaptureUseCase = when (useCaseMode) { - CameraUseCase.UseCaseMode.STANDARD, CameraUseCase.UseCaseMode.VIDEO_ONLY -> + val videoCaptureUseCase = when (sessionSettings.captureMode) { + CaptureMode.STANDARD, CaptureMode.VIDEO_ONLY -> createVideoUseCase( cameraProvider.getCameraInfo(initialCameraSelector), sessionSettings.aspectRatio, @@ -135,7 +135,6 @@ internal suspend fun runSingleCameraSession( sessionSettings.videoQuality, backgroundDispatcher ) - else -> { null } @@ -164,7 +163,7 @@ internal suspend fun runSingleCameraSession( aspectRatio = sessionSettings.aspectRatio, dynamicRange = sessionSettings.dynamicRange, imageFormat = sessionSettings.imageFormat, - useCaseMode = useCaseMode, + captureMode = sessionSettings.captureMode, effect = when (sessionSettings.streamConfig) { StreamConfig.SINGLE_STREAM -> SingleSurfaceForcingEffect(this@coroutineScope) StreamConfig.MULTI_STREAM -> null @@ -359,7 +358,7 @@ internal fun createUseCaseGroup( videoCaptureUseCase: VideoCapture?, dynamicRange: DynamicRange, imageFormat: ImageOutputFormat, - useCaseMode: CameraUseCase.UseCaseMode, + captureMode: CaptureMode, effect: CameraEffect? = null ): UseCaseGroup { val previewUseCase = @@ -368,7 +367,7 @@ internal fun createUseCaseGroup( aspectRatio, stabilizationMode ) - val imageCaptureUseCase = if (useCaseMode != CameraUseCase.UseCaseMode.VIDEO_ONLY) { + val imageCaptureUseCase = if (captureMode != CaptureMode.VIDEO_ONLY) { createImageUseCase(cameraInfo, aspectRatio, dynamicRange, imageFormat) } else { null diff --git a/core/camera/src/main/java/com/google/jetpackcamera/core/camera/CameraSessionSettings.kt b/core/camera/src/main/java/com/google/jetpackcamera/core/camera/CameraSessionSettings.kt index 898ab5d24..01f803869 100644 --- a/core/camera/src/main/java/com/google/jetpackcamera/core/camera/CameraSessionSettings.kt +++ b/core/camera/src/main/java/com/google/jetpackcamera/core/camera/CameraSessionSettings.kt @@ -17,6 +17,7 @@ package com.google.jetpackcamera.core.camera import androidx.camera.core.CameraInfo import com.google.jetpackcamera.settings.model.AspectRatio +import com.google.jetpackcamera.settings.model.CaptureMode import com.google.jetpackcamera.settings.model.DeviceRotation import com.google.jetpackcamera.settings.model.DynamicRange import com.google.jetpackcamera.settings.model.FlashMode @@ -34,9 +35,11 @@ import com.google.jetpackcamera.settings.model.VideoQuality */ internal sealed interface PerpetualSessionSettings { val aspectRatio: AspectRatio + val captureMode: CaptureMode data class SingleCamera( override val aspectRatio: AspectRatio, + override val captureMode: CaptureMode, val streamConfig: StreamConfig, val targetFrameRate: Int, val stabilizationMode: StabilizationMode, @@ -49,7 +52,9 @@ internal sealed interface PerpetualSessionSettings { val primaryCameraInfo: CameraInfo, val secondaryCameraInfo: CameraInfo, override val aspectRatio: AspectRatio - ) : PerpetualSessionSettings + ) : PerpetualSessionSettings { + override val captureMode: CaptureMode = CaptureMode.VIDEO_ONLY + } } /** diff --git a/core/camera/src/main/java/com/google/jetpackcamera/core/camera/CameraUseCase.kt b/core/camera/src/main/java/com/google/jetpackcamera/core/camera/CameraUseCase.kt index 1a5558c14..9a52393c4 100644 --- a/core/camera/src/main/java/com/google/jetpackcamera/core/camera/CameraUseCase.kt +++ b/core/camera/src/main/java/com/google/jetpackcamera/core/camera/CameraUseCase.kt @@ -21,6 +21,7 @@ import androidx.camera.core.ImageCapture import androidx.camera.core.SurfaceRequest import com.google.jetpackcamera.settings.model.AspectRatio import com.google.jetpackcamera.settings.model.CameraAppSettings +import com.google.jetpackcamera.settings.model.CaptureMode import com.google.jetpackcamera.settings.model.ConcurrentCameraMode import com.google.jetpackcamera.settings.model.DeviceRotation import com.google.jetpackcamera.settings.model.DynamicRange @@ -45,7 +46,6 @@ interface CameraUseCase { */ suspend fun initialize( cameraAppSettings: CameraAppSettings, - useCaseMode: UseCaseMode, isDebugMode: Boolean = false, cameraPropertiesJSONCallback: (result: String) -> Unit ) @@ -125,6 +125,7 @@ interface CameraUseCase { suspend fun setTargetFrameRate(targetFrameRate: Int) suspend fun setMaxVideoDuration(durationInMillis: Long) + suspend fun setCaptureMode(captureMode: CaptureMode) /** * Represents the events required for screen flash. @@ -145,12 +146,6 @@ interface CameraUseCase { data class OnVideoRecordError(val error: Throwable) : OnVideoRecordEvent } - - enum class UseCaseMode { - STANDARD, - IMAGE_ONLY, - VIDEO_ONLY - } } sealed interface VideoRecordingState { diff --git a/core/camera/src/main/java/com/google/jetpackcamera/core/camera/CameraXCameraUseCase.kt b/core/camera/src/main/java/com/google/jetpackcamera/core/camera/CameraXCameraUseCase.kt index 050da3f9d..affc62ecf 100644 --- a/core/camera/src/main/java/com/google/jetpackcamera/core/camera/CameraXCameraUseCase.kt +++ b/core/camera/src/main/java/com/google/jetpackcamera/core/camera/CameraXCameraUseCase.kt @@ -43,6 +43,7 @@ import com.google.jetpackcamera.settings.model.CameraAppSettings import com.google.jetpackcamera.settings.model.CameraConstraints import com.google.jetpackcamera.settings.model.CameraConstraints.Companion.FPS_15 import com.google.jetpackcamera.settings.model.CameraConstraints.Companion.FPS_60 +import com.google.jetpackcamera.settings.model.CaptureMode import com.google.jetpackcamera.settings.model.ConcurrentCameraMode import com.google.jetpackcamera.settings.model.DeviceRotation import com.google.jetpackcamera.settings.model.DynamicRange @@ -62,7 +63,6 @@ import java.text.SimpleDateFormat import java.util.Calendar import java.util.Locale import javax.inject.Inject -import kotlin.properties.Delegates import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.coroutineScope @@ -101,7 +101,6 @@ constructor( private var imageCaptureUseCase: ImageCapture? = null private lateinit var systemConstraints: SystemConstraints - private var useCaseMode by Delegates.notNull() private val screenFlashEvents: Channel = Channel(capacity = Channel.UNLIMITED) @@ -121,11 +120,9 @@ constructor( override suspend fun initialize( cameraAppSettings: CameraAppSettings, - useCaseMode: CameraUseCase.UseCaseMode, isDebugMode: Boolean, cameraPropertiesJSONCallback: (result: String) -> Unit ) { - this.useCaseMode = useCaseMode cameraProvider = ProcessCameraProvider.awaitInstance(application) // updates values for available cameras @@ -258,12 +255,13 @@ constructor( currentSettings.value = cameraAppSettings .tryApplyDynamicRangeConstraints() - .tryApplyAspectRatioForExternalCapture(this.useCaseMode) + .tryApplyAspectRatioForExternalCapture(cameraAppSettings.captureMode) .tryApplyImageFormatConstraints() .tryApplyFrameRateConstraints() .tryApplyStabilizationConstraints() .tryApplyConcurrentCameraModeConstraints() .tryApplyFlashModeConstraints() + .tryApplyCaptureModeConstraints() .tryApplyVideoQualityConstraints() if (isDebugMode && Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { withContext(iODispatcher) { @@ -314,6 +312,7 @@ constructor( PerpetualSessionSettings.SingleCamera( aspectRatio = currentCameraSettings.aspectRatio, + captureMode = currentCameraSettings.captureMode, streamConfig = currentCameraSettings.streamConfig, targetFrameRate = currentCameraSettings.targetFrameRate, stabilizationMode = resolvedStabilizationMode, @@ -369,7 +368,6 @@ constructor( when (sessionSettings) { is PerpetualSessionSettings.SingleCamera -> runSingleCameraSession( sessionSettings, - useCaseMode = useCaseMode, onImageCaptureCreated = { imageCapture -> imageCaptureUseCase = imageCapture } @@ -377,8 +375,7 @@ constructor( is PerpetualSessionSettings.ConcurrentCamera -> runConcurrentCameraSession( - sessionSettings, - useCaseMode = CameraUseCase.UseCaseMode.VIDEO_ONLY + sessionSettings ) } } finally { @@ -565,12 +562,53 @@ constructor( ?.tryApplyDynamicRangeConstraints() ?.tryApplyImageFormatConstraints() ?.tryApplyFlashModeConstraints() + ?.tryApplyCaptureModeConstraints() } else { old } } } + private fun CameraAppSettings.tryApplyCaptureModeConstraints(): CameraAppSettings { + Log.d(TAG, "applying capture mode constraints") + systemConstraints.perLensConstraints[cameraLensFacing]?.let { constraints -> + val newCaptureMode = + if (concurrentCameraMode == ConcurrentCameraMode.DUAL) { + Log.d(TAG, "CONCURRENT CAMERA CAPTURE MODE") + CaptureMode.VIDEO_ONLY + } else if (dynamicRange == DynamicRange.HLG10 || + imageFormat == ImageOutputFormat.JPEG_ULTRA_HDR + ) { + if (constraints.supportedDynamicRanges.contains(DynamicRange.HLG10)) { + if (constraints.supportedImageFormatsMap[streamConfig] + ?.contains(ImageOutputFormat.JPEG_ULTRA_HDR) == true + ) { + // if both image/video are supported, only change capture mode if default is the current + if (this.captureMode != CaptureMode.STANDARD) { + this.captureMode + } else { + CaptureMode.VIDEO_ONLY + } + } else { + // if only video is supported, change to video only + CaptureMode.VIDEO_ONLY + } + } else { + // if only image is supported, change to image only + CaptureMode.IMAGE_ONLY + } + } else { + // if no dynamic range value is set, its OK to return the current value + return this + } + Log.d(TAG, "new capture mode $newCaptureMode") + return this@tryApplyCaptureModeConstraints.copy( + captureMode = newCaptureMode + ) + } + ?: return this + } + private fun CameraAppSettings.tryApplyDynamicRangeConstraints(): CameraAppSettings = systemConstraints.perLensConstraints[cameraLensFacing]?.let { constraints -> with(constraints.supportedDynamicRanges) { @@ -587,13 +625,12 @@ constructor( } ?: this private fun CameraAppSettings.tryApplyAspectRatioForExternalCapture( - useCaseMode: CameraUseCase.UseCaseMode - ): CameraAppSettings = when (useCaseMode) { - CameraUseCase.UseCaseMode.STANDARD -> this - CameraUseCase.UseCaseMode.IMAGE_ONLY -> + captureMode: CaptureMode + ): CameraAppSettings = when (captureMode) { + CaptureMode.STANDARD -> this + CaptureMode.IMAGE_ONLY -> this.copy(aspectRatio = AspectRatio.THREE_FOUR) - - CameraUseCase.UseCaseMode.VIDEO_ONLY -> + CaptureMode.VIDEO_ONLY -> this.copy(aspectRatio = AspectRatio.NINE_SIXTEEN) } @@ -660,8 +697,8 @@ constructor( } } - private fun CameraAppSettings.tryApplyVideoQualityConstraints(): CameraAppSettings { - return systemConstraints.perLensConstraints[cameraLensFacing]?.let { constraints -> + private fun CameraAppSettings.tryApplyVideoQualityConstraints(): CameraAppSettings = + systemConstraints.perLensConstraints[cameraLensFacing]?.let { constraints -> with(constraints.supportedVideoQualitiesMap) { val newVideoQuality = get(dynamicRange).let { if (it == null) { @@ -678,7 +715,6 @@ constructor( ) } } ?: this - } private fun CameraAppSettings.tryApplyFlashModeConstraints(): CameraAppSettings = systemConstraints.perLensConstraints[cameraLensFacing]?.let { constraints -> @@ -730,6 +766,7 @@ constructor( old?.copy(streamConfig = streamConfig) ?.tryApplyImageFormatConstraints() ?.tryApplyConcurrentCameraModeConstraints() + ?.tryApplyCaptureModeConstraints() ?.tryApplyVideoQualityConstraints() } } @@ -738,6 +775,7 @@ constructor( currentSettings.update { old -> old?.copy(dynamicRange = dynamicRange) ?.tryApplyConcurrentCameraModeConstraints() + ?.tryApplyCaptureModeConstraints() } } @@ -751,12 +789,14 @@ constructor( currentSettings.update { old -> old?.copy(concurrentCameraMode = concurrentCameraMode) ?.tryApplyConcurrentCameraModeConstraints() + ?.tryApplyCaptureModeConstraints() } } override suspend fun setImageFormat(imageFormat: ImageOutputFormat) { currentSettings.update { old -> old?.copy(imageFormat = imageFormat) + ?.tryApplyCaptureModeConstraints() } } @@ -768,6 +808,10 @@ constructor( } } + override suspend fun setCaptureMode(captureMode: CaptureMode) { + currentSettings.update { old -> old?.copy(captureMode = captureMode) } + } + override suspend fun setStabilizationMode(stabilizationMode: StabilizationMode) { currentSettings.update { old -> old?.copy(stabilizationMode = stabilizationMode) diff --git a/core/camera/src/main/java/com/google/jetpackcamera/core/camera/ConcurrentCameraSession.kt b/core/camera/src/main/java/com/google/jetpackcamera/core/camera/ConcurrentCameraSession.kt index c38235f81..61b4d119f 100644 --- a/core/camera/src/main/java/com/google/jetpackcamera/core/camera/ConcurrentCameraSession.kt +++ b/core/camera/src/main/java/com/google/jetpackcamera/core/camera/ConcurrentCameraSession.kt @@ -20,6 +20,7 @@ import android.util.Log import androidx.camera.core.CompositionSettings import androidx.camera.core.TorchState import androidx.lifecycle.asFlow +import com.google.jetpackcamera.settings.model.CaptureMode import com.google.jetpackcamera.settings.model.DynamicRange import com.google.jetpackcamera.settings.model.ImageOutputFormat import com.google.jetpackcamera.settings.model.StabilizationMode @@ -36,8 +37,7 @@ private const val TAG = "ConcurrentCameraSession" context(CameraSessionContext) @SuppressLint("RestrictedApi") internal suspend fun runConcurrentCameraSession( - sessionSettings: PerpetualSessionSettings.ConcurrentCamera, - useCaseMode: CameraUseCase.UseCaseMode + sessionSettings: PerpetualSessionSettings.ConcurrentCamera ) = coroutineScope { val primaryLensFacing = sessionSettings.primaryCameraInfo.appLensFacing val secondaryLensFacing = sessionSettings.secondaryCameraInfo.appLensFacing @@ -51,7 +51,7 @@ internal suspend fun runConcurrentCameraSession( .filterNotNull() .first() - val videoCapture = if (useCaseMode != CameraUseCase.UseCaseMode.IMAGE_ONLY) { + val videoCapture = if (sessionSettings.captureMode != CaptureMode.IMAGE_ONLY) { createVideoUseCase( cameraProvider.getCameraInfo( initialTransientSettings.primaryLensFacing.toCameraSelector() @@ -74,7 +74,7 @@ internal suspend fun runConcurrentCameraSession( aspectRatio = sessionSettings.aspectRatio, dynamicRange = DynamicRange.SDR, imageFormat = ImageOutputFormat.JPEG, - useCaseMode = useCaseMode, + captureMode = sessionSettings.captureMode, videoCaptureUseCase = videoCapture ) diff --git a/core/camera/src/main/java/com/google/jetpackcamera/core/camera/test/FakeCameraUseCase.kt b/core/camera/src/main/java/com/google/jetpackcamera/core/camera/test/FakeCameraUseCase.kt index b6c5fa702..ddd5174b2 100644 --- a/core/camera/src/main/java/com/google/jetpackcamera/core/camera/test/FakeCameraUseCase.kt +++ b/core/camera/src/main/java/com/google/jetpackcamera/core/camera/test/FakeCameraUseCase.kt @@ -24,6 +24,7 @@ import com.google.jetpackcamera.core.camera.CameraState import com.google.jetpackcamera.core.camera.CameraUseCase import com.google.jetpackcamera.settings.model.AspectRatio import com.google.jetpackcamera.settings.model.CameraAppSettings +import com.google.jetpackcamera.settings.model.CaptureMode import com.google.jetpackcamera.settings.model.ConcurrentCameraMode import com.google.jetpackcamera.settings.model.DeviceRotation import com.google.jetpackcamera.settings.model.DynamicRange @@ -63,7 +64,6 @@ class FakeCameraUseCase(defaultCameraSettings: CameraAppSettings = CameraAppSett override suspend fun initialize( cameraAppSettings: CameraAppSettings, - useCaseMode: CameraUseCase.UseCaseMode, isDebugMode: Boolean, cameraPropertiesJSONCallback: (result: String) -> Unit ) { @@ -248,4 +248,10 @@ class FakeCameraUseCase(defaultCameraSettings: CameraAppSettings = CameraAppSett old.copy(maxVideoDurationMillis = durationInMillis) } } + + override suspend fun setCaptureMode(captureMode: CaptureMode) { + currentSettings.update { old -> + old.copy(captureMode = captureMode) + } + } } diff --git a/core/camera/src/test/java/com/google/jetpackcamera/core/camera/test/FakeCameraUseCaseTest.kt b/core/camera/src/test/java/com/google/jetpackcamera/core/camera/test/FakeCameraUseCaseTest.kt index 6ab07a8c3..09f9d2ffe 100644 --- a/core/camera/src/test/java/com/google/jetpackcamera/core/camera/test/FakeCameraUseCaseTest.kt +++ b/core/camera/src/test/java/com/google/jetpackcamera/core/camera/test/FakeCameraUseCaseTest.kt @@ -56,8 +56,7 @@ class FakeCameraUseCaseTest { @Test fun canInitialize() = runTest(testDispatcher) { cameraUseCase.initialize( - cameraAppSettings = DEFAULT_CAMERA_APP_SETTINGS, - useCaseMode = CameraUseCase.UseCaseMode.STANDARD + cameraAppSettings = DEFAULT_CAMERA_APP_SETTINGS ) {} } @@ -150,8 +149,7 @@ class FakeCameraUseCaseTest { private fun TestScope.initAndRunCamera() { backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) { cameraUseCase.initialize( - cameraAppSettings = DEFAULT_CAMERA_APP_SETTINGS, - useCaseMode = CameraUseCase.UseCaseMode.STANDARD + cameraAppSettings = DEFAULT_CAMERA_APP_SETTINGS ) {} cameraUseCase.runCamera() } diff --git a/data/settings/src/main/java/com/google/jetpackcamera/settings/model/CameraAppSettings.kt b/data/settings/src/main/java/com/google/jetpackcamera/settings/model/CameraAppSettings.kt index 892e0d752..494ed5b30 100644 --- a/data/settings/src/main/java/com/google/jetpackcamera/settings/model/CameraAppSettings.kt +++ b/data/settings/src/main/java/com/google/jetpackcamera/settings/model/CameraAppSettings.kt @@ -24,6 +24,7 @@ val DEFAULT_HDR_IMAGE_OUTPUT = ImageOutputFormat.JPEG_ULTRA_HDR * Data layer representation for settings. */ data class CameraAppSettings( + val captureMode: CaptureMode = CaptureMode.STANDARD, val cameraLensFacing: LensFacing = LensFacing.BACK, val darkMode: DarkMode = DarkMode.SYSTEM, val flashMode: FlashMode = FlashMode.OFF, diff --git a/data/settings/src/main/java/com/google/jetpackcamera/settings/model/CaptureMode.kt b/data/settings/src/main/java/com/google/jetpackcamera/settings/model/CaptureMode.kt new file mode 100644 index 000000000..ada8b5eb3 --- /dev/null +++ b/data/settings/src/main/java/com/google/jetpackcamera/settings/model/CaptureMode.kt @@ -0,0 +1,49 @@ +/* + * Copyright (C) 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.google.jetpackcamera.settings.model + +/** + * Class representing the app's configuration to capture an image + */ +enum class CaptureMode { + + /** + * Both Image and Video use cases will be bound. + * + * Tap the Capture Button to take an image. + * + * Hold the Capture button to start recording, and release to complete the recording. + */ + STANDARD, + + /** + * Video use case will be bound. Image use case will not be bound. + * + * Tap the Capture Button to start recording. + * Hold the Capture button to start recording; releasing will not stop the recording. + * + * Tap the capture button again after recording has started to complete the recording. + */ + VIDEO_ONLY, + + /** + * Image use case will be bound. Video use case will not be bound. + * + * Tap the Capture Button to capture an Image. + * Holding the Capture Button will do nothing. Subsequent release of the Capture button will also do nothing. + */ + IMAGE_ONLY +} diff --git a/feature/preview/src/main/java/com/google/jetpackcamera/feature/preview/CaptureModeToggleUiState.kt b/feature/preview/src/main/java/com/google/jetpackcamera/feature/preview/CaptureModeUiState.kt similarity index 56% rename from feature/preview/src/main/java/com/google/jetpackcamera/feature/preview/CaptureModeToggleUiState.kt rename to feature/preview/src/main/java/com/google/jetpackcamera/feature/preview/CaptureModeUiState.kt index 04b7a5e43..c02e4984b 100644 --- a/feature/preview/src/main/java/com/google/jetpackcamera/feature/preview/CaptureModeToggleUiState.kt +++ b/feature/preview/src/main/java/com/google/jetpackcamera/feature/preview/CaptureModeUiState.kt @@ -19,12 +19,75 @@ import com.google.jetpackcamera.feature.preview.ui.HDR_IMAGE_UNSUPPORTED_ON_DEVI import com.google.jetpackcamera.feature.preview.ui.HDR_IMAGE_UNSUPPORTED_ON_LENS_TAG import com.google.jetpackcamera.feature.preview.ui.HDR_IMAGE_UNSUPPORTED_ON_MULTI_STREAM_TAG import com.google.jetpackcamera.feature.preview.ui.HDR_IMAGE_UNSUPPORTED_ON_SINGLE_STREAM_TAG +import com.google.jetpackcamera.feature.preview.ui.HDR_SIMULTANEOUS_IMAGE_VIDEO_UNSUPPORTED_TAG import com.google.jetpackcamera.feature.preview.ui.HDR_VIDEO_UNSUPPORTED_ON_DEVICE_TAG import com.google.jetpackcamera.feature.preview.ui.HDR_VIDEO_UNSUPPORTED_ON_LENS_TAG import com.google.jetpackcamera.feature.preview.ui.IMAGE_CAPTURE_EXTERNAL_UNSUPPORTED_TAG import com.google.jetpackcamera.feature.preview.ui.IMAGE_CAPTURE_UNSUPPORTED_CONCURRENT_CAMERA_TAG import com.google.jetpackcamera.feature.preview.ui.VIDEO_CAPTURE_EXTERNAL_UNSUPPORTED_TAG +import com.google.jetpackcamera.settings.model.CaptureMode +sealed interface CaptureModeUiState { + data object Unavailable : CaptureModeUiState + data class Enabled( + val currentSelection: CaptureMode, + val defaultCaptureState: SingleSelectableState = SingleSelectableState.Selectable, + val videoOnlyCaptureState: SingleSelectableState = SingleSelectableState.Selectable, + val imageOnlyCaptureState: SingleSelectableState = SingleSelectableState.Selectable + ) : CaptureModeUiState +} + +enum class DisabledReason(val testTag: String, val reasonTextResId: Int) { + VIDEO_CAPTURE_EXTERNAL_UNSUPPORTED( + VIDEO_CAPTURE_EXTERNAL_UNSUPPORTED_TAG, + R.string.toast_video_capture_external_unsupported + ), + IMAGE_CAPTURE_EXTERNAL_UNSUPPORTED( + IMAGE_CAPTURE_EXTERNAL_UNSUPPORTED_TAG, + R.string.toast_image_capture_external_unsupported + + ), + IMAGE_CAPTURE_UNSUPPORTED_CONCURRENT_CAMERA( + IMAGE_CAPTURE_UNSUPPORTED_CONCURRENT_CAMERA_TAG, + R.string.toast_image_capture_unsupported_concurrent_camera + ), + HDR_VIDEO_UNSUPPORTED_ON_DEVICE( + HDR_VIDEO_UNSUPPORTED_ON_DEVICE_TAG, + R.string.toast_hdr_video_unsupported_on_device + ), + HDR_VIDEO_UNSUPPORTED_ON_LENS( + HDR_VIDEO_UNSUPPORTED_ON_LENS_TAG, + R.string.toast_hdr_video_unsupported_on_lens + ), + HDR_IMAGE_UNSUPPORTED_ON_DEVICE( + HDR_IMAGE_UNSUPPORTED_ON_DEVICE_TAG, + R.string.toast_hdr_photo_unsupported_on_device + ), + HDR_IMAGE_UNSUPPORTED_ON_LENS( + HDR_IMAGE_UNSUPPORTED_ON_LENS_TAG, + R.string.toast_hdr_photo_unsupported_on_lens + ), + HDR_IMAGE_UNSUPPORTED_ON_SINGLE_STREAM( + HDR_IMAGE_UNSUPPORTED_ON_SINGLE_STREAM_TAG, + R.string.toast_hdr_photo_unsupported_on_lens_single_stream + ), + HDR_IMAGE_UNSUPPORTED_ON_MULTI_STREAM( + HDR_IMAGE_UNSUPPORTED_ON_MULTI_STREAM_TAG, + R.string.toast_hdr_photo_unsupported_on_lens_multi_stream + ), + HDR_SIMULTANEOUS_IMAGE_VIDEO_UNSUPPORTED( + HDR_SIMULTANEOUS_IMAGE_VIDEO_UNSUPPORTED_TAG, + R.string.toast_hdr_simultaneous_image_video_unsupported + ) +} + +/** State for the individual options on Popup dialog settings */ +sealed interface SingleSelectableState { + data object Selectable : SingleSelectableState + data class Disabled(val disabledReason: DisabledReason) : SingleSelectableState +} + +/* sealed interface CaptureModeToggleUiState { data object Invisible : CaptureModeToggleUiState @@ -35,10 +98,8 @@ sealed interface CaptureModeToggleUiState { data class Enabled(override val currentMode: ToggleMode) : Visible - data class Disabled( - override val currentMode: ToggleMode, - val disabledReason: DisabledReason - ) : Visible + data class Disabled(override val currentMode: ToggleMode, val disabledReason: DisabledReason) : + Visible enum class DisabledReason(val testTag: String, val reasonTextResId: Int) { VIDEO_CAPTURE_EXTERNAL_UNSUPPORTED( @@ -84,4 +145,5 @@ sealed interface CaptureModeToggleUiState { CAPTURE_TOGGLE_IMAGE, CAPTURE_TOGGLE_VIDEO } -} + } + */ diff --git a/feature/preview/src/main/java/com/google/jetpackcamera/feature/preview/PreviewScreen.kt b/feature/preview/src/main/java/com/google/jetpackcamera/feature/preview/PreviewScreen.kt index d94cfe4e2..4cdc7202a 100644 --- a/feature/preview/src/main/java/com/google/jetpackcamera/feature/preview/PreviewScreen.kt +++ b/feature/preview/src/main/java/com/google/jetpackcamera/feature/preview/PreviewScreen.kt @@ -51,7 +51,6 @@ import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.LifecycleStartEffect import androidx.tracing.Trace -import com.google.jetpackcamera.core.camera.VideoRecordingState import com.google.jetpackcamera.feature.preview.quicksettings.QuickSettingsScreenOverlay import com.google.jetpackcamera.feature.preview.ui.CameraControlsOverlay import com.google.jetpackcamera.feature.preview.ui.PreviewDisplay @@ -62,6 +61,7 @@ import com.google.jetpackcamera.feature.preview.ui.ZoomLevelDisplayState import com.google.jetpackcamera.feature.preview.ui.debouncedOrientationFlow import com.google.jetpackcamera.feature.preview.ui.debug.DebugOverlayComponent import com.google.jetpackcamera.settings.model.AspectRatio +import com.google.jetpackcamera.settings.model.CaptureMode import com.google.jetpackcamera.settings.model.ConcurrentCameraMode import com.google.jetpackcamera.settings.model.DEFAULT_CAMERA_APP_SETTINGS import com.google.jetpackcamera.settings.model.DynamicRange @@ -141,13 +141,14 @@ fun PreviewScreen( onSetLensFacing = viewModel::setLensFacing, onTapToFocus = viewModel::tapToFocus, onChangeZoomScale = viewModel::setZoomScale, + onSetCaptureMode = viewModel::setCaptureMode, onChangeFlash = viewModel::setFlash, onChangeAspectRatio = viewModel::setAspectRatio, - onChangeCaptureMode = viewModel::setCaptureMode, + onChangeCaptureMode = viewModel::setStreamConfig, onChangeDynamicRange = viewModel::setDynamicRange, onChangeConcurrentCameraMode = viewModel::setConcurrentCameraMode, onChangeImageFormat = viewModel::setImageFormat, - onToggleWhenDisabled = viewModel::showSnackBarForDisabledHdrToggle, + onDisabledCaptureMode = viewModel::showSnackBarForDisabledHdrToggle, onToggleQuickSettings = viewModel::toggleQuickSettings, onToggleDebugOverlay = viewModel::toggleDebugOverlay, onSetPause = viewModel::setPaused, @@ -155,6 +156,7 @@ fun PreviewScreen( onCaptureImageWithUri = viewModel::captureImageWithUri, onStartVideoRecording = viewModel::startVideoRecording, onStopVideoRecording = viewModel::stopVideoRecording, + onLockVideoRecording = viewModel::setLockedRecording, onToastShown = viewModel::onToastShown, onRequestWindowColorMode = onRequestWindowColorMode, onSnackBarResult = viewModel::onSnackBarResult, @@ -173,6 +175,7 @@ private fun ContentScreen( modifier: Modifier = Modifier, onNavigateToSettings: () -> Unit = {}, onClearUiScreenBrightness: (Float) -> Unit = {}, + onSetCaptureMode: (CaptureMode) -> Unit = {}, onSetLensFacing: (newLensFacing: LensFacing) -> Unit = {}, onTapToFocus: (x: Float, y: Float) -> Unit = { _, _ -> }, onChangeZoomScale: (Float) -> Unit = {}, @@ -182,7 +185,7 @@ private fun ContentScreen( onChangeDynamicRange: (DynamicRange) -> Unit = {}, onChangeConcurrentCameraMode: (ConcurrentCameraMode) -> Unit = {}, onChangeImageFormat: (ImageOutputFormat) -> Unit = {}, - onToggleWhenDisabled: (CaptureModeToggleUiState.DisabledReason) -> Unit = {}, + onDisabledCaptureMode: (DisabledReason) -> Unit = {}, onToggleQuickSettings: () -> Unit = {}, onToggleDebugOverlay: () -> Unit = {}, onSetPause: (Boolean) -> Unit = {}, @@ -199,6 +202,7 @@ private fun ContentScreen( (PreviewViewModel.VideoCaptureEvent) -> Unit ) -> Unit = { _, _, _ -> }, onStopVideoRecording: () -> Unit = {}, + onLockVideoRecording: (Boolean) -> Unit = {}, onToastShown: () -> Unit = {}, onRequestWindowColorMode: (Int) -> Unit = {}, onSnackBarResult: (String) -> Unit = {}, @@ -253,17 +257,19 @@ private fun ContentScreen( CameraControlsOverlay( previewUiState = previewUiState, onNavigateToSettings = onNavigateToSettings, + onSetCaptureMode = onSetCaptureMode, onFlipCamera = onFlipCamera, onChangeFlash = onChangeFlash, onToggleAudio = onToggleAudio, onToggleQuickSettings = onToggleQuickSettings, onToggleDebugOverlay = onToggleDebugOverlay, onChangeImageFormat = onChangeImageFormat, - onToggleWhenDisabled = onToggleWhenDisabled, + onDisabledCaptureMode = onDisabledCaptureMode, onSetPause = onSetPause, onCaptureImageWithUri = onCaptureImageWithUri, onStartVideoRecording = onStartVideoRecording, onStopVideoRecording = onStopVideoRecording, + onLockVideoRecording = onLockVideoRecording, zoomLevelDisplayState = remember { ZoomLevelDisplayState(isDebugMode) } ) @@ -343,8 +349,7 @@ private fun ContentScreen_WhileRecording() { private val FAKE_PREVIEW_UI_STATE_READY = PreviewUiState.Ready( currentCameraSettings = DEFAULT_CAMERA_APP_SETTINGS, - videoRecordingState = VideoRecordingState.Inactive(), systemConstraints = TYPICAL_SYSTEM_CONSTRAINTS, - previewMode = PreviewMode.StandardMode {}, - captureModeToggleUiState = CaptureModeToggleUiState.Invisible + previewMode = PreviewMode.StandardMode {} + // captureModeToggleUiState = CaptureModeToggleUiState.Invisible ) diff --git a/feature/preview/src/main/java/com/google/jetpackcamera/feature/preview/PreviewUiState.kt b/feature/preview/src/main/java/com/google/jetpackcamera/feature/preview/PreviewUiState.kt index 33817e970..52a976d77 100644 --- a/feature/preview/src/main/java/com/google/jetpackcamera/feature/preview/PreviewUiState.kt +++ b/feature/preview/src/main/java/com/google/jetpackcamera/feature/preview/PreviewUiState.kt @@ -20,6 +20,7 @@ import com.google.jetpackcamera.core.camera.VideoRecordingState import com.google.jetpackcamera.feature.preview.ui.SnackbarData import com.google.jetpackcamera.feature.preview.ui.ToastMessage import com.google.jetpackcamera.settings.model.CameraAppSettings +import com.google.jetpackcamera.settings.model.CaptureMode import com.google.jetpackcamera.settings.model.FlashMode import com.google.jetpackcamera.settings.model.StabilizationMode import com.google.jetpackcamera.settings.model.SystemConstraints @@ -38,14 +39,12 @@ sealed interface PreviewUiState { val zoomScale: Float = 1f, val videoRecordingState: VideoRecordingState = VideoRecordingState.Inactive(), val quickSettingsIsOpen: Boolean = false, - // val audioMuted: Boolean = false, // todo: remove after implementing post capture screen val toastMessageToShow: ToastMessage? = null, val snackBarToShow: SnackbarData? = null, val lastBlinkTimeStamp: Long = 0, val previewMode: PreviewMode = PreviewMode.StandardMode {}, - val captureModeToggleUiState: CaptureModeToggleUiState = CaptureModeToggleUiState.Invisible, val sessionFirstFrameTimestamp: Long = 0L, val currentPhysicalCameraId: String? = null, val currentLogicalCameraId: String? = null, @@ -53,7 +52,9 @@ sealed interface PreviewUiState { val stabilizationUiState: StabilizationUiState = StabilizationUiState.Disabled, val flashModeUiState: FlashModeUiState = FlashModeUiState.Unavailable, val videoQuality: VideoQuality = VideoQuality.UNSPECIFIED, - val audioUiState: AudioUiState = AudioUiState.Disabled + val audioUiState: AudioUiState = AudioUiState.Disabled, + val captureModeUiState: CaptureModeUiState = CaptureModeUiState.Unavailable, + val captureButtonUiState: CaptureButtonUiState = CaptureButtonUiState.Unavailable ) : PreviewUiState } @@ -65,6 +66,19 @@ data class DebugUiState( val isDebugMode: Boolean = false, val isDebugOverlayOpen: Boolean = false ) +val DEFAULT_CAPTURE_BUTTON_STATE = CaptureButtonUiState.Enabled.Idle(CaptureMode.STANDARD) + +sealed interface CaptureButtonUiState { + data object Unavailable : CaptureButtonUiState + sealed interface Enabled : CaptureButtonUiState { + data class Idle(val captureMode: CaptureMode) : Enabled + + sealed interface Recording : Enabled { + data object PressedRecording : Recording + data object LockedRecording : Recording + } + } +} sealed interface AudioUiState { val amplitude: Double @@ -101,9 +115,7 @@ sealed interface StabilizationUiState { } } - data class Auto( - override val stabilizationMode: StabilizationMode - ) : Enabled { + data class Auto(override val stabilizationMode: StabilizationMode) : Enabled { override val active = true } } diff --git a/feature/preview/src/main/java/com/google/jetpackcamera/feature/preview/PreviewViewModel.kt b/feature/preview/src/main/java/com/google/jetpackcamera/feature/preview/PreviewViewModel.kt index 59ab52fe6..85f7554d4 100644 --- a/feature/preview/src/main/java/com/google/jetpackcamera/feature/preview/PreviewViewModel.kt +++ b/feature/preview/src/main/java/com/google/jetpackcamera/feature/preview/PreviewViewModel.kt @@ -41,6 +41,7 @@ import com.google.jetpackcamera.settings.SettingsRepository import com.google.jetpackcamera.settings.model.AspectRatio import com.google.jetpackcamera.settings.model.CameraAppSettings import com.google.jetpackcamera.settings.model.CameraConstraints +import com.google.jetpackcamera.settings.model.CaptureMode import com.google.jetpackcamera.settings.model.ConcurrentCameraMode import com.google.jetpackcamera.settings.model.DeviceRotation import com.google.jetpackcamera.settings.model.DynamicRange @@ -70,6 +71,7 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.transform @@ -93,6 +95,7 @@ class PreviewViewModel @AssistedInject constructor( ) : ViewModel() { private val _previewUiState: MutableStateFlow = MutableStateFlow(PreviewUiState.NotReady) + private val lockedRecordingState: MutableStateFlow = MutableStateFlow(false) val previewUiState: StateFlow = _previewUiState.asStateFlow() @@ -116,12 +119,24 @@ class PreviewViewModel @AssistedInject constructor( // used to ensure we don't start the camera before initialization is complete. private var initializationDeferred: Deferred = viewModelScope.async { cameraUseCase.initialize( - cameraAppSettings = settingsRepository.defaultCameraAppSettings.first(), - previewMode.toUseCaseMode(), - isDebugMode + cameraAppSettings = settingsRepository.defaultCameraAppSettings.first() + .applyPreviewMode(previewMode), + isDebugMode = isDebugMode ) { cameraPropertiesJSON = it } } + /** + * updates the capture mode based on the preview mode + */ + private fun CameraAppSettings.applyPreviewMode(previewMode: PreviewMode): CameraAppSettings { + val captureMode = previewMode.toCaptureMode() + return if (captureMode == CaptureMode.STANDARD) { + this + } else { + this.copy(captureMode = captureMode) + } + } + init { viewModelScope.launch { launch { @@ -139,8 +154,9 @@ class PreviewViewModel @AssistedInject constructor( combine( cameraUseCase.getCurrentSettings().filterNotNull(), constraintsRepository.systemConstraints.filterNotNull(), - cameraUseCase.getCurrentCameraState() - ) { cameraAppSettings, systemConstraints, cameraState -> + cameraUseCase.getCurrentCameraState(), + lockedRecordingState.filterNotNull().distinctUntilChanged() + ) { cameraAppSettings, systemConstraints, cameraState, lockedState -> var flashModeUiState: FlashModeUiState _previewUiState.update { old -> @@ -178,15 +194,15 @@ class PreviewViewModel @AssistedInject constructor( }.copy( // Update or initialize PreviewUiState.Ready previewMode = previewMode, - currentCameraSettings = cameraAppSettings, + currentCameraSettings = cameraAppSettings.applyPreviewMode(previewMode), systemConstraints = systemConstraints, zoomScale = cameraState.zoomScale, videoRecordingState = cameraState.videoRecordingState, sessionFirstFrameTimestamp = cameraState.sessionFirstFrameTimestamp, - captureModeToggleUiState = getCaptureToggleUiState( + /*captureModeToggleUiState = getCaptureToggleUiState( systemConstraints, cameraAppSettings - ), + ),*/ currentLogicalCameraId = cameraState.debugInfo.logicalCameraId, currentPhysicalCameraId = cameraState.debugInfo.physicalCameraId, debugUiState = DebugUiState( @@ -206,6 +222,15 @@ class PreviewViewModel @AssistedInject constructor( audioUiState = getAudioUiState( cameraAppSettings.audioEnabled, cameraState.videoRecordingState + ), + captureModeUiState = getCaptureModeUiState( + systemConstraints, + cameraAppSettings + ), + captureButtonUiState = getCaptureButtonUiState( + cameraAppSettings, + cameraState, + lockedState ) // TODO(kc): set elapsed time UI state once VideoRecordingState // refactor is complete. @@ -266,16 +291,14 @@ class PreviewViewModel @AssistedInject constructor( private fun getAudioUiState( isAudioEnabled: Boolean, videoRecordingState: VideoRecordingState - ): AudioUiState { - return if (isAudioEnabled) { - if (videoRecordingState is VideoRecordingState.Active) { - AudioUiState.Enabled.On(videoRecordingState.audioAmplitude) - } else { - AudioUiState.Enabled.On(0.0) - } + ): AudioUiState = if (isAudioEnabled) { + if (videoRecordingState is VideoRecordingState.Active) { + AudioUiState.Enabled.On(videoRecordingState.audioAmplitude) } else { - AudioUiState.Enabled.Mute + AudioUiState.Enabled.On(0.0) } + } else { + AudioUiState.Enabled.Mute } private fun stabilizationUiStateFrom( @@ -307,11 +330,11 @@ class PreviewViewModel @AssistedInject constructor( } } - private fun PreviewMode.toUseCaseMode() = when (this) { - is PreviewMode.ExternalImageCaptureMode -> CameraUseCase.UseCaseMode.IMAGE_ONLY - is PreviewMode.ExternalMultipleImageCaptureMode -> CameraUseCase.UseCaseMode.IMAGE_ONLY - is PreviewMode.ExternalVideoCaptureMode -> CameraUseCase.UseCaseMode.VIDEO_ONLY - is PreviewMode.StandardMode -> CameraUseCase.UseCaseMode.STANDARD + private fun PreviewMode.toCaptureMode() = when (this) { + is PreviewMode.ExternalImageCaptureMode -> CaptureMode.IMAGE_ONLY + is PreviewMode.ExternalMultipleImageCaptureMode -> CaptureMode.IMAGE_ONLY + is PreviewMode.ExternalVideoCaptureMode -> CaptureMode.VIDEO_ONLY + is PreviewMode.StandardMode -> CaptureMode.STANDARD } /** @@ -378,6 +401,224 @@ class PreviewViewModel @AssistedInject constructor( } } + private fun getCaptureModeUiState( + systemConstraints: SystemConstraints, + cameraAppSettings: CameraAppSettings + ): CaptureModeUiState { + Log.d(TAG, "new capture mode state ${cameraAppSettings.captureMode}") + + val cameraConstraints: CameraConstraints? = systemConstraints.forCurrentLens( + cameraAppSettings + ) + val isHdrOn = cameraAppSettings.dynamicRange == DynamicRange.HLG10 || + cameraAppSettings.imageFormat == ImageOutputFormat.JPEG_ULTRA_HDR + val currentHdrDynamicRangeSupported = + if (isHdrOn) { + cameraConstraints?.supportedDynamicRanges?.contains(DynamicRange.HLG10) == true + } else { + true + } + + val currentHdrImageFormatSupported = + if (isHdrOn) { + cameraConstraints?.supportedImageFormatsMap?.get( + cameraAppSettings.streamConfig + )?.contains(ImageOutputFormat.JPEG_ULTRA_HDR) == true + } else { + true + } + val supportedCaptureModes = getSupportedCaptureModes( + cameraAppSettings, + isHdrOn, + currentHdrDynamicRangeSupported, + currentHdrImageFormatSupported + ) + // if all capture modes are supported, return capturemodeuistate + if (supportedCaptureModes.containsAll( + listOf(CaptureMode.STANDARD, CaptureMode.IMAGE_ONLY, CaptureMode.VIDEO_ONLY) + ) + ) { + return CaptureModeUiState.Enabled(currentSelection = cameraAppSettings.captureMode) + } + // if all capture modes are not supported, give disabledReason + // if image or video is not supported, default will also be disabled + else { + lateinit var defaultCaptureState: SingleSelectableState.Disabled + lateinit var imageCaptureState: SingleSelectableState + lateinit var videoCaptureState: SingleSelectableState + if (!supportedCaptureModes.contains( + CaptureMode.VIDEO_ONLY + ) + ) { + val disabledReason = + getCaptureModeDisabledReason( + disabledCaptureMode = CaptureMode.VIDEO_ONLY, + hdrDynamicRangeSupported = currentHdrDynamicRangeSupported, + hdrImageFormatSupported = currentHdrImageFormatSupported, + systemConstraints = systemConstraints, + cameraAppSettings.cameraLensFacing, + cameraAppSettings.streamConfig, + cameraAppSettings.concurrentCameraMode + ) + + imageCaptureState = SingleSelectableState.Selectable + videoCaptureState = SingleSelectableState.Disabled(disabledReason = disabledReason) + defaultCaptureState = + SingleSelectableState.Disabled(disabledReason = disabledReason) + } else if (!supportedCaptureModes.contains(CaptureMode.IMAGE_ONLY)) { + val disabledReason = + getCaptureModeDisabledReason( + disabledCaptureMode = CaptureMode.IMAGE_ONLY, + currentHdrDynamicRangeSupported, + currentHdrImageFormatSupported, + systemConstraints, + cameraAppSettings.cameraLensFacing, + cameraAppSettings.streamConfig, + cameraAppSettings.concurrentCameraMode + ) + + videoCaptureState = SingleSelectableState.Selectable + imageCaptureState = SingleSelectableState.Disabled(disabledReason = disabledReason) + defaultCaptureState = + SingleSelectableState.Disabled(disabledReason = disabledReason) + } else { + videoCaptureState = SingleSelectableState.Selectable + imageCaptureState = SingleSelectableState.Selectable + defaultCaptureState = + SingleSelectableState.Disabled( + disabledReason = DisabledReason.HDR_SIMULTANEOUS_IMAGE_VIDEO_UNSUPPORTED + ) + } + return CaptureModeUiState.Enabled( + currentSelection = cameraAppSettings.captureMode, + videoOnlyCaptureState = videoCaptureState, + imageOnlyCaptureState = imageCaptureState, + defaultCaptureState = defaultCaptureState + ) + } + } + + private fun getSupportedCaptureModes( + cameraAppSettings: CameraAppSettings, + isHdrOn: Boolean, + currentHdrDynamicRangeSupported: Boolean, + currentHdrImageFormatSupported: Boolean + ): List = if ( + previewMode !is PreviewMode.ExternalImageCaptureMode && + previewMode !is PreviewMode.ExternalVideoCaptureMode && + currentHdrDynamicRangeSupported && + currentHdrImageFormatSupported && + cameraAppSettings.concurrentCameraMode == ConcurrentCameraMode.OFF + ) { + // do not allow both use cases to be bound if hdr is on + if (isHdrOn) { + listOf(CaptureMode.IMAGE_ONLY, CaptureMode.VIDEO_ONLY) + } else { + listOf(CaptureMode.STANDARD, CaptureMode.IMAGE_ONLY, CaptureMode.VIDEO_ONLY) + } + } else if ( + cameraAppSettings.concurrentCameraMode == ConcurrentCameraMode.OFF && + previewMode is PreviewMode.ExternalImageCaptureMode || + cameraAppSettings.imageFormat == ImageOutputFormat.JPEG_ULTRA_HDR + ) { + listOf(CaptureMode.IMAGE_ONLY) + } else { + listOf(CaptureMode.VIDEO_ONLY) + } + + private fun getCaptureModeDisabledReason( + disabledCaptureMode: CaptureMode, + hdrDynamicRangeSupported: Boolean, + hdrImageFormatSupported: Boolean, + systemConstraints: SystemConstraints, + currentLensFacing: LensFacing, + currentStreamConfig: StreamConfig, + concurrentCameraMode: ConcurrentCameraMode + ): DisabledReason { + when (disabledCaptureMode) { + CaptureMode.IMAGE_ONLY -> { + if (previewMode is PreviewMode.ExternalVideoCaptureMode) { + return DisabledReason + .IMAGE_CAPTURE_EXTERNAL_UNSUPPORTED + } + + if (concurrentCameraMode == ConcurrentCameraMode.DUAL) { + return DisabledReason + .IMAGE_CAPTURE_UNSUPPORTED_CONCURRENT_CAMERA + } + + if (!hdrImageFormatSupported) { + // First check if Ultra HDR image is supported on other capture modes + if (systemConstraints + .perLensConstraints[currentLensFacing] + ?.supportedImageFormatsMap + ?.anySupportsUltraHdr { it != currentStreamConfig } == true + ) { + return when (currentStreamConfig) { + StreamConfig.MULTI_STREAM -> + DisabledReason + .HDR_IMAGE_UNSUPPORTED_ON_MULTI_STREAM + + StreamConfig.SINGLE_STREAM -> + DisabledReason + .HDR_IMAGE_UNSUPPORTED_ON_SINGLE_STREAM + } + } + + // Check if any other lens supports HDR image + if (systemConstraints.anySupportsUltraHdr { it != currentLensFacing }) { + return DisabledReason.HDR_IMAGE_UNSUPPORTED_ON_LENS + } + + // No lenses support HDR image on device + return DisabledReason.HDR_IMAGE_UNSUPPORTED_ON_DEVICE + } + + throw RuntimeException("Unknown DisabledReason for video mode.") + } + + CaptureMode.VIDEO_ONLY -> { + if (previewMode is PreviewMode.ExternalImageCaptureMode) { + return DisabledReason + .VIDEO_CAPTURE_EXTERNAL_UNSUPPORTED + } + + if (!hdrDynamicRangeSupported) { + if (systemConstraints.anySupportsHdrDynamicRange { it != currentLensFacing }) { + return DisabledReason.HDR_VIDEO_UNSUPPORTED_ON_LENS + } + return DisabledReason.HDR_VIDEO_UNSUPPORTED_ON_DEVICE + } + + throw RuntimeException("Unknown DisabledReason for image mode.") + } + CaptureMode.STANDARD -> { + TODO() + } + } + } + + fun getCaptureButtonUiState( + cameraAppSettings: CameraAppSettings, + cameraState: CameraState, + lockedState: Boolean + ): CaptureButtonUiState = when (cameraState.videoRecordingState) { + // if not currently recording, check capturemode to determine idle capture button UI + is VideoRecordingState.Inactive -> CaptureButtonUiState.Enabled.Idle(captureMode = cameraAppSettings.captureMode) + + // display different capture button UI depending on if recording is pressed or locked + is VideoRecordingState.Active.Recording -> if (lockedState) { + CaptureButtonUiState.Enabled.Recording.LockedRecording + } else { + CaptureButtonUiState.Enabled.Recording.PressedRecording + } + //todo: how to handle pause... + is VideoRecordingState.Active.Paused -> CaptureButtonUiState.Enabled.Recording.LockedRecording + + // todo: how to handle starting... + VideoRecordingState.Starting -> CaptureButtonUiState.Enabled.Idle(captureMode = cameraAppSettings.captureMode) + } + /* private fun getCaptureToggleUiState( systemConstraints: SystemConstraints, cameraAppSettings: CameraAppSettings @@ -433,6 +674,8 @@ class PreviewViewModel @AssistedInject constructor( } } + + private fun getCaptureToggleUiStateDisabledReason( captureModeToggleUiState: CaptureModeToggleUiState.ToggleMode, hdrDynamicRangeSupported: Boolean, @@ -502,6 +745,8 @@ class PreviewViewModel @AssistedInject constructor( } } + */ + private fun SystemConstraints.anySupportsHdrDynamicRange( lensFilter: (LensFacing) -> Boolean ): Boolean = perLensConstraints.asSequence().firstOrNull { @@ -574,7 +819,7 @@ class PreviewViewModel @AssistedInject constructor( } } - fun setCaptureMode(streamConfig: StreamConfig) { + fun setStreamConfig(streamConfig: StreamConfig) { viewModelScope.launch { cameraUseCase.setStreamConfig(streamConfig) } @@ -742,7 +987,7 @@ class PreviewViewModel @AssistedInject constructor( } } - fun showSnackBarForDisabledHdrToggle(disabledReason: CaptureModeToggleUiState.DisabledReason) { + fun showSnackBarForDisabledHdrToggle(disabledReason: DisabledReason) { val cookieInt = snackBarCount.incrementAndGet() val cookie = "DisabledHdrToggle-$cookieInt" viewModelScope.launch { @@ -834,6 +1079,18 @@ class PreviewViewModel @AssistedInject constructor( cameraUseCase.stopVideoRecording() recordingJob?.cancel() } + setLockedRecording(false) + } + + /** + "Locks" the video recording such that the user no longer needs to keep their finger pressed on the capture button + */ + fun setLockedRecording(isLocked: Boolean) { + viewModelScope.launch { + lockedRecordingState.update { + isLocked + } + } } fun setZoomScale(scale: Float) { @@ -858,6 +1115,12 @@ class PreviewViewModel @AssistedInject constructor( } } + fun setCaptureMode(captureMode: CaptureMode) { + viewModelScope.launch { + cameraUseCase.setCaptureMode(captureMode) + } + } + // modify ui values fun toggleQuickSettings() { viewModelScope.launch { diff --git a/feature/preview/src/main/java/com/google/jetpackcamera/feature/preview/quicksettings/QuickSettingsScreen.kt b/feature/preview/src/main/java/com/google/jetpackcamera/feature/preview/quicksettings/QuickSettingsScreen.kt index 499c71a9e..7e7db4e1d 100644 --- a/feature/preview/src/main/java/com/google/jetpackcamera/feature/preview/quicksettings/QuickSettingsScreen.kt +++ b/feature/preview/src/main/java/com/google/jetpackcamera/feature/preview/quicksettings/QuickSettingsScreen.kt @@ -38,8 +38,7 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.dimensionResource import androidx.compose.ui.tooling.preview.Preview -import com.google.jetpackcamera.core.camera.VideoRecordingState -import com.google.jetpackcamera.feature.preview.CaptureModeToggleUiState +import com.google.jetpackcamera.feature.preview.DEFAULT_CAPTURE_BUTTON_STATE import com.google.jetpackcamera.feature.preview.FlashModeUiState import com.google.jetpackcamera.feature.preview.PreviewMode import com.google.jetpackcamera.feature.preview.PreviewUiState @@ -252,7 +251,7 @@ private fun ExpandedQuickSettingsUi( selectedDynamicRange = currentCameraSettings.dynamicRange, selectedImageOutputFormat = currentCameraSettings.imageFormat, hdrDynamicRangeSupported = - cameraConstraints?.hdrDynamicRangeSupported() ?: false, + cameraConstraints?.hdrDynamicRangeSupported() == true, previewMode = previewUiState.previewMode, enabled = shouldEnable() ) @@ -293,16 +292,13 @@ fun ExpandedQuickSettingsUiPreview() { MaterialTheme { ExpandedQuickSettingsUi( previewUiState = PreviewUiState.Ready( - currentCameraSettings = CameraAppSettings(), systemConstraints = TYPICAL_SYSTEM_CONSTRAINTS, - previewMode = PreviewMode.StandardMode {}, - videoRecordingState = VideoRecordingState.Inactive(), - captureModeToggleUiState = CaptureModeToggleUiState.Invisible, flashModeUiState = FlashModeUiState.Available( selectedFlashMode = FlashMode.OFF, availableFlashModes = listOf(FlashMode.OFF, FlashMode.ON), isActive = false - ) + ), + captureButtonUiState = DEFAULT_CAPTURE_BUTTON_STATE ), currentCameraSettings = CameraAppSettings(), onLensFaceClick = { }, @@ -324,11 +320,8 @@ fun ExpandedQuickSettingsUiPreview_WithHdr() { MaterialTheme { ExpandedQuickSettingsUi( previewUiState = PreviewUiState.Ready( - currentCameraSettings = CameraAppSettings(), systemConstraints = TYPICAL_SYSTEM_CONSTRAINTS, - previewMode = PreviewMode.StandardMode {}, - captureModeToggleUiState = CaptureModeToggleUiState.Invisible, - videoRecordingState = VideoRecordingState.Inactive() + captureButtonUiState = DEFAULT_CAPTURE_BUTTON_STATE ), currentCameraSettings = CameraAppSettings(dynamicRange = DynamicRange.HLG10), onLensFaceClick = { }, diff --git a/feature/preview/src/main/java/com/google/jetpackcamera/feature/preview/ui/CameraControlsOverlay.kt b/feature/preview/src/main/java/com/google/jetpackcamera/feature/preview/ui/CameraControlsOverlay.kt index 80cc89cd5..dd245580a 100644 --- a/feature/preview/src/main/java/com/google/jetpackcamera/feature/preview/ui/CameraControlsOverlay.kt +++ b/feature/preview/src/main/java/com/google/jetpackcamera/feature/preview/ui/CameraControlsOverlay.kt @@ -30,11 +30,6 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.safeDrawingPadding -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.CameraAlt -import androidx.compose.material.icons.filled.Videocam -import androidx.compose.material.icons.outlined.CameraAlt -import androidx.compose.material.icons.outlined.Videocam import androidx.compose.material3.LocalContentColor import androidx.compose.material3.LocalTextStyle import androidx.compose.runtime.Composable @@ -47,26 +42,26 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.vector.rememberVectorPainter import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.testTag -import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import com.google.jetpackcamera.core.camera.VideoRecordingState -import com.google.jetpackcamera.feature.preview.CaptureModeToggleUiState +import com.google.jetpackcamera.feature.preview.CaptureButtonUiState +import com.google.jetpackcamera.feature.preview.CaptureModeUiState +import com.google.jetpackcamera.feature.preview.DEFAULT_CAPTURE_BUTTON_STATE +import com.google.jetpackcamera.feature.preview.DisabledReason import com.google.jetpackcamera.feature.preview.FlashModeUiState import com.google.jetpackcamera.feature.preview.MultipleEventsCutter import com.google.jetpackcamera.feature.preview.PreviewMode import com.google.jetpackcamera.feature.preview.PreviewUiState import com.google.jetpackcamera.feature.preview.PreviewViewModel -import com.google.jetpackcamera.feature.preview.R import com.google.jetpackcamera.feature.preview.StabilizationUiState import com.google.jetpackcamera.feature.preview.quicksettings.ui.QuickSettingsIndicators import com.google.jetpackcamera.feature.preview.quicksettings.ui.ToggleQuickSettingsButton import com.google.jetpackcamera.feature.preview.ui.debug.DebugOverlayToggleButton -import com.google.jetpackcamera.settings.model.CameraAppSettings +import com.google.jetpackcamera.settings.model.CaptureMode import com.google.jetpackcamera.settings.model.FlashMode import com.google.jetpackcamera.settings.model.ImageOutputFormat import com.google.jetpackcamera.settings.model.LensFacing @@ -95,10 +90,11 @@ fun CameraControlsOverlay( modifier: Modifier = Modifier, zoomLevelDisplayState: ZoomLevelDisplayState = remember { ZoomLevelDisplayState() }, onNavigateToSettings: () -> Unit = {}, + onSetCaptureMode: (CaptureMode) -> Unit = {}, onFlipCamera: () -> Unit = {}, onChangeFlash: (FlashMode) -> Unit = {}, onChangeImageFormat: (ImageOutputFormat) -> Unit = {}, - onToggleWhenDisabled: (CaptureModeToggleUiState.DisabledReason) -> Unit = {}, + onDisabledCaptureMode: (DisabledReason) -> Unit = {}, onToggleQuickSettings: () -> Unit = {}, onToggleDebugOverlay: () -> Unit = {}, onToggleAudio: () -> Unit = {}, @@ -114,7 +110,8 @@ fun CameraControlsOverlay( Boolean, (PreviewViewModel.VideoCaptureEvent) -> Unit ) -> Unit = { _, _, _ -> }, - onStopVideoRecording: () -> Unit = {} + onStopVideoRecording: () -> Unit = {}, + onLockVideoRecording: (Boolean) -> Unit ) { // Show the current zoom level for a short period of time, only when the level changes. var firstRun by remember { mutableStateOf(true) } @@ -161,15 +158,18 @@ fun CameraControlsOverlay( isQuickSettingsOpen = previewUiState.quickSettingsIsOpen, systemConstraints = previewUiState.systemConstraints, videoRecordingState = previewUiState.videoRecordingState, + onSetCaptureMode = onSetCaptureMode, onFlipCamera = onFlipCamera, onCaptureImageWithUri = onCaptureImageWithUri, onToggleQuickSettings = onToggleQuickSettings, onToggleAudio = onToggleAudio, onSetPause = onSetPause, onChangeImageFormat = onChangeImageFormat, - onToggleWhenDisabled = onToggleWhenDisabled, + onDisabledCaptureMode = onDisabledCaptureMode, onStartVideoRecording = onStartVideoRecording, - onStopVideoRecording = onStopVideoRecording + onStopVideoRecording = onStopVideoRecording, + onLockVideoRecording = onLockVideoRecording + ) } } @@ -264,14 +264,29 @@ private fun ControlsBottom( onToggleAudio: () -> Unit = {}, onSetPause: (Boolean) -> Unit = {}, onChangeImageFormat: (ImageOutputFormat) -> Unit = {}, - onToggleWhenDisabled: (CaptureModeToggleUiState.DisabledReason) -> Unit = {}, + onSetCaptureMode: (CaptureMode) -> Unit = {}, + onDisabledCaptureMode: (DisabledReason) -> Unit = {}, onStartVideoRecording: ( Uri?, Boolean, (PreviewViewModel.VideoCaptureEvent) -> Unit ) -> Unit = { _, _, _ -> }, - onStopVideoRecording: () -> Unit = {} + onStopVideoRecording: () -> Unit = {}, + onLockVideoRecording: (Boolean) -> Unit = {} ) { + if (videoRecordingState is VideoRecordingState.Inactive && + previewUiState.captureModeUiState is CaptureModeUiState.Enabled + ) { + Box(modifier = Modifier.fillMaxSize()) { + // todo(kc): WIP UI... still need to properly style this + CaptureModeDropDown( + modifier = Modifier.align(Alignment.BottomEnd), + onSetCaptureMode = onSetCaptureMode, + captureModeUiState = previewUiState.captureModeUiState, + onDisabledCaptureMode = onDisabledCaptureMode + ) + } + } Column(modifier = modifier, horizontalAlignment = Alignment.CenterHorizontally) { CompositionLocalProvider( LocalTextStyle provides LocalTextStyle.current.copy(fontSize = 20.sp) @@ -324,13 +339,14 @@ private fun ControlsBottom( } } CaptureButton( - previewUiState = previewUiState, + captureButtonUiState = previewUiState.captureButtonUiState, + previewMode = previewUiState.previewMode, isQuickSettingsOpen = isQuickSettingsOpen, - videoRecordingState = videoRecordingState, onCaptureImageWithUri = onCaptureImageWithUri, onToggleQuickSettings = onToggleQuickSettings, onStartVideoRecording = onStartVideoRecording, - onStopVideoRecording = onStopVideoRecording + onStopVideoRecording = onStopVideoRecording, + onLockVideoRecording = onLockVideoRecording ) Row(Modifier.weight(1f), horizontalArrangement = Arrangement.SpaceEvenly) { if (videoRecordingState is VideoRecordingState.Active) { @@ -341,18 +357,20 @@ private fun ControlsBottom( onToggleAudio = onToggleAudio, audioUiState = previewUiState.audioUiState ) - } else { + } + /* else { if (!isQuickSettingsOpen && previewUiState.captureModeToggleUiState is CaptureModeToggleUiState.Visible ) { CaptureModeToggleButton( uiState = previewUiState.captureModeToggleUiState, onChangeImageFormat = onChangeImageFormat, - onToggleWhenDisabled = onToggleWhenDisabled, + onToggleWhenDisabled = onDisabledCaptureMode, modifier = Modifier.testTag(CAPTURE_MODE_TOGGLE_BUTTON) ) } } + */ } } } @@ -360,69 +378,73 @@ private fun ControlsBottom( @Composable private fun CaptureButton( - previewUiState: PreviewUiState.Ready, - isQuickSettingsOpen: Boolean, - videoRecordingState: VideoRecordingState, modifier: Modifier = Modifier, + captureButtonUiState: CaptureButtonUiState, + isQuickSettingsOpen: Boolean, + previewMode: PreviewMode, + onToggleQuickSettings: () -> Unit = {}, onCaptureImageWithUri: ( ContentResolver, Uri?, Boolean, (PreviewViewModel.ImageCaptureEvent, Int) -> Unit ) -> Unit = { _, _, _, _ -> }, - onToggleQuickSettings: () -> Unit = {}, onStartVideoRecording: ( Uri?, Boolean, (PreviewViewModel.VideoCaptureEvent) -> Unit ) -> Unit = { _, _, _ -> }, - onStopVideoRecording: () -> Unit = {} + onStopVideoRecording: () -> Unit = {}, + onLockVideoRecording: (Boolean) -> Unit = {} ) { val multipleEventsCutter = remember { MultipleEventsCutter() } val context = LocalContext.current CaptureButton( modifier = modifier.testTag(CAPTURE_BUTTON), - onClick = { - multipleEventsCutter.processEvent { - when (previewUiState.previewMode) { - is PreviewMode.StandardMode -> { - onCaptureImageWithUri( - context.contentResolver, - null, - true - ) { event: PreviewViewModel.ImageCaptureEvent, _: Int -> - previewUiState.previewMode.onImageCapture(event) + onCaptureImage = { + if (captureButtonUiState is CaptureButtonUiState.Enabled) { + multipleEventsCutter.processEvent { + when (previewMode) { + is PreviewMode.StandardMode -> { + onCaptureImageWithUri( + context.contentResolver, + null, + true + ) { event: PreviewViewModel.ImageCaptureEvent, _: Int -> + previewMode.onImageCapture(event) + } } - } - is PreviewMode.ExternalImageCaptureMode -> { - onCaptureImageWithUri( - context.contentResolver, - previewUiState.previewMode.imageCaptureUri, - false - ) { event: PreviewViewModel.ImageCaptureEvent, _: Int -> - previewUiState.previewMode.onImageCapture(event) + is PreviewMode.ExternalImageCaptureMode -> { + onCaptureImageWithUri( + context.contentResolver, + previewMode.imageCaptureUri, + false + ) { event: PreviewViewModel.ImageCaptureEvent, _: Int -> + previewMode.onImageCapture(event) + } } - } - is PreviewMode.ExternalMultipleImageCaptureMode -> { - val ignoreUri = previewUiState.previewMode.imageCaptureUris.isNullOrEmpty() - onCaptureImageWithUri( - context.contentResolver, - null, - previewUiState.previewMode.imageCaptureUris.isNullOrEmpty() || - ignoreUri, - previewUiState.previewMode.onImageCapture - ) - } + is PreviewMode.ExternalMultipleImageCaptureMode -> { + val ignoreUri = + previewMode.imageCaptureUris.isNullOrEmpty() + onCaptureImageWithUri( + context.contentResolver, + null, + previewMode.imageCaptureUris.isNullOrEmpty() || + ignoreUri, + previewMode.onImageCapture + ) + } - else -> { - onCaptureImageWithUri( - context.contentResolver, - null, - false - ) { _: PreviewViewModel.ImageCaptureEvent, _: Int -> } + else -> { + onCaptureImageWithUri( + context.contentResolver, + null, + false + ) { _: PreviewViewModel.ImageCaptureEvent, _: Int -> } + } } } } @@ -430,35 +452,38 @@ private fun CaptureButton( onToggleQuickSettings() } }, - onLongPress = { - when (previewUiState.previewMode) { - is PreviewMode.StandardMode -> { - onStartVideoRecording(null, false) {} - } + onStartVideoRecording = { + if (captureButtonUiState is CaptureButtonUiState.Enabled) { + when (previewMode) { + is PreviewMode.StandardMode -> { + onStartVideoRecording(null, false) {} + } - is PreviewMode.ExternalVideoCaptureMode -> { - onStartVideoRecording( - previewUiState.previewMode.videoCaptureUri, - true, - previewUiState.previewMode.onVideoCapture - ) - } + is PreviewMode.ExternalVideoCaptureMode -> { + onStartVideoRecording( + previewMode.videoCaptureUri, + true, + previewMode.onVideoCapture + ) + } - else -> { - onStartVideoRecording(null, false) {} + else -> { + onStartVideoRecording(null, false) {} + } + } + if (isQuickSettingsOpen) { + onToggleQuickSettings() } - } - if (isQuickSettingsOpen) { - onToggleQuickSettings() } }, - onRelease = { + onStopVideoRecording = { onStopVideoRecording() }, - videoRecordingState = videoRecordingState + captureButtonUiState = captureButtonUiState, + onLockVideoRecording = onLockVideoRecording ) } - +/* @Composable private fun CaptureModeToggleButton( uiState: CaptureModeToggleUiState.Visible, @@ -506,7 +531,7 @@ private fun CaptureModeToggleButton( stringResource(id = R.string.capture_mode_video_recording_content_description), modifier = modifier ) -} +}*/ @Preview(backgroundColor = 0xFF000000, showBackground = true) @Composable @@ -590,11 +615,8 @@ private fun Preview_ControlsBottom() { CompositionLocalProvider(LocalContentColor provides Color.White) { ControlsBottom( previewUiState = PreviewUiState.Ready( - currentCameraSettings = CameraAppSettings(), systemConstraints = TYPICAL_SYSTEM_CONSTRAINTS, - previewMode = PreviewMode.StandardMode {}, - captureModeToggleUiState = CaptureModeToggleUiState.Invisible, - videoRecordingState = VideoRecordingState.Inactive() + captureButtonUiState = DEFAULT_CAPTURE_BUTTON_STATE ), zoomLevel = 1.3f, showZoomLevel = true, @@ -611,11 +633,8 @@ private fun Preview_ControlsBottom_NoZoomLevel() { CompositionLocalProvider(LocalContentColor provides Color.White) { ControlsBottom( previewUiState = PreviewUiState.Ready( - currentCameraSettings = CameraAppSettings(), systemConstraints = TYPICAL_SYSTEM_CONSTRAINTS, - previewMode = PreviewMode.StandardMode {}, - captureModeToggleUiState = CaptureModeToggleUiState.Invisible, - videoRecordingState = VideoRecordingState.Inactive() + captureButtonUiState = DEFAULT_CAPTURE_BUTTON_STATE ), zoomLevel = 1.3f, showZoomLevel = false, @@ -632,11 +651,8 @@ private fun Preview_ControlsBottom_QuickSettingsOpen() { CompositionLocalProvider(LocalContentColor provides Color.White) { ControlsBottom( previewUiState = PreviewUiState.Ready( - currentCameraSettings = CameraAppSettings(), systemConstraints = TYPICAL_SYSTEM_CONSTRAINTS, - previewMode = PreviewMode.StandardMode {}, - captureModeToggleUiState = CaptureModeToggleUiState.Invisible, - videoRecordingState = VideoRecordingState.Inactive() + captureButtonUiState = DEFAULT_CAPTURE_BUTTON_STATE ), zoomLevel = 1.3f, showZoomLevel = true, @@ -653,11 +669,8 @@ private fun Preview_ControlsBottom_NoFlippableCamera() { CompositionLocalProvider(LocalContentColor provides Color.White) { ControlsBottom( previewUiState = PreviewUiState.Ready( - currentCameraSettings = CameraAppSettings(), systemConstraints = TYPICAL_SYSTEM_CONSTRAINTS, - previewMode = PreviewMode.StandardMode {}, - captureModeToggleUiState = CaptureModeToggleUiState.Invisible, - videoRecordingState = VideoRecordingState.Inactive() + captureButtonUiState = DEFAULT_CAPTURE_BUTTON_STATE ), zoomLevel = 1.3f, showZoomLevel = true, @@ -680,12 +693,9 @@ private fun Preview_ControlsBottom_Recording() { CompositionLocalProvider(LocalContentColor provides Color.White) { ControlsBottom( previewUiState = PreviewUiState.Ready( - currentCameraSettings = CameraAppSettings(), systemConstraints = TYPICAL_SYSTEM_CONSTRAINTS, - previewMode = PreviewMode.StandardMode {}, - captureModeToggleUiState = CaptureModeToggleUiState.Invisible, - videoRecordingState = VideoRecordingState.Active.Recording(0L, .9, 1_000_000_000) - + videoRecordingState = VideoRecordingState.Active.Recording(0L, .9, 1_000_000_000), + captureButtonUiState = DEFAULT_CAPTURE_BUTTON_STATE ), zoomLevel = 1.3f, showZoomLevel = true, diff --git a/feature/preview/src/main/java/com/google/jetpackcamera/feature/preview/ui/PreviewScreenComponents.kt b/feature/preview/src/main/java/com/google/jetpackcamera/feature/preview/ui/PreviewScreenComponents.kt index be7fb2441..b9e6b04cd 100644 --- a/feature/preview/src/main/java/com/google/jetpackcamera/feature/preview/ui/PreviewScreenComponents.kt +++ b/feature/preview/src/main/java/com/google/jetpackcamera/feature/preview/ui/PreviewScreenComponents.kt @@ -26,12 +26,17 @@ import androidx.camera.core.SurfaceRequest import androidx.camera.viewfinder.compose.MutableCoordinateTransformer import androidx.camera.viewfinder.core.ImplementationMode import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.animateColorAsState import androidx.compose.animation.core.EaseOutExpo +import androidx.compose.animation.core.FastOutSlowInEasing import androidx.compose.animation.core.LinearEasing +import androidx.compose.animation.core.animateDpAsState import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.core.tween +import androidx.compose.animation.expandVertically import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut +import androidx.compose.animation.shrinkVertically import androidx.compose.foundation.Canvas import androidx.compose.foundation.background import androidx.compose.foundation.border @@ -39,6 +44,7 @@ import androidx.compose.foundation.clickable import androidx.compose.foundation.gestures.detectTapGestures import androidx.compose.foundation.gestures.rememberTransformableState import androidx.compose.foundation.gestures.transformable +import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.BoxWithConstraints @@ -105,11 +111,16 @@ import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import com.google.jetpackcamera.core.camera.VideoRecordingState import com.google.jetpackcamera.feature.preview.AudioUiState +import com.google.jetpackcamera.feature.preview.CaptureButtonUiState +import com.google.jetpackcamera.feature.preview.CaptureModeUiState +import com.google.jetpackcamera.feature.preview.DisabledReason import com.google.jetpackcamera.feature.preview.PreviewUiState import com.google.jetpackcamera.feature.preview.R +import com.google.jetpackcamera.feature.preview.SingleSelectableState import com.google.jetpackcamera.feature.preview.StabilizationUiState import com.google.jetpackcamera.feature.preview.ui.theme.PreviewPreviewTheme import com.google.jetpackcamera.settings.model.AspectRatio +import com.google.jetpackcamera.settings.model.CaptureMode import com.google.jetpackcamera.settings.model.StabilizationMode import com.google.jetpackcamera.settings.model.VideoQuality import kotlin.time.Duration.Companion.nanoseconds @@ -673,24 +684,144 @@ fun CurrentCameraIdText(physicalCameraId: String?, logicalCameraId: String?) { } } +@Composable +fun CaptureModeDropDown( + modifier: Modifier = Modifier, + onSetCaptureMode: (CaptureMode) -> Unit, + onDisabledCaptureMode: (DisabledReason) -> Unit, + captureModeUiState: CaptureModeUiState.Enabled +) { + var isExpanded by remember { mutableStateOf(false) } + + Column(modifier = modifier) { + AnimatedVisibility( + visible = isExpanded, + enter = + fadeIn() + expandVertically(expandFrom = Alignment.Top), + exit = shrinkVertically(shrinkTowards = Alignment.Bottom) + ) { + fun onDisabledClick(selectableState: SingleSelectableState): () -> Unit = + if (selectableState is SingleSelectableState.Disabled) { + { onDisabledCaptureMode(selectableState.disabledReason) } + } else { + { TODO("Enabled should not have disabled click") } + } + + Column { + DropDownItem( + text = stringResource(R.string.capture_mode_text_standard), + enabled = captureModeUiState.defaultCaptureState + is SingleSelectableState.Selectable, + onClick = { + onSetCaptureMode(CaptureMode.STANDARD) + isExpanded = false + }, + onDisabledClick = onDisabledClick(captureModeUiState.defaultCaptureState) + ) + DropDownItem( + text = stringResource(R.string.capture_mode_text_image_only), + enabled = captureModeUiState.imageOnlyCaptureState + is SingleSelectableState.Selectable, + onClick = { + onSetCaptureMode(CaptureMode.IMAGE_ONLY) + isExpanded = false + }, + onDisabledClick = onDisabledClick(captureModeUiState.imageOnlyCaptureState) + ) + DropDownItem( + text = stringResource(R.string.capture_mode_text_video_only), + enabled = captureModeUiState.videoOnlyCaptureState + is SingleSelectableState.Selectable, + onClick = { + onSetCaptureMode(CaptureMode.VIDEO_ONLY) + isExpanded = false + }, + onDisabledClick = onDisabledClick( + captureModeUiState.videoOnlyCaptureState + ) + + ) + } + } + // this text displays the current selection + Box( + modifier = Modifier + .clickable( + interactionSource = remember { MutableInteractionSource() }, + // removes the greyish background animation that appears when clicking on a clickable + indication = null, + onClick = { isExpanded = !isExpanded } + ) + .padding(8.dp) + ) { + Text( + text = when (captureModeUiState.currentSelection) { + CaptureMode.STANDARD -> stringResource(R.string.capture_mode_text_standard) + CaptureMode.VIDEO_ONLY -> stringResource(R.string.capture_mode_text_video_only) + CaptureMode.IMAGE_ONLY -> stringResource(R.string.capture_mode_text_image_only) + }, + modifier = Modifier.padding(16.dp) + ) + } + } +} + +@Composable +fun DropDownItem( + modifier: Modifier = Modifier, + text: String, + onClick: () -> Unit = {}, + onDisabledClick: () -> Unit = {}, + enabled: Boolean = true, + isSelected: Boolean = false +) { + Text( + text = text, + color = if (enabled) Color.Unspecified else Color.DarkGray, + modifier = modifier + .clickable(enabled = true, onClick = if (enabled) onClick else onDisabledClick) + .apply { + if (!enabled) { + alpha(.37f) + } + } + .padding(16.dp) + ) +} + @Composable fun CaptureButton( - onClick: () -> Unit, - onLongPress: () -> Unit, - onRelease: () -> Unit, - videoRecordingState: VideoRecordingState, - modifier: Modifier = Modifier + modifier: Modifier = Modifier, + onCaptureImage: () -> Unit, + onStartVideoRecording: () -> Unit, + onStopVideoRecording: () -> Unit, + onLockVideoRecording: (Boolean) -> Unit, + captureButtonUiState: CaptureButtonUiState ) { + var currentUiState = rememberUpdatedState(captureButtonUiState) var isPressedDown by remember { mutableStateOf(false) } + var isLongPressing by remember { + mutableStateOf(false) + } + val currentColor = LocalContentColor.current Box( + contentAlignment = Alignment.Center, modifier = modifier .pointerInput(Unit) { detectTapGestures( onLongPress = { - onLongPress() + isLongPressing = true + var uiState = currentUiState.value + if (uiState is CaptureButtonUiState.Enabled.Idle) { + when (uiState.captureMode) { + CaptureMode.STANDARD, + CaptureMode.VIDEO_ONLY -> onStartVideoRecording() + CaptureMode.IMAGE_ONLY -> {} + } + } }, // TODO: @kimblebee - stopVideoRecording is being called every time the capture // button is pressed -- regardless of tap or long press @@ -698,37 +829,113 @@ fun CaptureButton( isPressedDown = true awaitRelease() isPressedDown = false - onRelease() + isLongPressing = false + var uiState = currentUiState.value + + when (uiState) { + // stop recording after button is lifted + is CaptureButtonUiState.Enabled.Recording.PressedRecording -> { + onStopVideoRecording() + } + + is CaptureButtonUiState.Enabled.Idle, CaptureButtonUiState.Unavailable -> {} + CaptureButtonUiState.Enabled.Recording.LockedRecording -> {} + } }, - onTap = { onClick() } + onTap = { + var uiState = currentUiState.value + when (uiState) { + is CaptureButtonUiState.Enabled.Idle -> { + Log.d(TAG, "capture mode ${uiState.captureMode}") + if (!isLongPressing) { + when (uiState.captureMode) { + CaptureMode.STANDARD, + CaptureMode.IMAGE_ONLY -> onCaptureImage() + + CaptureMode.VIDEO_ONLY -> { + onLockVideoRecording(true) + onStartVideoRecording() + } + } + } + } + // stop if locked recording + CaptureButtonUiState.Enabled.Recording.LockedRecording -> { + onStopVideoRecording() + } + CaptureButtonUiState.Unavailable, + CaptureButtonUiState.Enabled.Recording.PressedRecording -> {} + } + } ) } - .size(120.dp) - .padding(18.dp) - .border(4.dp, currentColor, CircleShape) + .size(80.dp) + // .padding(18.dp) + .border(4.dp, currentColor, CircleShape) // border is the white ring ) { - Canvas( - modifier = Modifier - .size(110.dp), - onDraw = { - drawCircle( - alpha = when (videoRecordingState) { - is VideoRecordingState.Active.Paused -> .37f - else -> 1f - }, - color = - when (videoRecordingState) { - is VideoRecordingState.Inactive -> { - if (isPressedDown) currentColor else Color.Transparent - } - - is VideoRecordingState.Active.Recording, - is VideoRecordingState.Active.Paused -> Color.Red + // now we draw center circle + val centerShapeSize by animateDpAsState( + targetValue = when (val uiState = currentUiState.value) { + CaptureButtonUiState.Enabled.Recording.LockedRecording -> 40.dp + CaptureButtonUiState.Enabled.Recording.PressedRecording -> 60.dp + CaptureButtonUiState.Unavailable -> 0.dp + is CaptureButtonUiState.Enabled.Idle -> when (uiState.captureMode) { + CaptureMode.STANDARD -> 0.dp // no inner circle will be visible + CaptureMode.IMAGE_ONLY -> 60.dp // large white circle will be visible + CaptureMode.VIDEO_ONLY -> 30.dp // small red circle will be visible + } + }, + animationSpec = tween(durationMillis = 500, easing = FastOutSlowInEasing) + ) + // radius is number between 0 is square, 100 is circle + val cornerRadius by animateFloatAsState( + targetValue = when (currentUiState.value) { + CaptureButtonUiState.Enabled.Recording.LockedRecording -> 30f // rounded square when locked + is CaptureButtonUiState.Enabled.Idle -> 100f // all shapes used when idle are circles + CaptureButtonUiState.Enabled.Recording.PressedRecording -> 100f // circle when pressing + CaptureButtonUiState.Unavailable -> 0f + }, + animationSpec = tween(durationMillis = 500, easing = FastOutSlowInEasing) + ) + /* val cornerRadius = when (currentUiState.value) { + is CaptureButtonUiState.Enabled.Idle -> centerShapeSize / 2 + is CaptureButtonUiState.Enabled.Recording.PressedRecording -> centerShapeSize / 2 + is + } + val cornerRadius = if (currentUiState.value is CaptureButtonUiState.Enabled.Idle) { + centerShapeSize / 2 // Circle when Idle + } else { + 30.dp // Or a fixed value in pixels for the rounded square + }*/ - VideoRecordingState.Starting -> currentColor + val animatedColor by animateColorAsState( + targetValue = when (val uiState = currentUiState.value) { + is CaptureButtonUiState.Enabled.Idle -> when (uiState.captureMode) { + CaptureMode.STANDARD -> Color.White + CaptureMode.IMAGE_ONLY -> Color.White + CaptureMode.VIDEO_ONLY -> Color.Red + } + is CaptureButtonUiState.Enabled.Recording -> Color.Red + is CaptureButtonUiState.Unavailable -> Color.Transparent + }, + animationSpec = tween(durationMillis = 500) + ) + // inner shape for the circle + Box( + modifier = Modifier + .size(centerShapeSize) + .clip(RoundedCornerShape(cornerRadius)) + .alpha( + if (isPressedDown && + currentUiState.value == + CaptureButtonUiState.Enabled.Idle(CaptureMode.IMAGE_ONLY) + ) { + .5f // fade out to show you tap the button for image capture, ONLY on IMAGE_ONLY + } else { + 1f // solid color the rest of the time } ) - } + .background(animatedColor) ) } } diff --git a/feature/preview/src/main/java/com/google/jetpackcamera/feature/preview/ui/TestTags.kt b/feature/preview/src/main/java/com/google/jetpackcamera/feature/preview/ui/TestTags.kt index 58ab9434b..d482f0c92 100644 --- a/feature/preview/src/main/java/com/google/jetpackcamera/feature/preview/ui/TestTags.kt +++ b/feature/preview/src/main/java/com/google/jetpackcamera/feature/preview/ui/TestTags.kt @@ -47,6 +47,7 @@ const val HDR_IMAGE_UNSUPPORTED_ON_SINGLE_STREAM_TAG = "HdrImageUnsupportedOnSin const val HDR_IMAGE_UNSUPPORTED_ON_MULTI_STREAM_TAG = "HdrImageUnsupportedOnMultiStreamTag" const val HDR_VIDEO_UNSUPPORTED_ON_DEVICE_TAG = "HdrVideoUnsupportedOnDeviceTag" const val HDR_VIDEO_UNSUPPORTED_ON_LENS_TAG = "HdrVideoUnsupportedOnDeviceTag" +const val HDR_SIMULTANEOUS_IMAGE_VIDEO_UNSUPPORTED_TAG = "HdrSimultaneousImageVideoUnsupportedTag" const val ZOOM_RATIO_TAG = "ZoomRatioTag" const val LOGICAL_CAMERA_ID_TAG = "LogicalCameraIdTag" const val PHYSICAL_CAMERA_ID_TAG = "PhysicalCameraIdTag" diff --git a/feature/preview/src/main/res/values/strings.xml b/feature/preview/src/main/res/values/strings.xml index d88a2e058..6ae431b74 100644 --- a/feature/preview/src/main/res/values/strings.xml +++ b/feature/preview/src/main/res/values/strings.xml @@ -28,6 +28,10 @@ Physical ID: Logical ID: + + Standard + Video Only + Image Only Image Capture Success Video Capture Success @@ -51,8 +55,10 @@ Multi-stream mode does not support UltraHDR photo capture for current lens HDR video not supported on this device HDR video not supported by current lens + HDR video and image capture cannot be bound simultaneously + FRONT BACK Front Camera diff --git a/feature/preview/src/test/java/com/google/jetpackcamera/feature/preview/ScreenFlashTest.kt b/feature/preview/src/test/java/com/google/jetpackcamera/feature/preview/ScreenFlashTest.kt index 1601d1964..30c17f959 100644 --- a/feature/preview/src/test/java/com/google/jetpackcamera/feature/preview/ScreenFlashTest.kt +++ b/feature/preview/src/test/java/com/google/jetpackcamera/feature/preview/ScreenFlashTest.kt @@ -114,8 +114,7 @@ class ScreenFlashTest { private fun runCameraTest(testBody: suspend TestScope.() -> Unit) = runTest(testDispatcher) { backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) { cameraUseCase.initialize( - DEFAULT_CAMERA_APP_SETTINGS, - CameraUseCase.UseCaseMode.STANDARD + DEFAULT_CAMERA_APP_SETTINGS ) {} cameraUseCase.runCamera() }