diff --git a/feature/preview/build.gradle.kts b/feature/preview/build.gradle.kts index 5ba5f3ac..b4480051 100644 --- a/feature/preview/build.gradle.kts +++ b/feature/preview/build.gradle.kts @@ -134,7 +134,7 @@ dependencies { // CameraX implementation(libs.camera.core) - implementation(libs.camera.viewfinder.compose) + implementation(libs.camera.compose) // Hilt implementation(libs.dagger.hilt.android) 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 55583a2b..f6e08575 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 @@ -38,6 +38,7 @@ import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -203,15 +204,11 @@ private fun ContentScreen( Scaffold( snackbarHost = { SnackbarHost(hostState = snackbarHostState) } ) { - val lensFacing = remember(previewUiState) { + val lensFacing by rememberUpdatedState( previewUiState.currentCameraSettings.cameraLensFacing - } + ) - val onFlipCamera = remember(lensFacing) { - { - onSetLensFacing(lensFacing.flip()) - } - } + val onFlipCamera = { onSetLensFacing(lensFacing.flip()) } val isMuted = remember(previewUiState) { previewUiState.currentCameraSettings.audioMuted diff --git a/feature/preview/src/main/java/com/google/jetpackcamera/feature/preview/ui/CameraXViewfinder.kt b/feature/preview/src/main/java/com/google/jetpackcamera/feature/preview/ui/CameraXViewfinder.kt deleted file mode 100644 index 2cf49adf..00000000 --- a/feature/preview/src/main/java/com/google/jetpackcamera/feature/preview/ui/CameraXViewfinder.kt +++ /dev/null @@ -1,187 +0,0 @@ -/* - * 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.feature.preview.ui - -import android.content.pm.ActivityInfo -import android.os.Build -import android.util.Log -import androidx.camera.core.DynamicRange -import androidx.camera.core.Preview -import androidx.camera.core.SurfaceRequest -import androidx.camera.core.SurfaceRequest.TransformationInfo as CXTransformationInfo -import androidx.camera.viewfinder.compose.MutableCoordinateTransformer -import androidx.camera.viewfinder.compose.Viewfinder -import androidx.camera.viewfinder.surface.ImplementationMode -import androidx.camera.viewfinder.surface.TransformationInfo -import androidx.camera.viewfinder.surface.ViewfinderSurfaceRequest -import androidx.compose.foundation.gestures.detectTapGestures -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.getValue -import androidx.compose.runtime.produceState -import androidx.compose.runtime.rememberUpdatedState -import androidx.compose.runtime.snapshotFlow -import androidx.compose.ui.Modifier -import androidx.compose.ui.input.pointer.pointerInput -import kotlinx.coroutines.CoroutineStart -import kotlinx.coroutines.Runnable -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.collect -import kotlinx.coroutines.flow.collectLatest -import kotlinx.coroutines.flow.combine -import kotlinx.coroutines.flow.distinctUntilChanged -import kotlinx.coroutines.flow.filterNotNull -import kotlinx.coroutines.flow.map -import kotlinx.coroutines.flow.onCompletion -import kotlinx.coroutines.flow.onEach -import kotlinx.coroutines.flow.takeWhile -import kotlinx.coroutines.launch - -private const val TAG = "CameraXViewfinder" - -/** - * A composable viewfinder that adapts CameraX's [Preview.SurfaceProvider] to [Viewfinder] - * - * This adapter code will eventually be upstreamed to CameraX, but for now can be copied - * in its entirety to connect CameraX to [Viewfinder]. - * - * @param[modifier] the modifier to be applied to the layout - * @param[surfaceRequest] a [SurfaceRequest] from [Preview.SurfaceProvider]. - * @param[implementationMode] the implementation mode, either [ImplementationMode.EXTERNAL] or - * [ImplementationMode.EMBEDDED]. - */ -@Composable -fun CameraXViewfinder( - surfaceRequest: SurfaceRequest, - modifier: Modifier = Modifier, - implementationMode: ImplementationMode = ImplementationMode.EXTERNAL, - onRequestWindowColorMode: (Int) -> Unit = {}, - onTap: (x: Float, y: Float) -> Unit = { _, _ -> } -) { - val currentImplementationMode by rememberUpdatedState(implementationMode) - val currentOnRequestWindowColorMode by rememberUpdatedState(onRequestWindowColorMode) - - val viewfinderArgs by produceState(initialValue = null, surfaceRequest) { - val viewfinderSurfaceRequest = ViewfinderSurfaceRequest.Builder(surfaceRequest.resolution) - .build() - - surfaceRequest.addRequestCancellationListener(Runnable::run) { - viewfinderSurfaceRequest.markSurfaceSafeToRelease() - } - - // Launch undispatched so we always reach the try/finally in this coroutine - launch(start = CoroutineStart.UNDISPATCHED) { - try { - val surface = viewfinderSurfaceRequest.getSurface() - surfaceRequest.provideSurface(surface, Runnable::run) { - viewfinderSurfaceRequest.markSurfaceSafeToRelease() - } - } finally { - // If we haven't provided the surface, such as if we're cancelled - // while suspending on getSurface(), this call will succeed. Otherwise - // it will be a no-op. - surfaceRequest.willNotProvideSurface() - } - } - - val transformationInfos = MutableStateFlow(null) - surfaceRequest.setTransformationInfoListener(Runnable::run) { - transformationInfos.value = it - } - - // The ImplementationMode that will be used for all TransformationInfo updates. - // This is locked in once we have updated ViewfinderArgs and won't change until - // this produceState block is cancelled and restarted - var snapshotImplementationMode: ImplementationMode? = null - - snapshotFlow { currentImplementationMode } - .combine(transformationInfos.filterNotNull()) { implMode, transformInfo -> - Pair(implMode, transformInfo) - }.takeWhile { (implMode, _) -> - val shouldAbort = - snapshotImplementationMode != null && implMode != snapshotImplementationMode - if (shouldAbort) { - // Abort flow and invalidate SurfaceRequest so a new one will be sent - surfaceRequest.invalidate() - } - !shouldAbort - }.collectLatest { (implMode, transformInfo) -> - // We'll only ever get here with a single non-null implMode, - // so setting it every time is ok - snapshotImplementationMode = implMode - value = ViewfinderArgs( - viewfinderSurfaceRequest, - isSourceHdr = surfaceRequest.dynamicRange.encoding != DynamicRange.ENCODING_SDR, - implMode, - TransformationInfo( - sourceRotation = transformInfo.rotationDegrees, - cropRectLeft = transformInfo.cropRect.left, - cropRectTop = transformInfo.cropRect.top, - cropRectRight = transformInfo.cropRect.right, - cropRectBottom = transformInfo.cropRect.bottom, - shouldMirror = transformInfo.isMirroring - ) - ) - } - } - - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - LaunchedEffect(Unit) { - snapshotFlow { viewfinderArgs } - .filterNotNull() - .map { args -> - if (args.isSourceHdr && - args.implementationMode == ImplementationMode.EXTERNAL - ) { - ActivityInfo.COLOR_MODE_HDR - } else { - ActivityInfo.COLOR_MODE_DEFAULT - } - }.distinctUntilChanged() - .onEach { currentOnRequestWindowColorMode(it) } - .onCompletion { currentOnRequestWindowColorMode(ActivityInfo.COLOR_MODE_DEFAULT) } - .collect() - } - } - - val coordinateTransformer = MutableCoordinateTransformer() - - viewfinderArgs?.let { args -> - Viewfinder( - surfaceRequest = args.viewfinderSurfaceRequest, - implementationMode = args.implementationMode, - transformationInfo = args.transformationInfo, - modifier = modifier.fillMaxSize().pointerInput(Unit) { - detectTapGestures { - with(coordinateTransformer) { - val tapOffset = it.transform() - Log.d(TAG, "onTap: $tapOffset") - onTap(tapOffset.x, tapOffset.y) - } - } - }, - coordinateTransformer = coordinateTransformer - ) - } -} - -private data class ViewfinderArgs( - val viewfinderSurfaceRequest: ViewfinderSurfaceRequest, - val isSourceHdr: Boolean, - val implementationMode: ImplementationMode, - val transformationInfo: TransformationInfo -) 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 25a0f285..f5d8139b 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 @@ -15,11 +15,15 @@ */ package com.google.jetpackcamera.feature.preview.ui +import android.content.pm.ActivityInfo import android.content.res.Configuration import android.os.Build import android.util.Log import android.widget.Toast +import androidx.camera.compose.CameraXViewfinder +import androidx.camera.core.DynamicRange as CXDynamicRange import androidx.camera.core.SurfaceRequest +import androidx.camera.viewfinder.compose.MutableCoordinateTransformer import androidx.camera.viewfinder.surface.ImplementationMode import androidx.compose.animation.core.EaseOutExpo import androidx.compose.animation.core.LinearEasing @@ -51,7 +55,6 @@ import androidx.compose.material.icons.filled.CameraAlt import androidx.compose.material.icons.filled.FlipCameraAndroid import androidx.compose.material.icons.filled.Mic import androidx.compose.material.icons.filled.MicOff -import androidx.compose.material.icons.filled.Nightlight import androidx.compose.material.icons.filled.Settings import androidx.compose.material.icons.filled.VideoStable import androidx.compose.material.icons.filled.Videocam @@ -75,6 +78,7 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.setValue +import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.alpha @@ -98,6 +102,10 @@ import com.google.jetpackcamera.settings.model.AspectRatio import com.google.jetpackcamera.settings.model.LowLightBoost import com.google.jetpackcamera.settings.model.Stabilization import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.onCompletion import kotlinx.coroutines.launch private const val TAG = "PreviewScreen" @@ -264,24 +272,12 @@ fun PreviewDisplay( } ) - val currentOnFlipCamera by rememberUpdatedState(onFlipCamera) - surfaceRequest?.let { BoxWithConstraints( Modifier .testTag(PREVIEW_DISPLAY) .fillMaxSize() - .background(Color.Black) - .pointerInput(Unit) { - detectTapGestures( - onDoubleTap = { offset -> - // double tap to flip camera - Log.d(TAG, "onDoubleTap $offset") - currentOnFlipCamera() - } - ) - }, - + .background(Color.Black), contentAlignment = Alignment.Center ) { val maxAspectRatio: Float = maxWidth / maxHeight @@ -316,21 +312,94 @@ fun PreviewDisplay( .alpha(imageAlpha) .clip(RoundedCornerShape(16.dp)) ) { + val implementationMode = when { + Build.VERSION.SDK_INT > 24 -> ImplementationMode.EXTERNAL + else -> ImplementationMode.EMBEDDED + } + + DetectWindowColorModeChanges( + surfaceRequest = surfaceRequest, + implementationMode = implementationMode, + onRequestWindowColorMode = onRequestWindowColorMode + ) + + val coordinateTransformer = remember { MutableCoordinateTransformer() } CameraXViewfinder( - modifier = Modifier.fillMaxSize(), + modifier = Modifier + .fillMaxSize() + .pointerInput(Unit) { + detectTapGestures( + onDoubleTap = { offset -> + // double tap to flip camera + Log.d(TAG, "onDoubleTap $offset") + onFlipCamera() + }, + onTap = { + with(coordinateTransformer) { + val surfaceCoords = it.transform() + Log.d( + "TAG", + "onTapToFocus: " + + "input{$it} -> surface{$surfaceCoords}" + ) + onTapToFocus(surfaceCoords.x, surfaceCoords.y) + } + } + ) + }, surfaceRequest = it, - implementationMode = when { - Build.VERSION.SDK_INT > 24 -> ImplementationMode.EXTERNAL - else -> ImplementationMode.EMBEDDED - }, - onRequestWindowColorMode = onRequestWindowColorMode, - onTap = { x, y -> onTapToFocus(x, y) } + implementationMode = implementationMode, + coordinateTransformer = coordinateTransformer ) } } } } +@Composable +fun DetectWindowColorModeChanges( + surfaceRequest: SurfaceRequest, + implementationMode: ImplementationMode, + onRequestWindowColorMode: (Int) -> Unit +) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + val currentSurfaceRequest: SurfaceRequest by rememberUpdatedState(surfaceRequest) + val currentImplementationMode: ImplementationMode by rememberUpdatedState( + implementationMode + ) + val currentOnRequestWindowColorMode: (Int) -> Unit by rememberUpdatedState( + onRequestWindowColorMode + ) + + LaunchedEffect(Unit) { + val colorModeSnapshotFlow = + snapshotFlow { Pair(currentSurfaceRequest.dynamicRange, currentImplementationMode) } + .map { (dynamicRange, implMode) -> + val isSourceHdr = dynamicRange.encoding != CXDynamicRange.ENCODING_SDR + val destSupportsHdr = implMode == ImplementationMode.EXTERNAL + if (isSourceHdr && destSupportsHdr) { + ActivityInfo.COLOR_MODE_HDR + } else { + ActivityInfo.COLOR_MODE_DEFAULT + } + }.distinctUntilChanged() + + val callbackSnapshotFlow = snapshotFlow { currentOnRequestWindowColorMode } + + // Combine both flows so that we call the callback every time it changes or the + // window color mode changes. + // We'll also reset to default when this LaunchedEffect is disposed + combine(colorModeSnapshotFlow, callbackSnapshotFlow) { colorMode, callback -> + Pair(colorMode, callback) + }.onCompletion { + currentOnRequestWindowColorMode(ActivityInfo.COLOR_MODE_DEFAULT) + }.collect { (colorMode, callback) -> + callback(colorMode) + } + } + } +} + @Composable fun StabilizationIcon( videoStabilization: Stabilization, diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 36ea1328..d5bfb5e6 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -21,8 +21,7 @@ protobufPlugin = "0.9.4" androidxActivityCompose = "1.8.2" androidxAppCompat = "1.6.1" androidxBenchmark = "1.2.3" -androidxCamera = "1.4.0-SNAPSHOT" -androidxCameraViewfinder = "1.0.0-SNAPSHOT" +androidxCamera = "1.5.0-SNAPSHOT" androidxConcurrentFutures = "1.1.0" androidxCoreKtx = "1.12.0" androidxDatastore = "1.0.0" @@ -73,7 +72,7 @@ camera-camera2 = { module = "androidx.camera:camera-camera2", version.ref = "and camera-core = { module = "androidx.camera:camera-core", version.ref = "androidxCamera" } camera-lifecycle = { module = "androidx.camera:camera-lifecycle", version.ref = "androidxCamera" } camera-video = { module = "androidx.camera:camera-video", version.ref = "androidxCamera" } -camera-viewfinder-compose = { module = "androidx.camera:camera-viewfinder-compose", version.ref = "androidxCameraViewfinder" } +camera-compose = { module = "androidx.camera:camera-compose", version.ref = "androidxCamera" } compose-bom = { module = "androidx.compose:compose-bom", version.ref = "composeBom" } compose-junit = { module = "androidx.compose.ui:ui-test-junit4" } compose-material3 = { module = "androidx.compose.material3:material3" } diff --git a/settings.gradle.kts b/settings.gradle.kts index 7c7842f5..7702c188 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -26,7 +26,7 @@ dependencyResolutionManagement { repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) repositories { maven { - setUrl("https://androidx.dev/snapshots/builds/12167802/artifacts/repository") + setUrl("https://androidx.dev/snapshots/builds/12335046/artifacts/repository") } google() mavenCentral()