Skip to content

Commit 863a206

Browse files
committed
✨ Add aspect ratio switching
1 parent 48641bf commit 863a206

File tree

4 files changed

+259
-28
lines changed

4 files changed

+259
-28
lines changed

app/src/main/java/co/stonephone/stonecamera/StoneCameraApp.kt

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,8 @@ fun StoneCameraApp(
7373
val showShutterFlash = stoneCameraViewModel.showShutterFlash
7474
val focusPoint = stoneCameraViewModel.focusPoint
7575
val flashMode = stoneCameraViewModel.flashMode
76+
val preview = stoneCameraViewModel.preview
77+
val aspectRatio = stoneCameraViewModel.selectedAspectRatio
7678
var visibleDimensions: List<Float>? by remember { mutableStateOf(null) }
7779

7880
// We can load cameras once (or whenever context changes) and pass them to the ViewModel
@@ -89,6 +91,8 @@ fun StoneCameraApp(
8991
lifecycleOwner = lifecycleOwner,
9092
imageCapture = imageCapture,
9193
videoCapture = videoCapture,
94+
preview = preview,
95+
stoneCameraViewModel = stoneCameraViewModel,
9296
onPreviewViewConnected = { pView, cam ->
9397
var isScaling = false
9498
previewView = pView
@@ -192,6 +196,22 @@ fun StoneCameraApp(
192196
tint = Color.White // Customize as needed
193197
)
194198
}
199+
200+
Text(
201+
text = aspectRatio,
202+
color = Color.White,
203+
style = MaterialTheme.typography.bodyLarge,
204+
fontWeight = FontWeight.Bold,
205+
modifier = Modifier.padding(8.dp)
206+
.clickable(onClick = {
207+
val nextAspectRatio = when (aspectRatio) {
208+
"16:9" -> "4:3"
209+
"4:3" -> "FULL"
210+
else -> "16:9"
211+
}
212+
stoneCameraViewModel.setAspectRatio(nextAspectRatio)
213+
})
214+
)
195215
}
196216

197217
// Show focus reticle if set

app/src/main/java/co/stonephone/stonecamera/StoneCameraViewModel.kt

Lines changed: 178 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,11 @@ import android.content.Context
66
import android.net.Uri
77
import android.provider.MediaStore
88
import android.util.Log
9+
import android.util.Size
910
import androidx.camera.core.*
11+
import androidx.camera.core.resolutionselector.AspectRatioStrategy
12+
import androidx.camera.core.resolutionselector.ResolutionSelector
13+
import androidx.camera.core.resolutionselector.ResolutionStrategy
1014
import androidx.camera.video.Quality
1115
import androidx.camera.video.QualitySelector
1216
import androidx.camera.video.Recorder
@@ -17,22 +21,21 @@ import androidx.lifecycle.ViewModel
1721
import androidx.lifecycle.ViewModelProvider
1822
import co.stonephone.stonecamera.utils.PrefStateDelegate
1923
import co.stonephone.stonecamera.utils.StoneCameraInfo
24+
import co.stonephone.stonecamera.utils.getLargestMatchingSize
25+
import co.stonephone.stonecamera.utils.getLargestSensorSize
2026
import co.stonephone.stonecamera.utils.selectCameraForStepZoomLevel
2127

22-
class StoneCameraViewModel(context: Context) : ViewModel() {
28+
class StoneCameraViewModel(private val context: Context) : ViewModel() {
2329

2430
private val ZOOM_CANCEL_THRESHOLD = 0.1f
2531

26-
//--------------------------------------------------------------------------------
27-
// Core CameraX use-cases (built once and shared)
28-
//--------------------------------------------------------------------------------
29-
val imageCapture: ImageCapture = ImageCapture.Builder().build()
32+
val supportedAspectRatios = listOf("4:3", "16:9", "FULL")
3033

31-
val recorder: Recorder = Recorder.Builder()
32-
.setQualitySelector(QualitySelector.from(Quality.HD))
33-
.build()
34+
var selectedAspectRatio by PrefStateDelegate(context, "aspect_ratio", "16:9")
35+
private set
3436

35-
val videoCapture: VideoCapture<Recorder> = VideoCapture.withOutput(recorder)
37+
var flashMode by PrefStateDelegate(context, "flash_mode", "OFF")
38+
private set
3639

3740
//--------------------------------------------------------------------------------
3841
// Mutable state that drives the UI
@@ -72,6 +75,30 @@ class StoneCameraViewModel(context: Context) : ViewModel() {
7275
var facingCameras by mutableStateOf(emptyList<StoneCameraInfo>())
7376
private set
7477

78+
//--------------------------------------------------------------------------------
79+
// Core CameraX use-cases (built once and shared)
80+
//--------------------------------------------------------------------------------
81+
var preview: Preview = createPreview(selectedAspectRatio)
82+
private set
83+
84+
var imageCapture: ImageCapture = createImageCapture(selectedAspectRatio)
85+
86+
val recorder: Recorder = Recorder.Builder()
87+
.setQualitySelector(QualitySelector.from(Quality.HD))
88+
.build()
89+
90+
val videoCapture: VideoCapture<Recorder> = VideoCapture.withOutput(recorder)
91+
92+
93+
//--------------------------------------------------------------------------------
94+
// Init
95+
//--------------------------------------------------------------------------------
96+
init {
97+
// Rebuild use-cases to match our persisted prefs
98+
recreateUseCases()
99+
}
100+
101+
75102
//--------------------------------------------------------------------------------
76103
// Public methods to manipulate the above states
77104
//--------------------------------------------------------------------------------
@@ -232,30 +259,159 @@ class StoneCameraViewModel(context: Context) : ViewModel() {
232259
return ((brightnessLevel + 1.0f) / 2.0f * (maxIndex - minIndex) + minIndex).toInt()
233260
}
234261

235-
//--------------------------------------------------------------------------------
236-
// Mutable state for Flash Mode
237-
//--------------------------------------------------------------------------------
238-
var flashMode by PrefStateDelegate(context, "flash_mode", "OFF")
239-
private set
240-
241262
/**
242-
* Set the flash mode for the camera.
243-
* Supported modes: ON, OFF, AUTO.
244-
* @param mode A string representing the flash mode ("ON", "OFF", "AUTO").
263+
* Build a ResolutionSelector that "prefers" [targetSize] if not null,
264+
* otherwise tries to fallback to an aspect ratio if [ratio] is non-null,
265+
* or does default if both are null.
245266
*/
267+
private fun buildResolutionSelector(
268+
targetSize: Size?, // e.g. 3000×3000 for 1:1
269+
ratio: Float? // e.g. 1.0f for 1:1, 1.333...f for 4:3, etc.
270+
): ResolutionSelector {
271+
val aspectRatioConst = when {
272+
ratio == null -> null // “FULL” or unknown
273+
kotlin.math.abs(ratio - (4f / 3f)) < 0.01f -> AspectRatio.RATIO_4_3
274+
kotlin.math.abs(ratio - (16f / 9f)) < 0.01f -> AspectRatio.RATIO_16_9
275+
else -> null // e.g. 1:1 or any custom ratio
276+
}
277+
278+
// If no targetSize is found, rely entirely on aspectRatioConst or a fallback
279+
if (targetSize == null) {
280+
// No recognized ratio => default to RATIO_4_3 fallback
281+
return if (aspectRatioConst == null) {
282+
ResolutionSelector.Builder()
283+
.setAspectRatioStrategy(
284+
AspectRatioStrategy(
285+
AspectRatio.RATIO_4_3,
286+
AspectRatioStrategy.FALLBACK_RULE_AUTO
287+
)
288+
)
289+
.build()
290+
} else {
291+
// Use the recognized built-in ratio
292+
ResolutionSelector.Builder()
293+
.setAspectRatioStrategy(
294+
AspectRatioStrategy(
295+
aspectRatioConst,
296+
AspectRatioStrategy.FALLBACK_RULE_AUTO
297+
)
298+
)
299+
.build()
300+
}
301+
}
302+
303+
// We do have a targetSize (the largest size matching the custom ratio, or “FULL” sensor size).
304+
val resolutionStrategy = ResolutionStrategy(
305+
targetSize,
306+
ResolutionStrategy.FALLBACK_RULE_CLOSEST_HIGHER_THEN_LOWER
307+
)
308+
309+
// If ratio maps to 4:3 or 16:9, we can also supply an AspectRatioStrategy fallback
310+
return if (aspectRatioConst != null) {
311+
ResolutionSelector.Builder()
312+
.setResolutionStrategy(resolutionStrategy)
313+
.setAspectRatioStrategy(
314+
AspectRatioStrategy(
315+
aspectRatioConst,
316+
AspectRatioStrategy.FALLBACK_RULE_AUTO
317+
)
318+
)
319+
.build()
320+
} else {
321+
// ratio is something else (e.g. 1:1), so no built-in aspect ratio
322+
// rely on the resolutionStrategy alone
323+
ResolutionSelector.Builder()
324+
.setResolutionStrategy(resolutionStrategy)
325+
.build()
326+
}
327+
}
328+
329+
330+
//--------------------------------------------------------------------------------
331+
// Flash
332+
//--------------------------------------------------------------------------------
246333
fun setFlash(mode: String) {
247-
val newFlashMode = when (mode.uppercase()) {
334+
val newFlashMode = flashModeStringToMode(mode)
335+
flashMode = mode.uppercase()
336+
imageCapture.flashMode = newFlashMode
337+
}
338+
339+
private fun flashModeStringToMode(modeStr: String): Int {
340+
return when (modeStr.uppercase()) {
248341
"ON" -> ImageCapture.FLASH_MODE_ON
249342
"OFF" -> ImageCapture.FLASH_MODE_OFF
250343
"AUTO" -> ImageCapture.FLASH_MODE_AUTO
251344
else -> {
252-
Log.e("StoneCameraViewModel", "Invalid flash mode: $mode. Defaulting to OFF.")
345+
Log.e("StoneCameraViewModel", "Invalid flash mode: $modeStr. Defaulting to OFF.")
253346
ImageCapture.FLASH_MODE_OFF
254347
}
255348
}
349+
}
256350

257-
imageCapture.flashMode = newFlashMode
258-
flashMode = mode.uppercase() // Update the state
351+
fun setAspectRatio(ratio: String) {
352+
if (ratio !in supportedAspectRatios) return
353+
selectedAspectRatio = ratio
354+
recreateUseCases()
355+
}
356+
357+
private fun recreateUseCases() {
358+
preview = createPreview(selectedAspectRatio)
359+
imageCapture = createImageCapture(selectedAspectRatio).also {
360+
it.flashMode = flashModeStringToMode(flashMode)
361+
}
362+
}
363+
364+
//--------------------------------------------------------------------------------
365+
// Building with ResolutionSelector
366+
//--------------------------------------------------------------------------------
367+
368+
private fun parseRatioOrNull(ratioStr: String): Float? {
369+
val nums = ratioStr.split(":").map { it.toFloatOrNull() }
370+
if(nums.any { it == null }) return null
371+
return nums[0]!! / nums[1]!!
372+
}
373+
374+
private fun createPreview(ratioStr: String): Preview {
375+
val ratioOrNull = parseRatioOrNull(ratioStr)
376+
val cameraId = selectedCameraId.ifEmpty { "0" }
377+
378+
// 1) Figure out the best "preferred size" for this ratio
379+
val targetSize = if (ratioOrNull == null) {
380+
getLargestSensorSize(cameraId, context)
381+
} else {
382+
getLargestMatchingSize(cameraId, context, ratioOrNull)
383+
}
384+
385+
// 2) Build a ResolutionSelector
386+
val resolutionSelector = buildResolutionSelector(targetSize, ratioOrNull)
387+
388+
// 3) Apply to the Preview builder
389+
return Preview.Builder()
390+
.setResolutionSelector(resolutionSelector)
391+
.build()
392+
.also {
393+
Log.d("StoneCameraViewModel", "Preview => ratio=$ratioStr, size=$targetSize")
394+
}
395+
}
396+
397+
private fun createImageCapture(ratioStr: String): ImageCapture {
398+
val ratioOrNull = parseRatioOrNull(ratioStr)
399+
val cameraId = selectedCameraId.ifEmpty { "0" }
400+
401+
val targetSize = if (ratioOrNull == null) {
402+
getLargestSensorSize(cameraId, context)
403+
} else {
404+
getLargestMatchingSize(cameraId, context, ratioOrNull)
405+
}
406+
407+
val resolutionSelector = buildResolutionSelector(targetSize, ratioOrNull)
408+
409+
return ImageCapture.Builder()
410+
.setResolutionSelector(resolutionSelector)
411+
.build()
412+
.also {
413+
Log.d("StoneCameraViewModel", "ImageCapture => ratio=$ratioStr, size=$targetSize")
414+
}
259415
}
260416
}
261417

app/src/main/java/co/stonephone/stonecamera/ui/StoneCameraPreview.kt

Lines changed: 11 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import androidx.compose.ui.Modifier
2222
import androidx.compose.ui.platform.LocalContext
2323
import androidx.compose.ui.viewinterop.AndroidView
2424
import androidx.lifecycle.LifecycleOwner
25+
import co.stonephone.stonecamera.StoneCameraViewModel
2526
import kotlinx.coroutines.Dispatchers
2627
import kotlinx.coroutines.Job
2728
import kotlinx.coroutines.launch
@@ -35,23 +36,23 @@ fun StoneCameraPreview(
3536
videoCapture: VideoCapture<Recorder>,
3637
lifecycleOwner: LifecycleOwner,
3738
modifier: Modifier = Modifier,
38-
onPreviewViewConnected: (PreviewView, Camera) -> Unit
39+
onPreviewViewConnected: (PreviewView, Camera) -> Unit,
40+
preview: Preview,
41+
stoneCameraViewModel: StoneCameraViewModel
3942
) {
4043
val context = LocalContext.current
4144
var previewView by remember { mutableStateOf(PreviewView(context)) }
4245

4346
val bindingJob = remember { mutableStateOf<Job?>(null) }
4447

45-
LaunchedEffect(cameraProvider, selectedCameraId, imageCapture, videoCapture) {
48+
LaunchedEffect(cameraProvider, selectedCameraId, imageCapture, videoCapture, preview) {
4649
// Cancel the ongoing binding job, if any
4750
bindingJob.value?.cancel()
4851

4952
// Launch a coroutine to bind the camera
5053
bindingJob.value = launch(Dispatchers.Main) {
5154
try {
52-
val preview = Preview.Builder().build().also {
53-
it.setSurfaceProvider(previewView.surfaceProvider)
54-
}
55+
preview.setSurfaceProvider(previewView.surfaceProvider)
5556

5657
val cameraSelector = CameraSelector.Builder()
5758
.addCameraFilter { cameraInfos ->
@@ -75,7 +76,11 @@ fun StoneCameraPreview(
7576
videoCapture
7677
)
7778

78-
previewView.scaleType = PreviewView.ScaleType.FIT_CENTER
79+
if (stoneCameraViewModel.selectedAspectRatio === "FULL") {
80+
previewView.scaleType = PreviewView.ScaleType.FILL_CENTER
81+
} else {
82+
previewView.scaleType = PreviewView.ScaleType.FIT_CENTER
83+
}
7984
// Notify the caller that the new PreviewView is connected
8085
onPreviewViewConnected(previewView, camera)
8186

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
package co.stonephone.stonecamera.utils
2+
3+
import android.content.Context
4+
import android.graphics.ImageFormat
5+
import android.hardware.camera2.CameraCharacteristics
6+
import android.hardware.camera2.CameraManager
7+
import android.util.Log
8+
import android.util.Size
9+
import kotlin.math.abs
10+
11+
fun getLargestSensorSize(cameraId: String, context: Context): Size? {
12+
val manager = context.getSystemService(Context.CAMERA_SERVICE) as CameraManager
13+
val chars = manager.getCameraCharacteristics(cameraId)
14+
val config = chars.get(CameraCharacteristics.SCALER_STREAM_CONFIGURATION_MAP) ?: return null
15+
val sizes = config.getOutputSizes(ImageFormat.JPEG) ?: return null
16+
return sizes.maxByOrNull { it.width.toLong() * it.height.toLong() }
17+
}
18+
19+
fun getLargestMatchingSize(
20+
cameraId: String,
21+
context: Context,
22+
requestedRatio: Float
23+
): Size? {
24+
val manager = context.getSystemService(Context.CAMERA_SERVICE) as CameraManager
25+
val chars = manager.getCameraCharacteristics(cameraId)
26+
val config = chars.get(CameraCharacteristics.SCALER_STREAM_CONFIGURATION_MAP) ?: return null
27+
val sizes = config.getOutputSizes(ImageFormat.JPEG) ?: return null
28+
29+
var bestSize: Size? = null
30+
var bestRatioDiff = Float.MAX_VALUE
31+
var bestArea = 0L
32+
33+
for (size in sizes) {
34+
val ratio = size.width.toFloat() / size.height.toFloat()
35+
val diff = abs(ratio - requestedRatio)
36+
if (diff < bestRatioDiff) {
37+
bestRatioDiff = diff
38+
bestSize = size
39+
bestArea = size.width.toLong() * size.height.toLong()
40+
} else if (abs(diff - bestRatioDiff) < 1e-3) {
41+
// Ratios are equally close, pick the larger area
42+
val area = size.width.toLong() * size.height.toLong()
43+
if (area > bestArea) {
44+
bestSize = size
45+
bestArea = area
46+
}
47+
}
48+
}
49+
return bestSize
50+
}

0 commit comments

Comments
 (0)