tilt-shift-camera/app/src/main/java/no/naiv/tiltshift/ui/CameraScreen.kt
Ole-Morten Duesund d321f07973 Support landscape orientation
Replace hardcoded portrait-only texture coordinate rotation with
SurfaceTexture.getTransformMatrix(), so the camera preview and capture
re-orient correctly when the device rotates. Also drive
Preview/ImageCapture targetRotation from the live display rotation, fix
the crop-to-fill aspect math to swap effective camera dimensions
between portrait and landscape, and make the slider control panel
scroll if it doesn't fit the shorter landscape height.

Bump to 1.1.6.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 16:31:43 +02:00

610 lines
24 KiB
Kotlin

package no.naiv.tiltshift.ui
import android.content.Intent
import android.graphics.SurfaceTexture
import android.opengl.GLSurfaceView
import android.util.Log
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.systemGestureExclusion
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.PickVisualMediaRequest
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.FlipCameraAndroid
import androidx.compose.material.icons.filled.LocationOff
import androidx.compose.material.icons.filled.LocationOn
import androidx.compose.material.icons.filled.PhotoLibrary
import androidx.compose.material.icons.filled.Tune
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.semantics.LiveRegionMode
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.liveRegion
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.semantics.stateDescription
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.viewinterop.AndroidView
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
import androidx.lifecycle.compose.LocalLifecycleOwner
import androidx.lifecycle.viewmodel.compose.viewModel
import kotlinx.coroutines.flow.collectLatest
import no.naiv.tiltshift.effect.BlurParameters
import no.naiv.tiltshift.effect.TiltShiftRenderer
import no.naiv.tiltshift.ui.theme.AppColors
/**
* Main camera screen with tilt-shift controls.
* Uses CameraViewModel to survive configuration changes.
*/
@Composable
fun CameraScreen(
modifier: Modifier = Modifier,
viewModel: CameraViewModel = viewModel()
) {
val context = LocalContext.current
val lifecycleOwner = LocalLifecycleOwner.current
// GL state (view-layer, not in ViewModel)
var surfaceTexture by remember { mutableStateOf<SurfaceTexture?>(null) }
var renderer by remember { mutableStateOf<TiltShiftRenderer?>(null) }
var glSurfaceView by remember { mutableStateOf<GLSurfaceView?>(null) }
// Collect ViewModel state
val blurParams by viewModel.blurParams.collectAsState()
val isCapturing by viewModel.isCapturing.collectAsState()
val isProcessing by viewModel.isProcessing.collectAsState()
val showSaveSuccess by viewModel.showSaveSuccess.collectAsState()
val showSaveError by viewModel.showSaveError.collectAsState()
val showControls by viewModel.showControls.collectAsState()
val lastSavedUri by viewModel.lastSavedUri.collectAsState()
val lastThumbnailBitmap by viewModel.lastThumbnailBitmap.collectAsState()
val galleryPreviewBitmap by viewModel.galleryPreviewBitmap.collectAsState()
val geotagEnabled by viewModel.geotagEnabled.collectAsState()
val isGalleryPreview = galleryPreviewBitmap != null
val zoomRatio by viewModel.cameraManager.zoomRatio.collectAsState()
val minZoom by viewModel.cameraManager.minZoomRatio.collectAsState()
val maxZoom by viewModel.cameraManager.maxZoomRatio.collectAsState()
val isFrontCamera by viewModel.cameraManager.isFrontCamera.collectAsState()
val previewResolution by viewModel.cameraManager.previewResolution.collectAsState()
val cameraError by viewModel.cameraManager.error.collectAsState()
val currentRotation by viewModel.currentRotation.collectAsState()
// Gallery picker
val galleryLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.PickVisualMedia()
) { uri ->
if (uri != null) {
viewModel.loadGalleryImage(uri)
}
}
// Show camera errors
LaunchedEffect(cameraError) {
cameraError?.let { message ->
viewModel.showCameraError(message)
viewModel.cameraManager.clearError()
}
}
// Collect orientation updates
LaunchedEffect(Unit) {
viewModel.orientationDetector.orientationFlow().collectLatest { rotation ->
viewModel.updateRotation(rotation)
}
}
// Collect location updates
LaunchedEffect(Unit) {
viewModel.locationProvider.locationFlow().collectLatest { location ->
viewModel.updateLocation(location)
}
}
// Update renderer with blur params
LaunchedEffect(blurParams) {
renderer?.updateParameters(blurParams)
glSurfaceView?.requestRender()
}
// Update renderer when camera switches (front/back)
LaunchedEffect(isFrontCamera) {
renderer?.setFrontCamera(isFrontCamera)
glSurfaceView?.requestRender()
}
// Update renderer with camera preview resolution for crop-to-fill
LaunchedEffect(previewResolution) {
if (previewResolution.width > 0) {
renderer?.setCameraResolution(previewResolution.width, previewResolution.height)
glSurfaceView?.requestRender()
}
}
// Forward device rotation to renderer (aspect math) and CameraX (target rotation)
LaunchedEffect(currentRotation, renderer) {
renderer?.setDisplayRotation(currentRotation)
viewModel.cameraManager.setTargetRotation(currentRotation)
glSurfaceView?.requestRender()
}
// Start camera when surface texture is available
LaunchedEffect(surfaceTexture) {
surfaceTexture?.let {
viewModel.cameraManager.startCamera(lifecycleOwner) { surfaceTexture }
}
}
// Pause/resume GLSurfaceView when entering/leaving gallery preview
LaunchedEffect(isGalleryPreview) {
if (isGalleryPreview) {
glSurfaceView?.onPause()
} else if (lifecycleOwner.lifecycle.currentState.isAtLeast(Lifecycle.State.RESUMED)) {
glSurfaceView?.onResume()
}
}
// Tie GLSurfaceView lifecycle to Activity lifecycle to prevent background rendering
val currentIsGalleryPreview by rememberUpdatedState(isGalleryPreview)
DisposableEffect(lifecycleOwner) {
val observer = LifecycleEventObserver { _, event ->
when (event) {
Lifecycle.Event.ON_RESUME -> {
if (!currentIsGalleryPreview) {
glSurfaceView?.onResume()
}
}
Lifecycle.Event.ON_PAUSE -> glSurfaceView?.onPause()
else -> {}
}
}
lifecycleOwner.lifecycle.addObserver(observer)
onDispose {
lifecycleOwner.lifecycle.removeObserver(observer)
glSurfaceView?.queueEvent { renderer?.release() }
}
}
Box(
modifier = modifier
.fillMaxSize()
.background(Color.Black)
) {
// Main view: gallery preview image or camera GL surface
if (isGalleryPreview) {
galleryPreviewBitmap?.let { bmp ->
Image(
bitmap = bmp.asImageBitmap(),
contentDescription = "Gallery image preview with tilt-shift effect. Adjust parameters then tap Apply.",
contentScale = ContentScale.Fit,
modifier = Modifier
.fillMaxSize()
.background(Color.Black)
)
}
} else {
// OpenGL Surface for camera preview with effect
AndroidView(
factory = { ctx ->
GLSurfaceView(ctx).apply {
setEGLContextClientVersion(2)
val view = this
val newRenderer = TiltShiftRenderer(
context = ctx,
onSurfaceTextureAvailable = { st -> surfaceTexture = st },
onFrameAvailable = { view.requestRender() }
)
renderer = newRenderer
setRenderer(newRenderer)
renderMode = GLSurfaceView.RENDERMODE_WHEN_DIRTY
glSurfaceView = this
}
},
modifier = Modifier.fillMaxSize()
)
}
// Tilt-shift overlay (gesture handling + visualization)
TiltShiftOverlay(
params = blurParams,
onParamsChange = { newParams ->
viewModel.updateBlurParams(newParams)
},
onZoomChange = { zoomDelta ->
if (!isGalleryPreview) {
val newZoom = (zoomRatio * zoomDelta).coerceIn(minZoom, maxZoom)
viewModel.cameraManager.setZoom(newZoom)
}
},
modifier = Modifier.fillMaxSize()
)
// Top bar with controls
Column(
modifier = Modifier
.fillMaxWidth()
.statusBarsPadding()
.padding(16.dp)
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
if (!isGalleryPreview) {
ZoomIndicator(currentZoom = zoomRatio)
} else {
Spacer(modifier = Modifier.width(1.dp))
}
Row(verticalAlignment = Alignment.CenterVertically) {
// GPS geotagging toggle
IconButton(
onClick = {
viewModel.toggleGeotag()
viewModel.haptics.tick()
}
) {
Icon(
imageVector = if (geotagEnabled) Icons.Default.LocationOn else Icons.Default.LocationOff,
contentDescription = if (geotagEnabled) "Disable GPS geotagging" else "Enable GPS geotagging",
tint = if (geotagEnabled) Color.White else Color.White.copy(alpha = 0.5f)
)
}
if (!isGalleryPreview) {
// Camera flip button
IconButton(
onClick = {
viewModel.cameraManager.switchCamera()
viewModel.haptics.click()
}
) {
Icon(
imageVector = Icons.Default.FlipCameraAndroid,
contentDescription = "Switch between front and back camera",
tint = Color.White
)
}
}
// Toggle controls button (tune icon instead of cryptic "Ctrl")
IconButton(
onClick = {
viewModel.toggleControls()
viewModel.haptics.tick()
},
modifier = Modifier.semantics {
stateDescription = if (showControls) "Controls visible" else "Controls hidden"
}
) {
Icon(
imageVector = if (showControls) Icons.Default.Close else Icons.Default.Tune,
contentDescription = if (showControls) "Hide blur controls" else "Show blur controls",
tint = Color.White
)
}
}
}
// Mode toggle
Row(
modifier = Modifier
.fillMaxWidth()
.padding(top = 8.dp),
horizontalArrangement = Arrangement.Center
) {
ModeToggle(
currentMode = blurParams.mode,
onModeChange = { mode ->
viewModel.updateBlurParams(blurParams.copy(mode = mode))
viewModel.haptics.click()
}
)
}
}
// Control panel (sliders)
AnimatedVisibility(
visible = showControls,
enter = fadeIn(),
exit = fadeOut(),
modifier = Modifier
.align(Alignment.CenterEnd)
.padding(end = 16.dp)
) {
ControlPanel(
params = blurParams,
onParamsChange = { newParams ->
viewModel.updateBlurParams(newParams)
},
onReset = { viewModel.resetBlurParams() }
)
}
// Bottom controls
Column(
modifier = Modifier
.align(Alignment.BottomCenter)
.navigationBarsPadding()
.padding(bottom = 48.dp)
.systemGestureExclusion(),
horizontalAlignment = Alignment.CenterHorizontally
) {
if (isGalleryPreview) {
// Gallery preview mode: Cancel | Apply (matched layout to camera mode)
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(24.dp)
) {
// Cancel button (same 52dp as gallery button for layout consistency)
IconButton(
onClick = { viewModel.cancelGalleryPreview() },
modifier = Modifier
.size(52.dp)
.clip(CircleShape)
.background(AppColors.OverlayDark)
.semantics { contentDescription = "Cancel gallery import" }
) {
Icon(
imageVector = Icons.Default.Close,
contentDescription = null,
tint = Color.White,
modifier = Modifier.size(28.dp)
)
}
// Apply button (same 72dp as capture button for layout consistency)
Box(
modifier = Modifier
.size(72.dp)
.clip(CircleShape)
.border(4.dp, Color.White, CircleShape)
.clickable(
enabled = !isCapturing,
role = Role.Button,
onClick = { viewModel.applyGalleryEffect() }
)
.semantics {
contentDescription = "Apply tilt-shift effect to gallery image"
if (isCapturing) stateDescription = "Processing"
},
contentAlignment = Alignment.Center
) {
Box(
modifier = Modifier
.size(if (isCapturing) 48.dp else 60.dp)
.clip(CircleShape)
.background(if (isCapturing) AppColors.Accent.copy(alpha = 0.5f) else AppColors.Accent),
contentAlignment = Alignment.Center
) {
if (isProcessing) {
CircularProgressIndicator(
modifier = Modifier.size(24.dp),
color = Color.Black,
strokeWidth = 3.dp
)
} else {
Icon(
imageVector = Icons.Default.Check,
contentDescription = null,
tint = Color.Black,
modifier = Modifier.size(28.dp)
)
}
}
}
// Spacer for visual symmetry
Spacer(modifier = Modifier.size(52.dp))
}
} else {
// Camera mode: Zoom presets + Gallery | Capture | Spacer
if (!isFrontCamera) {
ZoomControl(
currentZoom = zoomRatio,
minZoom = minZoom,
maxZoom = maxZoom,
onZoomSelected = { zoom ->
viewModel.cameraManager.setZoom(zoom)
viewModel.haptics.click()
}
)
Spacer(modifier = Modifier.height(24.dp))
}
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(24.dp)
) {
// Gallery picker button (with background for discoverability)
IconButton(
onClick = {
if (!isCapturing) {
galleryLauncher.launch(
PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageOnly)
)
}
},
enabled = !isCapturing,
modifier = Modifier
.size(52.dp)
.clip(CircleShape)
.background(AppColors.OverlayDark)
) {
Icon(
imageVector = Icons.Default.PhotoLibrary,
contentDescription = "Pick image from gallery",
tint = Color.White,
modifier = Modifier.size(28.dp)
)
}
// Capture button
CaptureButton(
isCapturing = isCapturing,
isProcessing = isProcessing,
onClick = { viewModel.capturePhoto() }
)
// Spacer for visual symmetry with gallery button
Spacer(modifier = Modifier.size(52.dp))
}
}
}
// Last captured photo thumbnail (hidden in gallery preview mode)
if (!isGalleryPreview) LastPhotoThumbnail(
thumbnail = lastThumbnailBitmap,
onTap = {
lastSavedUri?.let { uri ->
try {
val intent = Intent(Intent.ACTION_VIEW).apply {
setDataAndType(uri, "image/jpeg")
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
}
context.startActivity(intent)
} catch (e: android.content.ActivityNotFoundException) {
Log.w("CameraScreen", "No activity found to view image", e)
viewModel.showCameraError("No app available to view photos")
}
}
},
modifier = Modifier
.align(Alignment.BottomEnd)
.navigationBarsPadding()
.padding(bottom = 48.dp, end = 16.dp)
)
// Success indicator (announced to accessibility)
AnimatedVisibility(
visible = showSaveSuccess,
enter = fadeIn(),
exit = fadeOut(),
modifier = Modifier
.align(Alignment.Center)
.semantics { liveRegion = LiveRegionMode.Polite }
) {
Box(
modifier = Modifier
.size(80.dp)
.clip(CircleShape)
.background(AppColors.Success),
contentAlignment = Alignment.Center
) {
Icon(
imageVector = Icons.Default.Check,
contentDescription = "Photo saved successfully",
tint = Color.White,
modifier = Modifier.size(48.dp)
)
}
}
// Error indicator (announced to accessibility)
AnimatedVisibility(
visible = showSaveError != null,
enter = fadeIn(),
exit = fadeOut(),
modifier = Modifier
.align(Alignment.Center)
.padding(32.dp)
.semantics { liveRegion = LiveRegionMode.Assertive }
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier
.clip(RoundedCornerShape(16.dp))
.background(AppColors.Error)
.padding(24.dp)
) {
Icon(
imageVector = Icons.Default.Close,
contentDescription = null,
tint = Color.White,
modifier = Modifier.size(32.dp)
)
Spacer(modifier = Modifier.height(8.dp))
Text(
text = showSaveError ?: "Error",
color = Color.White,
fontSize = 14.sp,
maxLines = 3
)
}
}
// Processing overlay
AnimatedVisibility(
visible = isProcessing,
enter = fadeIn(),
exit = fadeOut(),
modifier = Modifier.align(Alignment.Center)
) {
if (!showSaveSuccess) {
Box(
modifier = Modifier
.size(80.dp)
.clip(CircleShape)
.background(AppColors.OverlayDark),
contentAlignment = Alignment.Center
) {
CircularProgressIndicator(
color = AppColors.Accent,
strokeWidth = 3.dp,
modifier = Modifier.size(36.dp)
)
}
}
}
}
}