tilt-shift-camera/app/src/main/java/no/naiv/tiltshift/camera/CameraManager.kt
Ole-Morten Duesund 4f8661f648 Revert orientation tracking on the camera image
Roll back the rendering-pipeline changes from v1.1.6 (and the
subsequent attempts in 1.1.7/1.1.8/1.1.9): no more
SurfaceTexture transform-matrix-driven re-orientation, no more
rebinding on rotation, no more inverse-rotation correction. The
camera passthrough goes back to fixed portrait-oriented texcoords
and the crop-to-fill math treats the camera buffer as portrait
unconditionally.

The activity stays `fullSensor`, so the Compose UI and the GL
blur passes continue to follow the device orientation — only the
camera image itself is left untouched, which matches the
"normal camera doesn't flip the picture when rotating the phone"
behaviour requested.

Bump to 1.1.10.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 14:59:18 +02:00

232 lines
8.2 KiB
Kotlin

package no.naiv.tiltshift.camera
import android.content.Context
import android.graphics.SurfaceTexture
import android.util.Log
import android.util.Size
import android.view.Surface
import androidx.camera.core.Camera
import androidx.camera.core.CameraSelector
import androidx.camera.core.ImageCapture
import androidx.camera.core.Preview
import androidx.camera.core.SurfaceRequest
import androidx.camera.core.resolutionselector.AspectRatioStrategy
import androidx.camera.core.resolutionselector.ResolutionSelector
import androidx.camera.lifecycle.ProcessCameraProvider
import androidx.core.content.ContextCompat
import androidx.lifecycle.LifecycleOwner
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import java.lang.ref.WeakReference
import java.util.concurrent.Executor
import java.util.concurrent.ExecutorService
import java.util.concurrent.Executors
/**
* Manages CameraX camera setup and controls.
*/
class CameraManager(private val context: Context) {
companion object {
private const val TAG = "CameraManager"
}
private var cameraProvider: ProcessCameraProvider? = null
private var camera: Camera? = null
private var preview: Preview? = null
var imageCapture: ImageCapture? = null
private set
val lensController = LensController()
private val _error = MutableStateFlow<String?>(null)
val error: StateFlow<String?> = _error.asStateFlow()
fun clearError() {
_error.value = null
}
private val _zoomRatio = MutableStateFlow(1.0f)
val zoomRatio: StateFlow<Float> = _zoomRatio.asStateFlow()
private val _minZoomRatio = MutableStateFlow(1.0f)
val minZoomRatio: StateFlow<Float> = _minZoomRatio.asStateFlow()
private val _maxZoomRatio = MutableStateFlow(1.0f)
val maxZoomRatio: StateFlow<Float> = _maxZoomRatio.asStateFlow()
private val _isFrontCamera = MutableStateFlow(false)
val isFrontCamera: StateFlow<Boolean> = _isFrontCamera.asStateFlow()
private val _previewResolution = MutableStateFlow(Size(0, 0))
val previewResolution: StateFlow<Size> = _previewResolution.asStateFlow()
/** Background executor for image capture callbacks to avoid blocking the main thread. */
private val captureExecutor: ExecutorService = Executors.newSingleThreadExecutor()
private var surfaceTextureProvider: (() -> SurfaceTexture?)? = null
private var surfaceSize: Size = Size(1920, 1080)
/** Weak reference to avoid preventing Activity GC across config changes. */
private var lifecycleOwnerRef: WeakReference<LifecycleOwner>? = null
/**
* Starts the camera with the given lifecycle owner.
* The surfaceTextureProvider should return the SurfaceTexture from the GL renderer.
*/
fun startCamera(
lifecycleOwner: LifecycleOwner,
surfaceTextureProvider: () -> SurfaceTexture?
) {
this.surfaceTextureProvider = surfaceTextureProvider
this.lifecycleOwnerRef = WeakReference(lifecycleOwner)
val cameraProviderFuture = ProcessCameraProvider.getInstance(context)
cameraProviderFuture.addListener({
try {
cameraProvider = cameraProviderFuture.get()
lensController.initialize(cameraProvider?.availableCameraInfos ?: emptyList())
bindCameraUseCases(lifecycleOwner)
} catch (e: Exception) {
Log.e(TAG, "CameraX initialization failed", e)
_error.value = "Camera could not initialize. Please restart the app."
}
}, ContextCompat.getMainExecutor(context))
}
private fun bindCameraUseCases(lifecycleOwner: LifecycleOwner) {
val provider = cameraProvider ?: run {
Log.w(TAG, "bindCameraUseCases called before camera provider initialized")
return
}
// Unbind all use cases before rebinding
provider.unbindAll()
// Preview use case with resolution selector
val resolutionSelector = ResolutionSelector.Builder()
.setAspectRatioStrategy(AspectRatioStrategy.RATIO_16_9_FALLBACK_AUTO_STRATEGY)
.build()
preview = Preview.Builder()
.setResolutionSelector(resolutionSelector)
.build()
// Image capture use case
val captureBuilder = ImageCapture.Builder()
.setCaptureMode(ImageCapture.CAPTURE_MODE_MAXIMIZE_QUALITY)
imageCapture = captureBuilder.build()
// Select camera based on front/back preference
val cameraSelector = if (_isFrontCamera.value) {
CameraSelector.DEFAULT_FRONT_CAMERA
} else {
// Use lens controller for back camera lens selection
lensController.getCurrentLens()?.selector ?: CameraSelector.DEFAULT_BACK_CAMERA
}
try {
// Bind use cases to camera
camera = provider.bindToLifecycle(
lifecycleOwner,
cameraSelector,
preview,
imageCapture
)
// Update zoom info
camera?.cameraInfo?.let { info ->
_minZoomRatio.value = info.zoomState.value?.minZoomRatio ?: 1.0f
_maxZoomRatio.value = info.zoomState.value?.maxZoomRatio ?: 1.0f
_zoomRatio.value = info.zoomState.value?.zoomRatio ?: 1.0f
}
// Set up surface provider for preview
preview?.setSurfaceProvider { request ->
provideSurface(request)
}
} catch (e: SecurityException) {
Log.e(TAG, "Camera permission denied at runtime", e)
_error.value = "Camera permission was revoked. Please grant it in Settings."
} catch (e: Exception) {
Log.e(TAG, "Camera binding failed", e)
_error.value = "Camera could not start. Please try again or restart the app."
}
}
private fun provideSurface(request: SurfaceRequest) {
val surfaceTexture = surfaceTextureProvider?.invoke()
if (surfaceTexture == null) {
request.willNotProvideSurface()
return
}
surfaceSize = request.resolution
_previewResolution.value = surfaceSize
surfaceTexture.setDefaultBufferSize(surfaceSize.width, surfaceSize.height)
val surface = Surface(surfaceTexture)
request.provideSurface(surface, ContextCompat.getMainExecutor(context)) { result ->
surface.release()
}
}
/**
* Sets the zoom ratio. Updates UI state immediately so that rapid pinch-to-zoom
* gestures accumulate correctly (each frame uses the latest ratio as its base).
* If the camera rejects the value, the next successful set corrects the state.
*/
fun setZoom(ratio: Float) {
val clamped = ratio.coerceIn(_minZoomRatio.value, _maxZoomRatio.value)
_zoomRatio.value = clamped
camera?.cameraControl?.setZoomRatio(clamped)
}
/**
* Sets zoom by linear percentage (0.0 to 1.0).
*/
fun setZoomLinear(percentage: Float) {
camera?.cameraControl?.setLinearZoom(percentage.coerceIn(0f, 1f))
}
/**
* Switches between front and back camera.
*/
fun switchCamera() {
_isFrontCamera.value = !_isFrontCamera.value
_zoomRatio.value = 1.0f // Reset zoom when switching
lifecycleOwnerRef?.get()?.let { bindCameraUseCases(it) }
}
/**
* Switches to a different lens (back camera only).
*/
fun switchLens(lensId: String, lifecycleOwner: LifecycleOwner) {
if (!_isFrontCamera.value && lensController.selectLens(lensId)) {
bindCameraUseCases(lifecycleOwner)
}
}
/**
* Gets the background executor for image capture callbacks.
* Uses a dedicated thread to avoid blocking the main/UI thread during heavy
* bitmap processing (decode, rotate, tilt-shift effect).
*/
fun getExecutor(): Executor {
return captureExecutor
}
/**
* Releases camera resources and shuts down the background executor.
*/
fun release() {
cameraProvider?.unbindAll()
captureExecutor.shutdown()
camera = null
preview = null
imageCapture = null
lifecycleOwnerRef = null
}
}