From a3d3d661ee4358c23cab5ec7b56c364039f471a0 Mon Sep 17 00:00:00 2001 From: Kimberly Crevecoeur Date: Wed, 31 Jan 2024 08:55:12 -0800 Subject: [PATCH] Kim/stabilization UI logic (#94) Add UI styling and logic for stabilization settings --- app/build.gradle.kts | 1 - data/settings/build.gradle.kts | 3 +- .../settings/JcaSettingsSerializer.kt | 2 + .../settings/LocalSettingsRepository.kt | 35 +++ .../settings/SettingsRepository.kt | 4 + .../settings/model/CameraAppSettings.kt | 3 +- .../model/SupportedStabilizationMode.kt | 25 ++ .../test/FakeJcaSettingsSerializer.kt | 14 + .../settings/test/FakeSettingsRepository.kt | 41 ++- .../jetpackcamera/settings/jca_settings.proto | 2 + .../domain/camera/CameraXCameraUseCase.kt | 74 +++-- .../feature/preview/PreviewScreen.kt | 252 +++++++++++------- .../preview/ui/PreviewScreenComponents.kt | 54 ++-- .../res/drawable/baseline_video_stable_24.xml | 21 ++ .../preview/src/main/res/values/strings.xml | 3 + .../quicksettings/QuickSettingsScreen.kt | 15 +- .../ui/QuickSettingsComponents.kt | 9 +- .../jetpackcamera/settings/SettingsScreen.kt | 6 +- .../settings/ui/SettingsComponents.kt | 64 +++-- .../settings/src/main/res/values/strings.xml | 2 +- settings.gradle.kts | 2 +- 21 files changed, 450 insertions(+), 182 deletions(-) create mode 100644 data/settings/src/main/java/com/google/jetpackcamera/settings/model/SupportedStabilizationMode.kt create mode 100644 feature/preview/src/main/res/drawable/baseline_video_stable_24.xml diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 274e616e3..1c27e4d24 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -73,7 +73,6 @@ android { } dependencies { - implementation("androidx.test.ext:junit-ktx:1.1.5") // Compose val composeBom = platform(libs.compose.bom) implementation(composeBom) diff --git a/data/settings/build.gradle.kts b/data/settings/build.gradle.kts index 1b2b9ba25..54cbb4889 100644 --- a/data/settings/build.gradle.kts +++ b/data/settings/build.gradle.kts @@ -56,10 +56,9 @@ dependencies { // Testing testImplementation(libs.junit) implementation(libs.kotlinx.coroutines.core) - androidTestImplementation(libs.kotlinx.coroutines.core) + androidTestImplementation(libs.kotlinx.coroutines.test) androidTestImplementation(libs.androidx.junit) androidTestImplementation(libs.androidx.espresso.core) - // Hilt implementation(libs.dagger.hilt.android) kapt(libs.dagger.hilt.compiler) diff --git a/data/settings/src/main/java/com/google/jetpackcamera/settings/JcaSettingsSerializer.kt b/data/settings/src/main/java/com/google/jetpackcamera/settings/JcaSettingsSerializer.kt index 4648a2d26..2530a2b5a 100644 --- a/data/settings/src/main/java/com/google/jetpackcamera/settings/JcaSettingsSerializer.kt +++ b/data/settings/src/main/java/com/google/jetpackcamera/settings/JcaSettingsSerializer.kt @@ -33,6 +33,8 @@ object JcaSettingsSerializer : Serializer { .setCaptureModeStatus(CaptureMode.CAPTURE_MODE_MULTI_STREAM) .setStabilizePreview(PreviewStabilization.PREVIEW_STABILIZATION_UNDEFINED) .setStabilizeVideo(VideoStabilization.VIDEO_STABILIZATION_UNDEFINED) + .setStabilizePreviewSupported(false) + .setStabilizeVideoSupported(false) .build() override suspend fun readFrom(input: InputStream): JcaSettings { diff --git a/data/settings/src/main/java/com/google/jetpackcamera/settings/LocalSettingsRepository.kt b/data/settings/src/main/java/com/google/jetpackcamera/settings/LocalSettingsRepository.kt index a6a9c3972..f54a1a961 100644 --- a/data/settings/src/main/java/com/google/jetpackcamera/settings/LocalSettingsRepository.kt +++ b/data/settings/src/main/java/com/google/jetpackcamera/settings/LocalSettingsRepository.kt @@ -28,6 +28,7 @@ import com.google.jetpackcamera.settings.model.CaptureMode import com.google.jetpackcamera.settings.model.DarkMode import com.google.jetpackcamera.settings.model.FlashMode import com.google.jetpackcamera.settings.model.Stabilization +import com.google.jetpackcamera.settings.model.SupportedStabilizationMode import javax.inject.Inject import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.map @@ -60,6 +61,10 @@ class LocalSettingsRepository @Inject constructor( aspectRatio = AspectRatio.fromProto(it.aspectRatioStatus), previewStabilization = Stabilization.fromProto(it.stabilizePreview), videoCaptureStabilization = Stabilization.fromProto(it.stabilizeVideo), + supportedStabilizationModes = getSupportedStabilization( + previewSupport = it.stabilizePreviewSupported, + videoSupport = it.stabilizeVideoSupported + ), captureMode = when (it.captureModeStatus) { CaptureModeProto.CAPTURE_MODE_SINGLE_STREAM -> CaptureMode.SINGLE_STREAM CaptureModeProto.CAPTURE_MODE_MULTI_STREAM -> CaptureMode.MULTI_STREAM @@ -174,4 +179,34 @@ class LocalSettingsRepository @Inject constructor( .build() } } + + override suspend fun updateVideoStabilizationSupported(isSupported: Boolean) { + jcaSettings.updateData { currentSettings -> + currentSettings.toBuilder() + .setStabilizeVideoSupported(isSupported) + .build() + } + } + + override suspend fun updatePreviewStabilizationSupported(isSupported: Boolean) { + jcaSettings.updateData { currentSettings -> + currentSettings.toBuilder() + .setStabilizeVideoSupported(isSupported) + .build() + } + } + + private fun getSupportedStabilization( + previewSupport: Boolean, + videoSupport: Boolean + ): List { + return buildList { + if (previewSupport && videoSupport) { + add(SupportedStabilizationMode.ON) + } + if (!previewSupport && videoSupport) { + add(SupportedStabilizationMode.HIGH_QUALITY) + } + } + } } diff --git a/data/settings/src/main/java/com/google/jetpackcamera/settings/SettingsRepository.kt b/data/settings/src/main/java/com/google/jetpackcamera/settings/SettingsRepository.kt index 9b5931ecd..0f622d13d 100644 --- a/data/settings/src/main/java/com/google/jetpackcamera/settings/SettingsRepository.kt +++ b/data/settings/src/main/java/com/google/jetpackcamera/settings/SettingsRepository.kt @@ -46,5 +46,9 @@ interface SettingsRepository { suspend fun updatePreviewStabilization(stabilization: Stabilization) suspend fun updateVideoStabilization(stabilization: Stabilization) + suspend fun updateVideoStabilizationSupported(isSupported: Boolean) + + suspend fun updatePreviewStabilizationSupported(isSupported: Boolean) + suspend fun getCameraAppSettings(): CameraAppSettings } 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 79e50a4d4..e1350896f 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 @@ -27,7 +27,8 @@ data class CameraAppSettings( val captureMode: CaptureMode = CaptureMode.MULTI_STREAM, val aspectRatio: AspectRatio = AspectRatio.NINE_SIXTEEN, val previewStabilization: Stabilization = Stabilization.UNDEFINED, - val videoCaptureStabilization: Stabilization = Stabilization.UNDEFINED + val videoCaptureStabilization: Stabilization = Stabilization.UNDEFINED, + val supportedStabilizationModes: List = emptyList() ) val DEFAULT_CAMERA_APP_SETTINGS = CameraAppSettings() diff --git a/data/settings/src/main/java/com/google/jetpackcamera/settings/model/SupportedStabilizationMode.kt b/data/settings/src/main/java/com/google/jetpackcamera/settings/model/SupportedStabilizationMode.kt new file mode 100644 index 000000000..9cdc8f7d6 --- /dev/null +++ b/data/settings/src/main/java/com/google/jetpackcamera/settings/model/SupportedStabilizationMode.kt @@ -0,0 +1,25 @@ +/* + * Copyright (C) 2023 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 + +/** Enum class representing the device's supported video stabilization configurations. */ +enum class SupportedStabilizationMode { + /** Device supports Preview stabilization. */ + ON, + + /** Device supports Video stabilization.*/ + HIGH_QUALITY +} diff --git a/data/settings/src/main/java/com/google/jetpackcamera/settings/test/FakeJcaSettingsSerializer.kt b/data/settings/src/main/java/com/google/jetpackcamera/settings/test/FakeJcaSettingsSerializer.kt index 2193922d1..c2033bd05 100644 --- a/data/settings/src/main/java/com/google/jetpackcamera/settings/test/FakeJcaSettingsSerializer.kt +++ b/data/settings/src/main/java/com/google/jetpackcamera/settings/test/FakeJcaSettingsSerializer.kt @@ -17,8 +17,13 @@ package com.google.jetpackcamera.settings.test import androidx.datastore.core.CorruptionException import androidx.datastore.core.Serializer +import com.google.jetpackcamera.settings.AspectRatio +import com.google.jetpackcamera.settings.CaptureMode import com.google.jetpackcamera.settings.DarkMode +import com.google.jetpackcamera.settings.FlashMode import com.google.jetpackcamera.settings.JcaSettings +import com.google.jetpackcamera.settings.PreviewStabilization +import com.google.jetpackcamera.settings.VideoStabilization import com.google.protobuf.InvalidProtocolBufferException import java.io.IOException import java.io.InputStream @@ -31,6 +36,15 @@ class FakeJcaSettingsSerializer( override val defaultValue: JcaSettings = JcaSettings.newBuilder() .setDarkModeStatus(DarkMode.DARK_MODE_SYSTEM) .setDefaultFrontCamera(false) + .setBackCameraAvailable(true) + .setFrontCameraAvailable(true) + .setFlashModeStatus(FlashMode.FLASH_MODE_OFF) + .setAspectRatioStatus(AspectRatio.ASPECT_RATIO_NINE_SIXTEEN) + .setCaptureModeStatus(CaptureMode.CAPTURE_MODE_MULTI_STREAM) + .setStabilizePreview(PreviewStabilization.PREVIEW_STABILIZATION_UNDEFINED) + .setStabilizeVideo(VideoStabilization.VIDEO_STABILIZATION_UNDEFINED) + .setStabilizeVideoSupported(false) + .setStabilizePreviewSupported(false) .build() override suspend fun readFrom(input: InputStream): JcaSettings { diff --git a/data/settings/src/main/java/com/google/jetpackcamera/settings/test/FakeSettingsRepository.kt b/data/settings/src/main/java/com/google/jetpackcamera/settings/test/FakeSettingsRepository.kt index e88bf0425..2bb4295a6 100644 --- a/data/settings/src/main/java/com/google/jetpackcamera/settings/test/FakeSettingsRepository.kt +++ b/data/settings/src/main/java/com/google/jetpackcamera/settings/test/FakeSettingsRepository.kt @@ -23,11 +23,14 @@ import com.google.jetpackcamera.settings.model.DEFAULT_CAMERA_APP_SETTINGS import com.google.jetpackcamera.settings.model.DarkMode import com.google.jetpackcamera.settings.model.FlashMode import com.google.jetpackcamera.settings.model.Stabilization +import com.google.jetpackcamera.settings.model.SupportedStabilizationMode import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.flow object FakeSettingsRepository : SettingsRepository { var currentCameraSettings: CameraAppSettings = DEFAULT_CAMERA_APP_SETTINGS + private var isPreviewStabilizationSupported: Boolean = false + private var isVideoStabilizationSupported: Boolean = false override val cameraAppSettings: Flow = flow { emit(currentCameraSettings) } @@ -36,8 +39,8 @@ object FakeSettingsRepository : SettingsRepository { currentCameraSettings = currentCameraSettings.copy(isFrontCameraFacing = newLensFacing) } - override suspend fun updateDarkModeStatus(darkmode: DarkMode) { - currentCameraSettings = currentCameraSettings.copy(darkMode = darkmode) + override suspend fun updateDarkModeStatus(darkMode: DarkMode) { + currentCameraSettings = currentCameraSettings.copy(darkMode = darkMode) } override suspend fun updateFlashModeStatus(flashMode: FlashMode) { @@ -64,14 +67,42 @@ object FakeSettingsRepository : SettingsRepository { } override suspend fun updatePreviewStabilization(stabilization: Stabilization) { - TODO("Not yet implemented") + currentCameraSettings = + currentCameraSettings.copy(previewStabilization = stabilization) } override suspend fun updateVideoStabilization(stabilization: Stabilization) { - TODO("Not yet implemented") + currentCameraSettings = + currentCameraSettings.copy(videoCaptureStabilization = stabilization) + } + + override suspend fun updateVideoStabilizationSupported(isSupported: Boolean) { + isVideoStabilizationSupported = isSupported + setSupportedStabilizationMode() + } + + override suspend fun updatePreviewStabilizationSupported(isSupported: Boolean) { + isPreviewStabilizationSupported = isSupported + setSupportedStabilizationMode() + } + + private fun setSupportedStabilizationMode() { + val stabilizationModes = + buildList { + if (isPreviewStabilizationSupported) { + add(SupportedStabilizationMode.ON) + } + if (isVideoStabilizationSupported) { + add(SupportedStabilizationMode.HIGH_QUALITY) + } + } + + currentCameraSettings = + currentCameraSettings.copy(supportedStabilizationModes = stabilizationModes) } override suspend fun updateAspectRatio(aspectRatio: AspectRatio) { - TODO("Not yet implemented") + currentCameraSettings = + currentCameraSettings.copy(aspectRatio = aspectRatio) } } diff --git a/data/settings/src/main/proto/com/google/jetpackcamera/settings/jca_settings.proto b/data/settings/src/main/proto/com/google/jetpackcamera/settings/jca_settings.proto index 5d235f992..288d501d7 100644 --- a/data/settings/src/main/proto/com/google/jetpackcamera/settings/jca_settings.proto +++ b/data/settings/src/main/proto/com/google/jetpackcamera/settings/jca_settings.proto @@ -39,4 +39,6 @@ message JcaSettings { CaptureMode capture_mode_status = 8; PreviewStabilization stabilize_preview = 9; VideoStabilization stabilize_video = 10; + bool stabilize_video_supported = 11; + bool stabilize_preview_supported = 12; } \ No newline at end of file diff --git a/domain/camera/src/main/java/com/google/jetpackcamera/domain/camera/CameraXCameraUseCase.kt b/domain/camera/src/main/java/com/google/jetpackcamera/domain/camera/CameraXCameraUseCase.kt index 2d7ec164a..aa0f372da 100644 --- a/domain/camera/src/main/java/com/google/jetpackcamera/domain/camera/CameraXCameraUseCase.kt +++ b/domain/camera/src/main/java/com/google/jetpackcamera/domain/camera/CameraXCameraUseCase.kt @@ -31,7 +31,6 @@ import androidx.camera.core.FocusMeteringAction import androidx.camera.core.ImageCapture import androidx.camera.core.ImageCapture.OutputFileOptions import androidx.camera.core.ImageCapture.ScreenFlash -import androidx.camera.core.ImageCapture.ScreenFlashUiCompleter import androidx.camera.core.ImageCaptureException import androidx.camera.core.ImageProxy import androidx.camera.core.Preview @@ -53,6 +52,7 @@ 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.Stabilization +import com.google.jetpackcamera.settings.model.SupportedStabilizationMode import dagger.hilt.android.scopes.ViewModelScoped import java.io.FileNotFoundException import java.lang.RuntimeException @@ -104,6 +104,7 @@ constructor( private lateinit var stabilizePreviewMode: Stabilization private lateinit var stabilizeVideoMode: Stabilization private lateinit var surfaceProvider: Preview.SurfaceProvider + private lateinit var supportedStabilizationModes: List private var isFrontFacing = true private val screenFlashEvents: MutableSharedFlow = @@ -114,10 +115,9 @@ constructor( this.captureMode = currentCameraSettings.captureMode this.stabilizePreviewMode = currentCameraSettings.previewStabilization this.stabilizeVideoMode = currentCameraSettings.videoCaptureStabilization + this.supportedStabilizationModes = currentCameraSettings.supportedStabilizationModes setFlashMode(currentCameraSettings.flashMode, currentCameraSettings.isFrontCameraFacing) cameraProvider = ProcessCameraProvider.getInstance(application).await() - videoCaptureUseCase = createVideoUseCase() - updateUseCaseGroup() val availableCameraLens = listOf( @@ -126,15 +126,16 @@ constructor( ).filter { lensFacing -> cameraProvider.hasCamera(cameraLensToSelector(lensFacing)) } - // updates values for available camera lens if necessary coroutineScope { settingsRepository.updateAvailableCameraLens( availableCameraLens.contains(CameraSelector.LENS_FACING_FRONT), availableCameraLens.contains(CameraSelector.LENS_FACING_BACK) ) + settingsRepository.updateVideoStabilizationSupported(isStabilizationSupported()) } - + videoCaptureUseCase = createVideoUseCase() + updateUseCaseGroup() return availableCameraLens } @@ -330,12 +331,15 @@ constructor( if (isScreenFlashRequired) { imageCaptureUseCase.screenFlash = object : ScreenFlash { - override fun apply(screenFlashUiCompleter: ScreenFlashUiCompleter) { + override fun apply( + expirationTimeMillis: Long, + listener: ImageCapture.ScreenFlashListener + ) { Log.d(TAG, "ImageCapture.ScreenFlash: apply") coroutineScope.launch { screenFlashEvents.emit( CameraUseCase.ScreenFlashEvent(Type.APPLY_UI) { - screenFlashUiCompleter.complete() + listener.onCompleted() } ) } @@ -413,22 +417,31 @@ constructor( useCaseGroup = useCaseGroupBuilder.build() } - private fun createVideoUseCase(): VideoCapture { + /** + * Checks if video stabilization is supported by the device. + * + */ + private fun isStabilizationSupported(): Boolean { val availableCameraInfo = cameraProvider.availableCameraInfos val cameraSelector = if (isFrontFacing) { CameraSelector.DEFAULT_FRONT_CAMERA } else { CameraSelector.DEFAULT_BACK_CAMERA } - val videoCaptureBuilder = VideoCapture.Builder(recorder) - val isVideoStabilizationSupported = cameraSelector.filter(availableCameraInfo).firstOrNull()?.let { Recorder.getVideoCapabilities(it).isStabilizationSupported } ?: false + return isVideoStabilizationSupported + } + + private fun createVideoUseCase(): VideoCapture { + val videoCaptureBuilder = VideoCapture.Builder(recorder) + // set video stabilization - if (isVideoStabilizationSupported && stabilizeVideoMode != Stabilization.UNDEFINED) { + + if (shouldVideoBeStabilized()) { val isStabilized = when (stabilizeVideoMode) { Stabilization.ON -> true Stabilization.OFF, Stabilization.UNDEFINED -> false @@ -438,20 +451,28 @@ constructor( return videoCaptureBuilder.build() } - private fun createPreviewUseCase(): Preview { - val availableCameraInfo = cameraProvider.availableCameraInfos - val cameraSelector = if (isFrontFacing) { - CameraSelector.DEFAULT_FRONT_CAMERA - } else { - CameraSelector.DEFAULT_BACK_CAMERA - } - val isPreviewStabilizationSupported = - cameraSelector.filter(availableCameraInfo).firstOrNull()?.let { - Preview.getPreviewCapabilities(it).isStabilizationSupported - } ?: false + private fun shouldVideoBeStabilized(): Boolean { + // video is supported by the device AND + // video is on OR preview is on + return (supportedStabilizationModes.contains(SupportedStabilizationMode.HIGH_QUALITY)) && + ( + // high quality (video only) selected + ( + stabilizeVideoMode == Stabilization.ON && + stabilizePreviewMode == Stabilization.UNDEFINED + ) || + // or on is selected + ( + stabilizePreviewMode == Stabilization.ON && + stabilizeVideoMode != Stabilization.OFF + ) + ) + } + private fun createPreviewUseCase(): Preview { val previewUseCaseBuilder = Preview.Builder() - if (isPreviewStabilizationSupported && stabilizePreviewMode != Stabilization.UNDEFINED) { + // set preview stabilization + if (shouldPreviewBeStabilized()) { val isStabilized = when (stabilizePreviewMode) { Stabilization.ON -> true else -> false @@ -461,6 +482,13 @@ constructor( return previewUseCaseBuilder.build() } + private fun shouldPreviewBeStabilized(): Boolean { + return ( + supportedStabilizationModes.contains(SupportedStabilizationMode.ON) && + stabilizePreviewMode == Stabilization.ON + ) + } + // converts LensFacing from datastore to @LensFacing Int value private fun getLensFacing(isFrontFacing: Boolean): Int = when (isFrontFacing) { true -> CameraSelector.LENS_FACING_FRONT 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 0f8fdcb51..a078aae95 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 @@ -64,10 +64,12 @@ import com.google.jetpackcamera.feature.preview.ui.PreviewDisplay import com.google.jetpackcamera.feature.preview.ui.ScreenFlashScreen import com.google.jetpackcamera.feature.preview.ui.SettingsNavButton import com.google.jetpackcamera.feature.preview.ui.ShowTestableToast +import com.google.jetpackcamera.feature.preview.ui.StabilizationIcon import com.google.jetpackcamera.feature.preview.ui.TestingButton import com.google.jetpackcamera.feature.preview.ui.ZoomScaleText -import com.google.jetpackcamera.feature.quicksettings.QuickSettingsScreen +import com.google.jetpackcamera.feature.quicksettings.QuickSettingsScreenOverlay import com.google.jetpackcamera.feature.quicksettings.ui.QuickSettingsIndicators +import com.google.jetpackcamera.feature.quicksettings.ui.ToggleQuickSettingsButton import com.google.jetpackcamera.settings.model.CaptureMode import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.awaitCancellation @@ -128,85 +130,127 @@ fun PreviewScreen( Text(text = stringResource(R.string.camera_not_ready), color = Color.White) } } else if (previewUiState.cameraState == CameraState.READY) { - // display camera feed. this stays behind everything else - PreviewDisplay( - onFlipCamera = viewModel::flipCamera, - onTapToFocus = viewModel::tapToFocus, - onZoomChange = { zoomChange: Float -> - viewModel.setZoomScale(zoomChange) - zoomScaleShow = true - zoomHandler.postDelayed({ zoomScaleShow = false }, ZOOM_SCALE_SHOW_TIMEOUT_MS) - }, - aspectRatio = previewUiState.currentCameraSettings.aspectRatio, - deferredSurfaceProvider = deferredSurfaceProvider - ) - // overlay Box( - modifier = Modifier - .semantics { - testTagsAsResourceId = true - } - .fillMaxSize() + modifier = Modifier.semantics { + testTagsAsResourceId = true + } ) { - // hide settings, quickSettings, and quick capture mode button - when (previewUiState.videoRecordingState) { - VideoRecordingState.ACTIVE -> {} - VideoRecordingState.INACTIVE -> { - QuickSettingsScreen( - modifier = Modifier - .align(Alignment.TopCenter), - isOpen = previewUiState.quickSettingsIsOpen, - toggleIsOpen = { viewModel.toggleQuickSettings() }, - currentCameraSettings = previewUiState.currentCameraSettings, - onLensFaceClick = viewModel::flipCamera, - onFlashModeClick = viewModel::setFlash, - onAspectRatioClick = { - viewModel.setAspectRatio(it) - } - // onTimerClick = {}/*TODO*/ - ) + // display camera feed. this stays behind everything else + PreviewDisplay( + onFlipCamera = viewModel::flipCamera, + onTapToFocus = viewModel::tapToFocus, + onZoomChange = { zoomChange: Float -> + viewModel.setZoomScale(zoomChange) + zoomScaleShow = true + zoomHandler.postDelayed({ zoomScaleShow = false }, ZOOM_SCALE_SHOW_TIMEOUT_MS) + }, + aspectRatio = previewUiState.currentCameraSettings.aspectRatio, + deferredSurfaceProvider = deferredSurfaceProvider + ) - Row( - modifier = Modifier - .align(Alignment.TopStart), - horizontalArrangement = Arrangement.Start, - verticalAlignment = Alignment.CenterVertically - ) { - SettingsNavButton( + QuickSettingsScreenOverlay( + modifier = Modifier, + isOpen = previewUiState.quickSettingsIsOpen, + toggleIsOpen = { viewModel.toggleQuickSettings() }, + currentCameraSettings = previewUiState.currentCameraSettings, + onLensFaceClick = viewModel::flipCamera, + onFlashModeClick = viewModel::setFlash, + onAspectRatioClick = { + viewModel.setAspectRatio(it) + } + // onTimerClick = {}/*TODO*/ + ) + // relative-grid style overlay on top of preview display + Column( + modifier = Modifier + .fillMaxSize() + ) { + // hide settings, quickSettings, and quick capture mode button + when (previewUiState.videoRecordingState) { + VideoRecordingState.ACTIVE -> {} + VideoRecordingState.INACTIVE -> { + // 3-segmented row to keep quick settings button centered + Row( modifier = Modifier - .padding(12.dp), - onNavigateToSettings = onNavigateToSettings - ) + .fillMaxWidth() + .height(IntrinsicSize.Min) + ) { + // row to left of quick settings button + Row( + modifier = Modifier + .weight(1f), + horizontalArrangement = Arrangement.Start, + verticalAlignment = Alignment.CenterVertically + ) { + // button to open default settings page + SettingsNavButton( + modifier = Modifier + .padding(12.dp), + onNavigateToSettings = onNavigateToSettings + ) + if (!previewUiState.quickSettingsIsOpen) { + QuickSettingsIndicators( + currentCameraSettings = previewUiState + .currentCameraSettings, + onFlashModeClick = viewModel::setFlash + ) + } + } + // quick settings button + ToggleQuickSettingsButton( + toggleDropDown = { viewModel.toggleQuickSettings() }, + isOpen = previewUiState.quickSettingsIsOpen + ) - QuickSettingsIndicators( - currentCameraSettings = previewUiState.currentCameraSettings, - onFlashModeClick = viewModel::setFlash - ) - } + // Row to right of quick settings + Row( + modifier = Modifier + .weight(1f) + .fillMaxHeight(), + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically + ) { + TestingButton( + modifier = Modifier + .testTag("ToggleCaptureMode"), + onClick = { viewModel.toggleCaptureMode() }, + text = stringResource( + when (previewUiState.currentCameraSettings.captureMode) { + CaptureMode.SINGLE_STREAM -> + R.string.capture_mode_single_stream - TestingButton( - modifier = Modifier - .testTag("ToggleCaptureMode") - .align(Alignment.TopEnd) - .padding(12.dp), - onClick = { viewModel.toggleCaptureMode() }, - text = stringResource( - when (previewUiState.currentCameraSettings.captureMode) { - CaptureMode.SINGLE_STREAM -> R.string.capture_mode_single_stream - CaptureMode.MULTI_STREAM -> R.string.capture_mode_multi_stream + CaptureMode.MULTI_STREAM -> + R.string.capture_mode_multi_stream + } + ) + ) + StabilizationIcon( + supportedStabilizationMode = previewUiState + .currentCameraSettings.supportedStabilizationModes, + videoStabilization = previewUiState + .currentCameraSettings.videoCaptureStabilization, + previewStabilization = previewUiState + .currentCameraSettings.previewStabilization + ) } - ) - ) + } + } } - } - Column( - horizontalAlignment = Alignment.CenterHorizontally, - modifier = Modifier.align(Alignment.BottomCenter) - ) { + // this component places a gap in the center of the column that will push out the top + // and bottom edges. This will also allow the addition of vertical button bars on the + // sides of the screen + Row( + modifier = Modifier + .weight(1f) + .fillMaxWidth() + ) {} + if (zoomScaleShow) { ZoomScaleText(zoomScale = zoomScale) } + + // 3-segmented row to keep capture button centered Row( modifier = Modifier @@ -214,6 +258,7 @@ fun PreviewScreen( .height(IntrinsicSize.Min) ) { when (previewUiState.videoRecordingState) { + // hide first segment while recording in progress VideoRecordingState.ACTIVE -> { Spacer( modifier = Modifier @@ -221,23 +266,33 @@ fun PreviewScreen( .weight(1f) ) } - + // show first segment when not recording VideoRecordingState.INACTIVE -> { - FlipCameraButton( + Row( modifier = Modifier .weight(1f) .fillMaxHeight(), - onClick = { viewModel.flipCamera() }, - // enable only when phone has front and rear camera - enabledCondition = - previewUiState.currentCameraSettings.isBackCameraAvailable && - previewUiState.currentCameraSettings.isFrontCameraAvailable - ) + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically + ) { + if (!previewUiState.quickSettingsIsOpen) { + FlipCameraButton( + onClick = { viewModel.flipCamera() }, + // enable only when phone has front and rear camera + enabledCondition = + previewUiState + .currentCameraSettings + .isBackCameraAvailable && + previewUiState + .currentCameraSettings + .isFrontCameraAvailable + ) + } + } } } val multipleEventsCutter = remember { MultipleEventsCutter() } val context = LocalContext.current - /*todo: close quick settings on start record/image capture*/ CaptureButton( modifier = Modifier .testTag(CAPTURE_BUTTON), @@ -257,21 +312,28 @@ fun PreviewScreen( } } } + if (previewUiState.quickSettingsIsOpen) { + viewModel.toggleQuickSettings() + } + }, + onLongPress = { + viewModel.startVideoRecording() + if (previewUiState.quickSettingsIsOpen) { + viewModel.toggleQuickSettings() + } }, - onLongPress = { viewModel.startVideoRecording() }, onRelease = { viewModel.stopVideoRecording() }, videoRecordingState = previewUiState.videoRecordingState ) - /* spacer is a placeholder to maintain the proportionate location of this - row of UI elements. if you want to add another element, replace it with ONE - element. If you want to add multiple components, use a container - (Box, Row, Column, etc.) - */ - Spacer( + // You can replace this row so long as the weight of the component is 1f to + // ensure the capture button remains centered. + Row( modifier = Modifier .fillMaxHeight() .weight(1f) - ) + ) { + /*TODO("Place other components here") */ + } } } // displays toast when there is a message to show @@ -283,17 +345,17 @@ fun PreviewScreen( onToastShown = viewModel::onToastShown ) } - } - // Screen flash overlay that stays on top of everything but invisible normally. This should - // not be enabled based on whether screen flash is enabled because a previous image capture - // may still be running after flash mode change and clear actions (e.g. brightness restore) - // may need to be handled later. Compose smart recomposition should be able to optimize this - // if the relevant states are no longer changing. - ScreenFlashScreen( - screenFlashUiState = screenFlashUiState, - onInitialBrightnessCalculated = viewModel.screenFlash::setClearUiScreenBrightness - ) + // Screen flash overlay that stays on top of everything but invisible normally. This should + // not be enabled based on whether screen flash is enabled because a previous image capture + // may still be running after flash mode change and clear actions (e.g. brightness restore) + // may need to be handled later. Compose smart recomposition should be able to optimize this + // if the relevant states are no longer changing. + ScreenFlashScreen( + screenFlashUiState = screenFlashUiState, + onInitialBrightnessCalculated = viewModel.screenFlash::setClearUiScreenBrightness + ) + } } } 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 6d16f1d40..01f09c8ee 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 @@ -45,7 +45,6 @@ import androidx.compose.material3.IconButton import androidx.compose.material3.SuggestionChip import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.ui.Alignment @@ -55,12 +54,15 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import com.google.jetpackcamera.feature.preview.R import com.google.jetpackcamera.feature.preview.VideoRecordingState import com.google.jetpackcamera.settings.model.AspectRatio +import com.google.jetpackcamera.settings.model.Stabilization +import com.google.jetpackcamera.settings.model.SupportedStabilizationMode import com.google.jetpackcamera.viewfinder.CameraPreview import kotlinx.coroutines.CompletableDeferred @@ -182,6 +184,29 @@ fun PreviewDisplay( } } +@Composable +fun StabilizationIcon( + supportedStabilizationMode: List, + videoStabilization: Stabilization, + previewStabilization: Stabilization +) { + if (supportedStabilizationMode.isNotEmpty() && + (videoStabilization == Stabilization.ON || previewStabilization == Stabilization.ON) + ) { + val descriptionText = if (videoStabilization == Stabilization.ON) { + stringResource(id = R.string.stabilization_icon_description_preview_and_video) + } else { + // previewStabilization will not be on for high quality + stringResource(id = R.string.stabilization_icon_description_video_only) + } + Icon( + painter = painterResource(id = R.drawable.baseline_video_stable_24), + contentDescription = descriptionText, + tint = Color.White + ) + } +} + /** * A temporary button that can be added to preview for quick testing purposes */ @@ -202,21 +227,18 @@ fun FlipCameraButton( enabledCondition: Boolean, onClick: () -> Unit ) { - Box(modifier = modifier) { - IconButton( - modifier = Modifier - .align(Alignment.Center) - .size(40.dp), - onClick = onClick, - enabled = enabledCondition - ) { - Icon( - imageVector = Icons.Filled.Refresh, - tint = Color.White, - contentDescription = stringResource(id = R.string.flip_camera_content_description), - modifier = Modifier.size(72.dp) - ) - } + IconButton( + modifier = modifier + .size(40.dp), + onClick = onClick, + enabled = enabledCondition + ) { + Icon( + imageVector = Icons.Filled.Refresh, + tint = Color.White, + contentDescription = stringResource(id = R.string.flip_camera_content_description), + modifier = Modifier.size(72.dp) + ) } } diff --git a/feature/preview/src/main/res/drawable/baseline_video_stable_24.xml b/feature/preview/src/main/res/drawable/baseline_video_stable_24.xml new file mode 100644 index 000000000..54f965197 --- /dev/null +++ b/feature/preview/src/main/res/drawable/baseline_video_stable_24.xml @@ -0,0 +1,21 @@ + + + + + + + diff --git a/feature/preview/src/main/res/values/strings.xml b/feature/preview/src/main/res/values/strings.xml index b16f19036..b2713f5f3 100644 --- a/feature/preview/src/main/res/values/strings.xml +++ b/feature/preview/src/main/res/values/strings.xml @@ -23,4 +23,7 @@ Image Capture Success Image Capture Failure + Preview is Stabilized + Only Video is Stabilized + diff --git a/feature/quicksettings/src/main/java/com/google/jetpackcamera/feature/quicksettings/QuickSettingsScreen.kt b/feature/quicksettings/src/main/java/com/google/jetpackcamera/feature/quicksettings/QuickSettingsScreen.kt index 6b0ba2f52..8a8a60ffc 100644 --- a/feature/quicksettings/src/main/java/com/google/jetpackcamera/feature/quicksettings/QuickSettingsScreen.kt +++ b/feature/quicksettings/src/main/java/com/google/jetpackcamera/feature/quicksettings/QuickSettingsScreen.kt @@ -36,9 +36,6 @@ import androidx.compose.ui.draw.alpha import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.dimensionResource -import androidx.compose.ui.semantics.semantics -import androidx.compose.ui.semantics.testTagsAsResourceId -import com.google.jetpackcamera.feature.quicksettings.ui.DropDownIcon import com.google.jetpackcamera.feature.quicksettings.ui.ExpandedQuickSetRatio import com.google.jetpackcamera.feature.quicksettings.ui.QuickFlipCamera import com.google.jetpackcamera.feature.quicksettings.ui.QuickSetFlash @@ -54,7 +51,7 @@ import com.google.jetpackcamera.settings.model.FlashMode */ @OptIn(ExperimentalComposeUiApi::class) @Composable -fun QuickSettingsScreen( +fun QuickSettingsScreenOverlay( modifier: Modifier = Modifier, currentCameraSettings: CameraAppSettings, isOpen: Boolean = false, @@ -83,13 +80,10 @@ fun QuickSettingsScreen( if (isOpen) { Column( modifier = - Modifier + modifier .fillMaxSize() .background(color = backgroundColor.value) .alpha(alpha = contentAlpha.value) - .semantics { - testTagsAsResourceId = true - } .clickable { // if a setting is expanded, click on the background to close it. // if no other settings are expanded, then close the popup @@ -115,11 +109,6 @@ fun QuickSettingsScreen( } else { shouldShowQuickSetting = IsExpandedQuickSetting.NONE } - DropDownIcon( - modifier = modifier, - toggleDropDown = toggleIsOpen, - isOpen = isOpen - ) } // enum representing which individual quick setting is currently expanded diff --git a/feature/quicksettings/src/main/java/com/google/jetpackcamera/feature/quicksettings/ui/QuickSettingsComponents.kt b/feature/quicksettings/src/main/java/com/google/jetpackcamera/feature/quicksettings/ui/QuickSettingsComponents.kt index d3bb28c6e..3242f3545 100644 --- a/feature/quicksettings/src/main/java/com/google/jetpackcamera/feature/quicksettings/ui/QuickSettingsComponents.kt +++ b/feature/quicksettings/src/main/java/com/google/jetpackcamera/feature/quicksettings/ui/QuickSettingsComponents.kt @@ -164,8 +164,15 @@ fun QuickFlipCamera( ) } +/** + * Button to toggle quick settings + */ @Composable -fun DropDownIcon(modifier: Modifier = Modifier, toggleDropDown: () -> Unit, isOpen: Boolean) { +fun ToggleQuickSettingsButton( + modifier: Modifier = Modifier, + toggleDropDown: () -> Unit, + isOpen: Boolean +) { Row( modifier = modifier, horizontalArrangement = Arrangement.Center, diff --git a/feature/settings/src/main/java/com/google/jetpackcamera/settings/SettingsScreen.kt b/feature/settings/src/main/java/com/google/jetpackcamera/settings/SettingsScreen.kt index 29a34fc66..fd8b32c27 100644 --- a/feature/settings/src/main/java/com/google/jetpackcamera/settings/SettingsScreen.kt +++ b/feature/settings/src/main/java/com/google/jetpackcamera/settings/SettingsScreen.kt @@ -31,7 +31,7 @@ import com.google.jetpackcamera.settings.ui.DefaultCameraFacing import com.google.jetpackcamera.settings.ui.FlashModeSetting import com.google.jetpackcamera.settings.ui.SectionHeader import com.google.jetpackcamera.settings.ui.SettingsPageHeader -import com.google.jetpackcamera.settings.ui.VideoStabilizeSetting +import com.google.jetpackcamera.settings.ui.StabilizationSetting /** * Screen used for the Settings feature. @@ -81,10 +81,10 @@ fun SettingsList(uiState: SettingsUiState, viewModel: SettingsViewModel) { // todo: b/313647247 - query device and disable setting if preview stabilization isn't supported. // todo: b/313647809 - query device and disable setting if video stabilization isn't supported. - VideoStabilizeSetting( + StabilizationSetting( currentVideoStabilization = uiState.cameraAppSettings.videoCaptureStabilization, currentPreviewStabilization = uiState.cameraAppSettings.previewStabilization, - + supportedStabilizationMode = uiState.cameraAppSettings.supportedStabilizationModes, setVideoStabilization = viewModel::setVideoStabilization, setPreviewStabilization = viewModel::setPreviewStabilization ) diff --git a/feature/settings/src/main/java/com/google/jetpackcamera/settings/ui/SettingsComponents.kt b/feature/settings/src/main/java/com/google/jetpackcamera/settings/ui/SettingsComponents.kt index 067eb5274..e97021666 100644 --- a/feature/settings/src/main/java/com/google/jetpackcamera/settings/ui/SettingsComponents.kt +++ b/feature/settings/src/main/java/com/google/jetpackcamera/settings/ui/SettingsComponents.kt @@ -32,6 +32,7 @@ import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.ListItem +import androidx.compose.material3.LocalContentColor import androidx.compose.material3.MaterialTheme import androidx.compose.material3.RadioButton import androidx.compose.material3.Switch @@ -44,7 +45,6 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.Role -import androidx.compose.ui.text.font.FontStyle import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import com.google.jetpackcamera.settings.R @@ -54,6 +54,7 @@ import com.google.jetpackcamera.settings.model.CaptureMode import com.google.jetpackcamera.settings.model.DarkMode import com.google.jetpackcamera.settings.model.FlashMode import com.google.jetpackcamera.settings.model.Stabilization +import com.google.jetpackcamera.settings.model.SupportedStabilizationMode /** * MAJOR SETTING UI COMPONENTS @@ -271,41 +272,44 @@ private fun getStabilizationStringRes( * ON - Both preview and video are stabilized. * HIGH_QUALITY - Video will be stabilized, preview might be stabilized, depending on the device. * OFF - Preview and video stabilization is disabled. + * + * @param supportedStabilizationMode the enabled condition for this setting. */ @Composable -fun VideoStabilizeSetting( +fun StabilizationSetting( currentPreviewStabilization: Stabilization, currentVideoStabilization: Stabilization, + supportedStabilizationMode: List, setVideoStabilization: (Stabilization) -> Unit, setPreviewStabilization: (Stabilization) -> Unit ) { BasicPopupSetting( title = stringResource(R.string.video_stabilization_title), leadingIcon = null, - description = stringResource( - id = getStabilizationStringRes( - previewStabilization = currentPreviewStabilization, - videoStabilization = currentVideoStabilization + enabled = supportedStabilizationMode.isNotEmpty(), + description = if (supportedStabilizationMode.isEmpty()) { + stringResource(id = R.string.stabilization_description_unsupported) + } else { + stringResource( + id = getStabilizationStringRes( + previewStabilization = currentPreviewStabilization, + videoStabilization = currentVideoStabilization + ) ) - ), + }, popupContents = { Column(Modifier.selectableGroup()) { - Text( - text = stringResource(id = R.string.stabilization_popup_disclaimer), - fontStyle = FontStyle.Italic, - color = MaterialTheme.colorScheme.onPrimaryContainer - ) - Spacer(modifier = Modifier.height(10.dp)) // on selector SingleChoiceSelector( text = stringResource(id = R.string.stabilization_selector_on), secondaryText = stringResource(id = R.string.stabilization_selector_on_info), + enabled = supportedStabilizationMode.contains(SupportedStabilizationMode.ON), selected = (currentPreviewStabilization == Stabilization.ON) && (currentVideoStabilization != Stabilization.OFF), onClick = { - setVideoStabilization(Stabilization.ON) + setVideoStabilization(Stabilization.UNDEFINED) setPreviewStabilization(Stabilization.ON) } ) @@ -316,6 +320,9 @@ fun VideoStabilizeSetting( secondaryText = stringResource( id = R.string.stabilization_selector_high_quality_info ), + enabled = supportedStabilizationMode.contains( + SupportedStabilizationMode.HIGH_QUALITY + ), selected = (currentPreviewStabilization == Stabilization.UNDEFINED) && (currentVideoStabilization == Stabilization.ON), @@ -361,6 +368,7 @@ fun BasicPopupSetting( SettingUI( modifier = modifier.clickable(enabled = enabled) { popupStatus.value = true }, title = title, + enabled = enabled, description = description, leadingIcon = leadingIcon, trailingContent = null @@ -403,6 +411,7 @@ fun SwitchSettingUI( value = settingValue, onValueChange = { _ -> onClick() } ), + enabled = enabled, title = title, description = description, leadingIcon = leadingIcon, @@ -425,17 +434,30 @@ fun SwitchSettingUI( fun SettingUI( modifier: Modifier = Modifier, title: String, + enabled: Boolean = true, description: String? = null, leadingIcon: @Composable (() -> Unit)?, trailingContent: @Composable (() -> Unit)? ) { ListItem( modifier = modifier, - headlineContent = { Text(title) }, - supportingContent = when (description) { - null -> null - else -> { - { Text(description) } + headlineContent = { + when (enabled) { + true -> Text(title) + false -> { + Text(text = title, color = LocalContentColor.current.copy(alpha = .7f)) + } + } + }, + supportingContent = { + if (description != null) { + when (enabled) { + true -> Text(description) + false -> Text( + text = description, + color = LocalContentColor.current.copy(alpha = .7f) + ) + } } }, leadingContent = leadingIcon, @@ -461,13 +483,15 @@ fun SingleChoiceSelector( .selectable( selected = selected, role = Role.RadioButton, - onClick = onClick + onClick = onClick, + enabled = enabled ), verticalAlignment = Alignment.CenterVertically ) { SettingUI( title = text, description = secondaryText, + enabled = enabled, leadingIcon = { RadioButton( selected = selected, diff --git a/feature/settings/src/main/res/values/strings.xml b/feature/settings/src/main/res/values/strings.xml index 2ffcf7483..44a0bbcc0 100644 --- a/feature/settings/src/main/res/values/strings.xml +++ b/feature/settings/src/main/res/values/strings.xml @@ -72,7 +72,6 @@ Set Video Stabilization - *Stabilization will be applied only to supported devices." On High Quality @@ -84,4 +83,5 @@ Stabilization On Stabilization High Quality Stabilization Off + Stabilization unsupported \ No newline at end of file diff --git a/settings.gradle.kts b/settings.gradle.kts index dce870c7e..b611e8d81 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -25,7 +25,7 @@ dependencyResolutionManagement { repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) repositories { maven { - setUrl("https://androidx.dev/snapshots/builds/11253863/artifacts/repository") + setUrl("https://androidx.dev/snapshots/builds/11359450/artifacts/repository") } google() mavenCentral()