diff --git a/.gitignore b/.gitignore index 40d4baf..4092273 100644 --- a/.gitignore +++ b/.gitignore @@ -2,4 +2,5 @@ build/ .gradle UserInterfaceState.xcuserstate -.kotlin \ No newline at end of file +.kotlin +local.properties diff --git a/README.md b/README.md index e1b5f0c..dc7498b 100644 --- a/README.md +++ b/README.md @@ -60,6 +60,26 @@ ScannerWithPermissions( Scanner(onScanned = { println(it); true }, types = listOf(CodeType.QR)) ``` +## Camera zoom (Android / iOS) + +Use state-based zoom with [rememberCameraZoomState](scanner/src/commonMain/kotlin/CameraZoomState.kt) and pass it to [Scanner](scanner/src/commonMain/kotlin/Scanner.kt) or [ScannerWithPermissions](scanner/src/commonMain/kotlin/Scanner.kt): + +```kotlin +val cameraZoomState = rememberCameraZoomState() +ScannerWithPermissions( + onScanned = { println(it); false }, + types = listOf(CodeType.QR), + cameraPosition = CameraPosition.BACK, + enableTorch = false, + cameraZoomState = cameraZoomState, +) +val range = cameraZoomState.zoomRange.value // null until the camera is ready +// e.g. Slider when range != null: valueRange = range.minZoomRatio..range.maxZoomRatio +cameraZoomState.setZoomRatio(2f) +``` + +`zoomRatio` follows the platform camera stack (CameraX zoom ratio on Android; `videoZoomFactor` on iOS). `zoomRange` is null until the session is bound; `setZoomRatio` clamps to the last known range (or only enforces a small positive minimum before the range exists). + Check out the [sample app](./sample-app) included in the repository. # Code Types diff --git a/sample-app/shared/src/commonMain/kotlin/MainView.kt b/sample-app/shared/src/commonMain/kotlin/MainView.kt index 0c02f2b..311c64c 100644 --- a/sample-app/shared/src/commonMain/kotlin/MainView.kt +++ b/sample-app/shared/src/commonMain/kotlin/MainView.kt @@ -1,11 +1,14 @@ package org.publicvalue.multiplatform.qrcode.sample 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.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.material3.Scaffold +import androidx.compose.material3.Slider import androidx.compose.material3.SnackbarDuration import androidx.compose.material3.SnackbarHost import androidx.compose.material3.SnackbarHostState @@ -25,11 +28,11 @@ import kotlinx.coroutines.launch import org.publicvalue.multiplatform.qrcode.CameraPosition import org.publicvalue.multiplatform.qrcode.CodeType import org.publicvalue.multiplatform.qrcode.ScannerWithPermissions +import org.publicvalue.multiplatform.qrcode.rememberCameraUtils +import org.publicvalue.multiplatform.qrcode.rememberCameraZoomState import kotlin.time.Duration.Companion.minutes import kotlin.time.Duration.Companion.seconds -import kotlin.time.TimeMark import kotlin.time.TimeSource -import org.publicvalue.multiplatform.qrcode.rememberCameraUtils @Composable fun MainView() { @@ -40,8 +43,12 @@ fun MainView() { hostState = snackbarHostState ) } - ) { - Column(modifier = Modifier.padding(it)) { + ) { paddingValues -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(paddingValues) + ) { Text("Scan QR-Code below") var scannerVisible by remember { mutableStateOf(false) } var enableTorch by remember { mutableStateOf(false) } @@ -67,41 +74,73 @@ fun MainView() { checked = enableTorch, onCheckedChange = { enableTorch = it - if (!scannerVisible) { // android needs separate handling when capturing video + if (!scannerVisible) { cameraUtils.setTorchMode(CameraPosition.BACK, it) } } ) } } - var lastCode by remember { mutableStateOf(null)} - var snackbarJob by remember { mutableStateOf(null)} - var lastSnackbar by remember { mutableStateOf(TimeSource.Monotonic.markNow().minus(1.minutes))} + + var lastCode by remember { mutableStateOf(null) } + var snackbarJob by remember { mutableStateOf(null) } + var lastSnackbar by remember { mutableStateOf(TimeSource.Monotonic.markNow().minus(1.minutes)) } + if (scannerVisible) { val scope = rememberCoroutineScope() - ScannerWithPermissions( - modifier = Modifier.padding(16.dp), - onScanned = { - if (lastCode == it) { return@ScannerWithPermissions false } - if (TimeSource.Monotonic.markNow().minus(lastSnackbar) < 1.seconds) { - return@ScannerWithPermissions false - } - snackbarJob?.cancel() - lastSnackbar = TimeSource.Monotonic.markNow() - snackbarJob = scope.launch { - snackbarHostState.showSnackbar(it, duration = SnackbarDuration.Short) + val cameraZoomState = rememberCameraZoomState() + val zoomRange by cameraZoomState.zoomRange + + zoomRange?.let { range -> + Column( + modifier = Modifier + .fillMaxWidth() + .padding(start = 16.dp, end = 16.dp, top = 8.dp, bottom = 4.dp) + ) { + Text("Zoom") + Slider( + modifier = Modifier.fillMaxWidth(), + value = cameraZoomState.zoomRatio, + onValueChange = { cameraZoomState.setZoomRatio(it) }, + valueRange = range.minZoomRatio..range.maxZoomRatio, + ) + } + } + + Box( + modifier = Modifier + .weight(1f) + .fillMaxWidth() + ) { + ScannerWithPermissions( + modifier = Modifier + .fillMaxSize() + .padding(16.dp), + onScanned = { if (lastCode == it) { - lastCode = null + return@ScannerWithPermissions false } - } - lastCode = it - false // continue scanning - }, - types = listOf(CodeType.QR), - cameraPosition = CameraPosition.BACK, - enableTorch = enableTorch, - ) + if (TimeSource.Monotonic.markNow().minus(lastSnackbar) < 1.seconds) { + return@ScannerWithPermissions false + } + snackbarJob?.cancel() + lastSnackbar = TimeSource.Monotonic.markNow() + snackbarJob = scope.launch { + snackbarHostState.showSnackbar(it, duration = SnackbarDuration.Short) + if (lastCode == it) { + lastCode = null + } + } + lastCode = it + false + }, + types = listOf(CodeType.QR), + cameraPosition = CameraPosition.BACK, + enableTorch = enableTorch, + cameraZoomState = cameraZoomState, + ) + } } } } -} \ No newline at end of file +} diff --git a/scanner/src/androidMain/kotlin/CameraView.kt b/scanner/src/androidMain/kotlin/CameraView.kt index c1c4bee..643fcae 100644 --- a/scanner/src/androidMain/kotlin/CameraView.kt +++ b/scanner/src/androidMain/kotlin/CameraView.kt @@ -1,9 +1,11 @@ package org.publicvalue.multiplatform.qrcode import android.util.Log +import androidx.camera.core.Camera import androidx.camera.core.CameraSelector import androidx.camera.core.ImageAnalysis import androidx.camera.core.Preview +import androidx.camera.core.ZoomState import androidx.camera.lifecycle.ProcessCameraProvider import androidx.camera.view.PreviewView import androidx.compose.foundation.layout.fillMaxSize @@ -11,11 +13,17 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.remember +import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.viewinterop.AndroidView import androidx.core.content.ContextCompat +import androidx.lifecycle.LifecycleOwner +import androidx.lifecycle.Observer import androidx.lifecycle.compose.LocalLifecycleOwner +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.withContext @Composable fun CameraView( @@ -23,6 +31,7 @@ fun CameraView( analyzer: BarcodeAnalyzer, cameraPosition: CameraPosition, enableTorch: Boolean, + cameraZoomState: CameraZoomState?, ) { val context = LocalContext.current val lifecycleOwner = LocalLifecycleOwner.current @@ -40,19 +49,18 @@ fun CameraView( val imageAnalysis = remember(context, analyzer) { ImageAnalysis.Builder() .build() - .also { it.setAnalyzer( ContextCompat.getMainExecutor(context), analyzer ) } + .also { it.setAnalyzer(ContextCompat.getMainExecutor(context), analyzer) } } - val camera = remember(cameraProviderFuture, imageAnalysis) { + val camera = remember(cameraProviderFuture, imageAnalysis, selector) { runCatching { cameraProviderFuture.get().unbindAll() - val camera1 = cameraProviderFuture.get().bindToLifecycle( + cameraProviderFuture.get().bindToLifecycle( lifecycleOwner, selector, preview, imageAnalysis ) - camera1 }.onFailure { Log.e("CAMERA", "Camera bind error ${it.localizedMessage}", it) } @@ -62,6 +70,18 @@ fun CameraView( camera.getOrNull()?.cameraControl?.enableTorch(enableTorch) } + ObserveCameraZoom(camera, lifecycleOwner, cameraZoomState) + + LaunchedEffect(camera.getOrNull(), cameraZoomState) { + val cam = camera.getOrNull() ?: return@LaunchedEffect + val zoomState = cameraZoomState ?: return@LaunchedEffect + snapshotFlow { zoomState.zoomRatio }.collectLatest { ratio -> + withContext(Dispatchers.Main.immediate) { + cam.cameraControl.setZoomRatio(ratio) + } + } + } + DisposableEffect(cameraProviderFuture) { onDispose { cameraProviderFuture.get().unbindAll() @@ -72,13 +92,44 @@ fun CameraView( modifier = modifier.fillMaxSize(), factory = { context -> val previewView = PreviewView(context) - preview.setSurfaceProvider(previewView.surfaceProvider) + preview.surfaceProvider = previewView.surfaceProvider previewView }, ) } -fun CameraPosition.toSelector() = when(this) { +@Composable +private fun ObserveCameraZoom( + camera: Result, + lifecycleOwner: LifecycleOwner, + cameraZoomState: CameraZoomState?, +) { + DisposableEffect(camera.getOrNull(), lifecycleOwner, cameraZoomState) { + val cam = camera.getOrNull() + if (cam != null && cameraZoomState != null) { + val observer = Observer { state -> + cameraZoomState.updateZoomRangeFromPlatform( + CameraZoomRange( + minZoomRatio = state.minZoomRatio, + maxZoomRatio = state.maxZoomRatio, + ) + ) + } + cam.cameraInfo.zoomState.observe(lifecycleOwner, observer) + onDispose { + cam.cameraInfo.zoomState.removeObserver(observer) + cameraZoomState.updateZoomRangeFromPlatform(null) + } + } else { + if (cam == null && cameraZoomState != null) { + cameraZoomState.updateZoomRangeFromPlatform(null) + } + onDispose { } + } + } +} + +fun CameraPosition.toSelector() = when (this) { CameraPosition.FRONT -> CameraSelector.LENS_FACING_FRONT CameraPosition.BACK -> CameraSelector.LENS_FACING_BACK -} \ No newline at end of file +} diff --git a/scanner/src/androidMain/kotlin/Scanner.android.kt b/scanner/src/androidMain/kotlin/Scanner.android.kt index a0d3393..928eba4 100644 --- a/scanner/src/androidMain/kotlin/Scanner.android.kt +++ b/scanner/src/androidMain/kotlin/Scanner.android.kt @@ -21,11 +21,12 @@ actual fun Scanner( types: List, cameraPosition: CameraPosition, enableTorch: Boolean, + cameraZoomState: CameraZoomState?, ) { val analyzer = remember() { BarcodeAnalyzer(types.toFormat(), onScanned) } - CameraView(modifier, analyzer, cameraPosition, enableTorch) + CameraView(modifier, analyzer, cameraPosition, enableTorch, cameraZoomState) } @OptIn(ExperimentalPermissionsApi::class) diff --git a/scanner/src/commonMain/kotlin/CameraZoomState.kt b/scanner/src/commonMain/kotlin/CameraZoomState.kt new file mode 100644 index 0000000..06ece39 --- /dev/null +++ b/scanner/src/commonMain/kotlin/CameraZoomState.kt @@ -0,0 +1,68 @@ +package org.publicvalue.multiplatform.qrcode + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Stable +import androidx.compose.runtime.State +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember + +/** + * Device-reported zoom bounds once the camera is bound. Ratios are defined by the + * underlying platform (CameraX on Android, AVFoundation on iOS); see README for details. + */ +data class CameraZoomRange( + val minZoomRatio: Float, + val maxZoomRatio: Float, +) + +/** + * Snapshot-backed zoom state for [Scanner], similar in usage to state-based Compose APIs + * (hold the instance, pass it into [Scanner], read [zoomRange] / [zoomRatio], call [setZoomRatio] from UI). + * + * [zoomRange] is null until the camera session is ready. Until then, [setZoomRatio] accepts any positive + * ratio and applies clamping once the range is known. + */ +@Stable +class CameraZoomState(initialZoomRatio: Float = 1f) { + + private val _zoomRatio = mutableFloatStateOf(initialZoomRatio.coerceAtLeast(MIN_ZOOM_BEFORE_RANGE)) + private val _zoomRange = mutableStateOf(null) + + /** Current zoom ratio; always within [zoomRange] when the range is non-null. */ + val zoomRatio: Float get() = _zoomRatio.floatValue + + /** Null until the camera reports capabilities. */ + val zoomRange: State get() = _zoomRange + + fun setZoomRatio(ratio: Float) { + val clamped = clampRatio(ratio) + _zoomRatio.floatValue = clamped + } + + internal fun updateZoomRangeFromPlatform(range: CameraZoomRange?) { + _zoomRange.value = range + if (range != null) { + _zoomRatio.floatValue = clampRatio(_zoomRatio.floatValue) + } + } + + private fun clampRatio(ratio: Float): Float { + val range = _zoomRange.value + val safe = ratio.coerceAtLeast(MIN_ZOOM_BEFORE_RANGE) + return if (range != null) { + safe.coerceIn(range.minZoomRatio, range.maxZoomRatio) + } else { + safe + } + } + + private companion object { + const val MIN_ZOOM_BEFORE_RANGE = 0.01f + } +} + +@Composable +fun rememberCameraZoomState(initialZoomRatio: Float = 1f): CameraZoomState { + return remember { CameraZoomState(initialZoomRatio) } +} diff --git a/scanner/src/commonMain/kotlin/Scanner.kt b/scanner/src/commonMain/kotlin/Scanner.kt index 69d4268..b3b7df1 100644 --- a/scanner/src/commonMain/kotlin/Scanner.kt +++ b/scanner/src/commonMain/kotlin/Scanner.kt @@ -18,6 +18,7 @@ import androidx.compose.ui.unit.dp * @param onScanned Called when a code was scanned. The given lambda should return true * if scanning was successful and scanning should be aborted. * Return false if scanning should continue. + * @param cameraZoomState Optional optical zoom state; use [rememberCameraZoomState]. */ @Composable expect fun Scanner( @@ -26,6 +27,7 @@ expect fun Scanner( types: List, cameraPosition: CameraPosition = CameraPosition.BACK, enableTorch: Boolean, + cameraZoomState: CameraZoomState? = null, ) /** @@ -37,6 +39,7 @@ expect fun Scanner( * Return false if scanning should continue. * @param permissionText Text to show if permission was denied. * @param openSettingsLabel Label to show on the "Go to settings" Button + * @param cameraZoomState Optional optical zoom state; use [rememberCameraZoomState]. */ @Composable fun ScannerWithPermissions( @@ -45,6 +48,7 @@ fun ScannerWithPermissions( types: List, cameraPosition: CameraPosition = CameraPosition.BACK, enableTorch: Boolean, + cameraZoomState: CameraZoomState? = null, permissionText: String = "Camera is required for QR Code scanning", openSettingsLabel: String = "Open Settings", ) { @@ -54,6 +58,7 @@ fun ScannerWithPermissions( types = types, cameraPosition = cameraPosition, enableTorch = enableTorch, + cameraZoomState = cameraZoomState, permissionDeniedContent = { permissionState -> Column(modifier, horizontalAlignment = Alignment.CenterHorizontally) { Text( @@ -76,6 +81,7 @@ fun ScannerWithPermissions( * if scanning was successful and scanning should be aborted. * Return false if scanning should continue. * @param permissionDeniedContent Content to show if permission was denied. + * @param cameraZoomState Optional optical zoom state; use [rememberCameraZoomState]. */ @Composable fun ScannerWithPermissions( @@ -84,6 +90,7 @@ fun ScannerWithPermissions( types: List, cameraPosition: CameraPosition, enableTorch: Boolean, + cameraZoomState: CameraZoomState? = null, permissionDeniedContent: @Composable (CameraPermissionState) -> Unit, ) { val permissionState = rememberCameraPermissionState() @@ -95,7 +102,14 @@ fun ScannerWithPermissions( } if (permissionState.status == CameraPermissionStatus.Granted) { - Scanner(modifier, types = types, onScanned = onScanned, cameraPosition = cameraPosition, enableTorch = enableTorch) + Scanner( + modifier, + types = types, + onScanned = onScanned, + cameraPosition = cameraPosition, + enableTorch = enableTorch, + cameraZoomState = cameraZoomState, + ) } else { permissionDeniedContent(permissionState) } diff --git a/scanner/src/iosMain/kotlin/Scanner.ios.kt b/scanner/src/iosMain/kotlin/Scanner.ios.kt index 46ae491..dfd2954 100644 --- a/scanner/src/iosMain/kotlin/Scanner.ios.kt +++ b/scanner/src/iosMain/kotlin/Scanner.ios.kt @@ -7,6 +7,7 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue +import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.Modifier import platform.AVFoundation.AVAuthorizationStatusAuthorized import platform.AVFoundation.AVCaptureDevice @@ -24,6 +25,7 @@ actual fun Scanner( types: List, cameraPosition: CameraPosition, enableTorch: Boolean, + cameraZoomState: CameraZoomState?, ) { var started by remember { mutableStateOf(false) } val cameraUtils = rememberCameraUtils() @@ -44,6 +46,7 @@ actual fun Scanner( }, allowedMetadataTypes = types.toFormat(), cameraPosition = cameraPosition, + cameraZoomState = cameraZoomState, onStarted = { cameraUtils.setTorchMode(cameraPosition, enableTorch) started = true diff --git a/scanner/src/iosMain/kotlin/ScannerView.kt b/scanner/src/iosMain/kotlin/ScannerView.kt index 02999eb..ba9d495 100644 --- a/scanner/src/iosMain/kotlin/ScannerView.kt +++ b/scanner/src/iosMain/kotlin/ScannerView.kt @@ -3,7 +3,9 @@ package org.publicvalue.multiplatform.qrcode import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.remember +import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.Modifier import androidx.compose.ui.viewinterop.UIKitInteropProperties import androidx.compose.ui.viewinterop.UIKitView @@ -18,7 +20,9 @@ import kotlinx.cinterop.ptr import kotlinx.cinterop.value import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.GlobalScope +import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext import platform.AVFoundation.AVCaptureDevice import platform.AVFoundation.AVCaptureDeviceInput import platform.AVFoundation.AVCaptureMetadataOutput @@ -32,6 +36,9 @@ import platform.AVFoundation.AVCaptureVideoPreviewLayer import platform.AVFoundation.AVLayerVideoGravityResizeAspectFill import platform.AVFoundation.AVMetadataMachineReadableCodeObject import platform.AVFoundation.AVMetadataObjectType +import platform.AVFoundation.maxAvailableVideoZoomFactor +import platform.AVFoundation.minAvailableVideoZoomFactor +import platform.AVFoundation.setVideoZoomFactor import platform.CoreGraphics.CGRect import platform.CoreGraphics.CGRectZero import platform.Foundation.NSError @@ -51,6 +58,7 @@ fun UiScannerView( // https://developer.apple.com/documentation/avfoundation/avmetadataobjecttype?language=objc allowedMetadataTypes: List, cameraPosition: CameraPosition, + cameraZoomState: CameraZoomState?, onScanned: (String) -> Boolean, onStarted: () -> Unit, ) { @@ -62,6 +70,22 @@ fun UiScannerView( ) } + DisposableEffect(coordinator, cameraZoomState) { + coordinator.bindZoomState(cameraZoomState) + onDispose { + coordinator.bindZoomState(null) + } + } + + LaunchedEffect(coordinator, cameraZoomState) { + val state = cameraZoomState ?: return@LaunchedEffect + snapshotFlow { state.zoomRatio }.collectLatest { ratio -> + withContext(Dispatchers.Main) { + coordinator.applyZoomRatio(ratio) + } + } + } + DisposableEffect(Unit) { val listener = OrientationListener { orientation -> coordinator.setCurrentOrientation(orientation) @@ -71,7 +95,7 @@ fun UiScannerView( onDispose { listener.unregister() - // stop capture + coordinator.bindZoomState(null) coordinator.captureSession.stopRunning() } } @@ -91,7 +115,7 @@ fun UiScannerView( } @OptIn(ExperimentalForeignApi::class) -class ScannerPreviewView(private val coordinator: ScannerCameraCoordinator): UIView(frame = cValue { CGRectZero }) { +class ScannerPreviewView(private val coordinator: ScannerCameraCoordinator) : UIView(frame = cValue { CGRectZero }) { @OptIn(ExperimentalForeignApi::class) override fun layoutSubviews() { super.layoutSubviews() @@ -108,10 +132,50 @@ class ScannerCameraCoordinator( val onScanned: (String) -> Boolean, val onStarted: () -> Unit, val cameraPosition: CameraPosition -): AVCaptureMetadataOutputObjectsDelegateProtocol, NSObject() { +) : AVCaptureMetadataOutputObjectsDelegateProtocol, NSObject() { private var previewLayer: AVCaptureVideoPreviewLayer? = null lateinit var captureSession: AVCaptureSession + private var captureDevice: AVCaptureDevice? = null + private var boundZoomState: CameraZoomState? = null + + fun bindZoomState(state: CameraZoomState?) { + val previous = boundZoomState + if (previous != state) { + previous?.updateZoomRangeFromPlatform(null) + } + boundZoomState = state + captureDevice?.let { publishDeviceZoomRange(it) } + } + + private fun publishDeviceZoomRange(device: AVCaptureDevice) { + val holder = boundZoomState ?: return + val minF = device.minAvailableVideoZoomFactor.toFloat() + val maxF = device.maxAvailableVideoZoomFactor.toFloat() + if (maxF >= minF && minF > 0f && maxF.isFinite()) { + holder.updateZoomRangeFromPlatform(CameraZoomRange(minZoomRatio = minF, maxZoomRatio = maxF)) + } else { + val maxFmt = device.activeFormat.videoMaxZoomFactor.toFloat().coerceAtLeast(1f) + holder.updateZoomRangeFromPlatform(CameraZoomRange(minZoomRatio = 1f, maxZoomRatio = maxFmt)) + } + } + + @OptIn(ExperimentalForeignApi::class, BetaInteropApi::class) + fun applyZoomRatio(ratio: Float) { + val device = captureDevice ?: return + val minZ = device.minAvailableVideoZoomFactor + val maxZ = device.maxAvailableVideoZoomFactor + val clamped = ratio.toDouble().coerceIn(minZ, maxZ) + memScoped { + val error: ObjCObjectVar = alloc>() + device.lockForConfiguration(error.ptr) + try { + device.setVideoZoomFactor(clamped) + } finally { + device.unlockForConfiguration() + } + } + } @OptIn(ExperimentalForeignApi::class, BetaInteropApi::class) fun prepare(layer: CALayer, allowedMetadataTypes: List) { @@ -152,12 +216,16 @@ class ScannerCameraCoordinator( println("Could not add output") return } + captureDevice = device + previewLayer = AVCaptureVideoPreviewLayer(session = captureSession).also { it.frame = layer.bounds it.videoGravity = AVLayerVideoGravityResizeAspectFill layer.addSublayer(it) } + publishDeviceZoomRange(device) + GlobalScope.launch(Dispatchers.Default) { captureSession.startRunning() onStarted() @@ -165,7 +233,7 @@ class ScannerCameraCoordinator( } fun setCurrentOrientation(newOrientation: UIDeviceOrientation) { - when(newOrientation) { + when (newOrientation) { UIDeviceOrientation.UIDeviceOrientationLandscapeLeft -> previewLayer?.connection?.videoOrientation = AVCaptureVideoOrientationLandscapeRight UIDeviceOrientation.UIDeviceOrientationLandscapeRight -> @@ -179,7 +247,11 @@ class ScannerCameraCoordinator( } } - override fun captureOutput(output: platform.AVFoundation.AVCaptureOutput, didOutputMetadataObjects: List<*>, fromConnection: platform.AVFoundation.AVCaptureConnection) { + override fun captureOutput( + output: platform.AVFoundation.AVCaptureOutput, + didOutputMetadataObjects: List<*>, + fromConnection: platform.AVFoundation.AVCaptureConnection + ) { val metadataObject = didOutputMetadataObjects.firstOrNull() as? AVMetadataMachineReadableCodeObject metadataObject?.stringValue?.let { onFound(it) } } diff --git a/scanner/src/jvmMain/kotlin/Scanner.jvm.kt b/scanner/src/jvmMain/kotlin/Scanner.jvm.kt index 04d9d42..9fd5374 100644 --- a/scanner/src/jvmMain/kotlin/Scanner.jvm.kt +++ b/scanner/src/jvmMain/kotlin/Scanner.jvm.kt @@ -11,6 +11,7 @@ actual fun Scanner( types: List, cameraPosition: CameraPosition, enableTorch: Boolean, + cameraZoomState: CameraZoomState?, ) { Text("Scanner not implemented for JVM") } \ No newline at end of file