diff --git a/ImageSaverPlugin/build.gradle.kts b/ImageSaverPlugin/build.gradle.kts index 2dcb081..3e35dd4 100644 --- a/ImageSaverPlugin/build.gradle.kts +++ b/ImageSaverPlugin/build.gradle.kts @@ -1,5 +1,4 @@ import com.vanniktech.maven.publish.SonatypeHost -import org.jetbrains.compose.compose plugins { alias(libs.plugins.multiplatform) @@ -17,7 +16,7 @@ kotlin { androidTarget { publishLibraryVariants("release", "debug") } - + jvm("desktop") listOf( iosX64(), @@ -31,6 +30,7 @@ kotlin { } sourceSets { + val desktopMain by getting commonMain.dependencies { api(projects.cameraK) } diff --git a/ImageSaverPlugin/src/desktopMain/kotlin/com/kashif/imagesaverplugin/ImageSaverPlugin.desktop.kt b/ImageSaverPlugin/src/desktopMain/kotlin/com/kashif/imagesaverplugin/ImageSaverPlugin.desktop.kt new file mode 100644 index 0000000..3bc11f3 --- /dev/null +++ b/ImageSaverPlugin/src/desktopMain/kotlin/com/kashif/imagesaverplugin/ImageSaverPlugin.desktop.kt @@ -0,0 +1,67 @@ +package com.kashif.imagesaverplugin + +import coil3.PlatformContext +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import java.io.ByteArrayInputStream +import java.io.File +import java.io.FileOutputStream +import java.io.IOException +import javax.imageio.ImageIO + +/** + * jvm-specific implementation of [ImageSaverPlugin]. + * + * @param config The configuration settings for the plugin. + * @param onImageSaved Callback invoked when the image is successfully saved. + * @param onImageSavedFailed Callback invoked when the image saving fails. + */ +class JVMImageSaverPlugin( + config: ImageSaverConfig, + private val onImageSaved: () -> Unit, + private val onImageSavedFailed: (String) -> Unit +) : ImageSaverPlugin(config) { + + override suspend fun saveImage(byteArray: ByteArray, imageName: String?): String? { + return withContext(Dispatchers.IO) { + try { + val image = ImageIO.read(ByteArrayInputStream(byteArray)) + val fileName = "${imageName ?: "image_${System.currentTimeMillis()}"}.jpg" + val outputDirectory = File(config.directory.name) + val outputFile = File(outputDirectory, fileName) + val outputStream = FileOutputStream(outputFile) + ImageIO.write(image, "jpg", outputStream) + outputStream.close() + outputFile.absolutePath + } catch (e: Exception) { + null + } + } + } + + override fun getByteArrayFrom(path: String): ByteArray { + return try { + File(path).readBytes() + } catch (e: Exception) { + throw IOException("Failed to read image from path: $path", e) + } + } +} + +/** + * Factory function to create an jvm-specific [ImageSaverPlugin]. + * + * @param config Configuration settings for the plugin. + * @return An instance of [JVMImageSaverPlugin]. + */ + +actual fun createPlatformImageSaverPlugin( + context: PlatformContext, + config: ImageSaverConfig +): ImageSaverPlugin { + return JVMImageSaverPlugin( + config = config, + onImageSaved = { println("Image saved successfully!") }, + onImageSavedFailed = { errorMessage -> println("Failed to save image: $errorMessage") } + ) +} \ No newline at end of file diff --git a/Sample/build.gradle.kts b/Sample/build.gradle.kts index aea8130..35398d3 100644 --- a/Sample/build.gradle.kts +++ b/Sample/build.gradle.kts @@ -1,4 +1,5 @@ import org.jetbrains.compose.ExperimentalComposeLibrary +import org.jetbrains.compose.desktop.application.dsl.TargetFormat import org.jetbrains.kotlin.gradle.plugin.KotlinSourceSetTree plugins { @@ -15,7 +16,7 @@ kotlin { instrumentedTestVariant.sourceSetTree.set(KotlinSourceSetTree.test) } - + jvm("desktop") listOf( iosX64(), iosArm64(), @@ -28,6 +29,8 @@ kotlin { } sourceSets { + val desktopMain by getting + commonMain.dependencies { implementation(compose.runtime) implementation(compose.foundation) @@ -51,6 +54,11 @@ kotlin { implementation(libs.androidx.activityCompose) } + desktopMain.dependencies { + implementation(compose.desktop.currentOs) + implementation(libs.kotlinx.coroutines.swing) + } + } } @@ -83,3 +91,30 @@ tasks.named("embedAndSignAppleFrameworkForXcode") { } } + + +compose.desktop { + application { + mainClass = "MainKt" + + nativeDistributions { + targetFormats(TargetFormat.Dmg, TargetFormat.Msi, TargetFormat.Deb) + packageName = "org.company.app" + packageVersion = "1.0.0" + } + + afterEvaluate { + tasks.withType { + jvmArgs("--add-opens", "java.desktop/sun.awt=ALL-UNNAMED") + jvmArgs("--add-opens", "java.desktop/java.awt.peer=ALL-UNNAMED") // recommended but not necessary + + if (System.getProperty("os.name").contains("Mac")) { + jvmArgs("--add-opens", "java.desktop/sun.lwawt=ALL-UNNAMED") + jvmArgs("--add-opens", "java.desktop/sun.lwawt.macosx=ALL-UNNAMED") + } + } + } + + + } +} diff --git a/Sample/src/commonMain/kotlin/org/company/app/App.kt b/Sample/src/commonMain/kotlin/org/company/app/App.kt index ebc3d91..dd15ead 100644 --- a/Sample/src/commonMain/kotlin/org/company/app/App.kt +++ b/Sample/src/commonMain/kotlin/org/company/app/App.kt @@ -172,11 +172,14 @@ private fun CameraContent( }, onCameraControllerReady = { cameraController.value = it - qrScannerPlugin.startScanning() + } ) cameraController.value?.let { controller -> + LaunchedEffect(controller) { + qrScannerPlugin.startScanning() + } EnhancedCameraScreen( cameraController = controller, imageSaverPlugin = imageSaverPlugin diff --git a/Sample/src/desktopMain/kotlin/main.kt b/Sample/src/desktopMain/kotlin/main.kt new file mode 100644 index 0000000..e5931ca --- /dev/null +++ b/Sample/src/desktopMain/kotlin/main.kt @@ -0,0 +1,20 @@ +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Window +import androidx.compose.ui.window.application +import androidx.compose.ui.window.rememberWindowState +import org.company.app.App + +fun main()= application { + System.setProperty("compose.interop.blending", "true") + System.setProperty("compose.swing.render.on.layer", "true") + System.setProperty("compose.interop.blending", "true") + + Window( + title = "CameraK", + state = rememberWindowState(width = 1440.dp, height = 1024.dp), + onCloseRequest = ::exitApplication, + ) { + App() + } + +} \ No newline at end of file diff --git a/Sample/src/desktopMain/kotlin/org/company/app/theme/Theme.desktop.kt b/Sample/src/desktopMain/kotlin/org/company/app/theme/Theme.desktop.kt new file mode 100644 index 0000000..32d975a --- /dev/null +++ b/Sample/src/desktopMain/kotlin/org/company/app/theme/Theme.desktop.kt @@ -0,0 +1,8 @@ +package org.company.app.theme + +import androidx.compose.runtime.Composable + +@Composable +internal actual fun SystemAppearance(isDark: Boolean) { + //not needed +} \ No newline at end of file diff --git a/cameraK/build.gradle.kts b/cameraK/build.gradle.kts index 564b2c2..cb81db3 100644 --- a/cameraK/build.gradle.kts +++ b/cameraK/build.gradle.kts @@ -17,7 +17,7 @@ kotlin { androidTarget { publishLibraryVariants("release", "debug") } - + jvm("desktop") listOf( iosX64(), @@ -31,6 +31,12 @@ kotlin { } sourceSets { + val desktopMain by getting{ + dependencies{ + api(libs.javacv.platform) + } + } + commonMain.dependencies { api(libs.kotlinx.coroutines.core) api(libs.kotlinx.coroutines.test) diff --git a/cameraK/src/desktopMain/kotlin/com/kashif/cameraK/controller/CameraGrabber.kt b/cameraK/src/desktopMain/kotlin/com/kashif/cameraK/controller/CameraGrabber.kt new file mode 100644 index 0000000..0af15a8 --- /dev/null +++ b/cameraK/src/desktopMain/kotlin/com/kashif/cameraK/controller/CameraGrabber.kt @@ -0,0 +1,105 @@ +package com.kashif.cameraK.controller + + +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.delay +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch +import org.bytedeco.javacv.FFmpegFrameGrabber +import java.awt.image.BufferedImage + + +class CameraGrabber( + private val frameChannel: Channel, + private val errorHandler: (Throwable) -> Unit +) { + private val converter = FrameConverter() + private var grabber: FFmpegFrameGrabber? = null + private var job: Job? = null + + fun grabCurrentFrame(): BufferedImage? { + val frame = grabber?.grab() + return if (frame?.image != null) { + converter.convert(frame) + } else { + null + } + } + fun start(coroutineScope: CoroutineScope) { + if (job?.isActive == true) return + + grabber = createGrabber().apply { + try { + start() + } catch (e: Exception) { + errorHandler(e) + return + } + } + + job = coroutineScope.launch(Dispatchers.IO) { + var frameCount = 0 + var lastFpsTime = System.currentTimeMillis() + + try { + while (isActive) { + val frame = grabber?.grab() + if (frame?.image != null) { + converter.convert(frame)?.let { image -> + frameChannel.trySend(image) + + frameCount++ + val currentTime = System.currentTimeMillis() + if (currentTime - lastFpsTime >= 1000) { + println("Camera FPS: $frameCount") + frameCount = 0 + lastFpsTime = currentTime + } + } + } + + // Minimal delay to prevent CPU overload + delay(1) + } + } catch (e: Exception) { + errorHandler(e) + } + } + } + + fun stop() { + job?.cancel() + try { + grabber?.stop() + grabber?.release() + converter.release() + } catch (e: Exception) { + errorHandler(e) + } + } + + private fun createGrabber() = when { + System.getProperty("os.name").lowercase().contains("mac") -> { + FFmpegFrameGrabber("default").apply { + format = "avfoundation" + } + } + System.getProperty("os.name").lowercase().contains("windows") -> { + FFmpegFrameGrabber("video=0").apply { + format = "dshow" + } + } + else -> { + FFmpegFrameGrabber("/dev/video0").apply { + format = "v4l2" + } + } + }.apply { + frameRate = 30.0 + imageWidth = 640 + imageHeight = 480 + } +} \ No newline at end of file diff --git a/cameraK/src/desktopMain/kotlin/com/kashif/cameraK/controller/Controller.desktop.kt b/cameraK/src/desktopMain/kotlin/com/kashif/cameraK/controller/Controller.desktop.kt new file mode 100644 index 0000000..ff8d7df --- /dev/null +++ b/cameraK/src/desktopMain/kotlin/com/kashif/cameraK/controller/Controller.desktop.kt @@ -0,0 +1,139 @@ +package com.kashif.cameraK.controller + +import com.kashif.cameraK.enums.Directory +import com.kashif.cameraK.enums.FlashMode +import com.kashif.cameraK.enums.ImageFormat +import com.kashif.cameraK.plugins.CameraPlugin +import com.kashif.cameraK.result.ImageCaptureResult +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import java.awt.image.BufferedImage +import java.io.ByteArrayOutputStream +import javax.imageio.ImageIO + +/** + * Interface defining the core functionalities of the CameraController. + */ +actual class CameraController( + internal var plugins: MutableList, + imageFormat: ImageFormat, + directory: Directory +) { + private var cameraGrabber: CameraGrabber? = null + private val frameChannel = Channel(Channel.CONFLATED) + + private var listener: (ByteArray) -> Unit = { + + } + + /** + * Captures an image. + * + * @return The result of the image capture operation. + */ + actual suspend fun takePicture(): ImageCaptureResult { + return withContext(Dispatchers.IO) { + val currentImage = cameraGrabber?.grabCurrentFrame() + + if (currentImage == null) { + println("No image available") + return@withContext ImageCaptureResult.Error(IllegalStateException("No image available")) + } + + val outputStream = ByteArrayOutputStream() + return@withContext try { + ImageIO.write(currentImage, "jpg", outputStream) + listener(outputStream.toByteArray()) + ImageCaptureResult.Success(outputStream.toByteArray()) + } catch (e: Exception) { + e.printStackTrace() + ImageCaptureResult.Error(e) + } finally { + outputStream.close() + } + } + } + + /** + * Toggles the flash mode between ON, OFF, and AUTO. + */ + actual fun toggleFlashMode() { + //flash not available on desktop + } + + /** + * Sets the flash mode of the camera + * + * @param mode The desired [FlashMode] + */ + actual fun setFlashMode(mode: FlashMode) { + //flash not available on desktop + } + + /** + * @return the current [FlashMode] of the camera, if available + */ + actual fun getFlashMode(): FlashMode? { + return FlashMode.OFF + } + + /** + * Toggles the torch mode between ON, OFF + * + * In IOS, torch mode include AUTO. + */ + actual fun toggleTorchMode() { + //torch not available on desktop + } + + /** + * Toggles the camera lens between FRONT and BACK. + */ + actual fun toggleCameraLens() { + //camera lens not available on desktop + } + + /** + * Starts the camera session. + */ + actual fun startSession() { + CoroutineScope(Dispatchers.Default).launch { + cameraGrabber = CameraGrabber(frameChannel, { + println("Camera error: ${it.message}") + it.printStackTrace() + }).apply { + start(this@launch) + } + } + + } + + /** + * Stops the camera session. + */ + actual fun stopSession() { + cameraGrabber?.stop() + frameChannel.close() + } + + /** + * Adds a listener for image capture events. + * + * @param listener The listener to add, receiving image data as [ByteArray]. + */ + actual fun addImageCaptureListener(listener: (ByteArray) -> Unit) { + this.listener = listener + } + + actual fun initializeControllerPlugins() { + plugins.forEach { + it.initialize(this) + } + } + + fun getFrameChannel() = frameChannel + +} \ No newline at end of file diff --git a/cameraK/src/desktopMain/kotlin/com/kashif/cameraK/controller/DesktopCameraControllerBuilder.kt b/cameraK/src/desktopMain/kotlin/com/kashif/cameraK/controller/DesktopCameraControllerBuilder.kt new file mode 100644 index 0000000..1b6367a --- /dev/null +++ b/cameraK/src/desktopMain/kotlin/com/kashif/cameraK/controller/DesktopCameraControllerBuilder.kt @@ -0,0 +1,70 @@ +package com.kashif.cameraK.controller + +import com.kashif.cameraK.builder.CameraControllerBuilder +import com.kashif.cameraK.enums.CameraLens +import com.kashif.cameraK.enums.Directory +import com.kashif.cameraK.enums.FlashMode +import com.kashif.cameraK.enums.ImageFormat +import com.kashif.cameraK.enums.TorchMode +import com.kashif.cameraK.plugins.CameraPlugin +import com.kashif.cameraK.utils.InvalidConfigurationException + +/** + * Desktop-specific implementation of [CameraControllerBuilder]. + */ +class DesktopCameraControllerBuilder : CameraControllerBuilder { + + private var flashMode: FlashMode = FlashMode.OFF + private var torchMode: TorchMode = TorchMode.OFF + private var cameraLens: CameraLens = CameraLens.BACK + private var imageFormat: ImageFormat? = null + private var directory: Directory? = null + private val plugins = mutableListOf() + + override fun setFlashMode(flashMode: FlashMode): CameraControllerBuilder { + this.flashMode = flashMode + return this + } + + override fun setCameraLens(cameraLens: CameraLens): CameraControllerBuilder { + this.cameraLens = cameraLens + return this + } + + + + override fun setImageFormat(imageFormat: ImageFormat): CameraControllerBuilder { + this.imageFormat = imageFormat + return this + } + + override fun setTorchMode(torchMode: TorchMode): CameraControllerBuilder { + this.torchMode = torchMode + return this + } + + override fun setDirectory(directory: Directory): CameraControllerBuilder { + this.directory = directory + return this + } + + override fun addPlugin(plugin: CameraPlugin): CameraControllerBuilder { + plugins.add(plugin) + return this + } + + override fun build(): CameraController { + + val format = imageFormat ?: throw InvalidConfigurationException("ImageFormat must be set.") + val dir = directory ?: throw InvalidConfigurationException("Directory must be set.") + + // Initialize the iOS-specific CameraController + val cameraController = CameraController( + imageFormat = format, + directory = dir, + plugins = plugins + ) + + return cameraController + } +} diff --git a/cameraK/src/desktopMain/kotlin/com/kashif/cameraK/controller/FrameConverter.kt b/cameraK/src/desktopMain/kotlin/com/kashif/cameraK/controller/FrameConverter.kt new file mode 100644 index 0000000..ded86e4 --- /dev/null +++ b/cameraK/src/desktopMain/kotlin/com/kashif/cameraK/controller/FrameConverter.kt @@ -0,0 +1,39 @@ +package com.kashif.cameraK.controller + +import org.bytedeco.javacv.Frame +import org.bytedeco.javacv.Java2DFrameConverter +import java.awt.GraphicsEnvironment +import java.awt.Transparency +import java.awt.image.BufferedImage + +class FrameConverter { + private val converter = Java2DFrameConverter() + private var cachedImage: BufferedImage? = null + private val graphicsConfig = GraphicsEnvironment + .getLocalGraphicsEnvironment() + .defaultScreenDevice + .defaultConfiguration + + fun convert(frame: Frame): BufferedImage? { + return try { + if (cachedImage == null || + cachedImage?.width != frame.imageWidth || + cachedImage?.height != frame.imageHeight) { + cachedImage = graphicsConfig.createCompatibleImage( + frame.imageWidth, + frame.imageHeight, + Transparency.OPAQUE + ) + } + converter.convert( frame) + } catch (e: Exception) { + e.printStackTrace() + null + } + } + + fun release() { + converter.close() + cachedImage = null + } +} \ No newline at end of file diff --git a/cameraK/src/desktopMain/kotlin/com/kashif/cameraK/controller/ImageCaptureManager.kt b/cameraK/src/desktopMain/kotlin/com/kashif/cameraK/controller/ImageCaptureManager.kt new file mode 100644 index 0000000..4139ff9 --- /dev/null +++ b/cameraK/src/desktopMain/kotlin/com/kashif/cameraK/controller/ImageCaptureManager.kt @@ -0,0 +1,37 @@ +package com.kashif.cameraK.controller + +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import java.awt.image.BufferedImage +import java.io.File +import java.time.LocalDateTime +import java.time.format.DateTimeFormatter +import java.util.concurrent.atomic.AtomicBoolean +import javax.imageio.ImageIO + +class ImageCaptureManager { + private val isCapturing = AtomicBoolean(false) + private val outputDir = File("captured_images").apply { + if (!exists()) mkdirs() + } + + suspend fun captureImage(image: BufferedImage): Result = withContext(Dispatchers.IO) { + if (!isCapturing.compareAndSet(false, true)) { + return@withContext Result.failure(IllegalStateException("Capture already in progress")) + } + + try { + val timestamp = LocalDateTime.now().format( + DateTimeFormatter.ofPattern("yyyy-MM-dd_HH-mm-ss-SSS") + ) + val outputFile = File(outputDir, "capture_$timestamp.jpg") + + Result.runCatching { + ImageIO.write(image, "jpg", outputFile) + outputFile + } + } finally { + isCapturing.set(false) + } + } +} diff --git a/cameraK/src/desktopMain/kotlin/com/kashif/cameraK/controller/ImagePanel.kt b/cameraK/src/desktopMain/kotlin/com/kashif/cameraK/controller/ImagePanel.kt new file mode 100644 index 0000000..9f4bd11 --- /dev/null +++ b/cameraK/src/desktopMain/kotlin/com/kashif/cameraK/controller/ImagePanel.kt @@ -0,0 +1,77 @@ +package com.kashif.cameraK.controller + +import java.awt.AlphaComposite +import java.awt.Color +import java.awt.Graphics +import java.awt.RenderingHints +import java.awt.Transparency +import java.awt.image.BufferedImage +import java.awt.image.VolatileImage +import javax.swing.JPanel + + +class ImagePanel : JPanel(true) { + private var volatileImage: VolatileImage? = null + var currentImage: BufferedImage? = null + private var renderCount = 0 + private var lastRenderTime = System.currentTimeMillis() + + init { + background = Color(0, 0, 0, 0) + isOpaque = false + + + enableEvents(0L) + isEnabled = false + } + + + override fun contains(x: Int, y: Int): Boolean = false + + fun updateImage(image: BufferedImage?) { + currentImage = image + renderCount++ + val currentTime = System.currentTimeMillis() + if (currentTime - lastRenderTime >= 1000) { + println("Render FPS: $renderCount") + renderCount = 0 + lastRenderTime = currentTime + } + repaint() + } + + override fun paintComponent(g: Graphics) { + super.paintComponent(g) + + if (currentImage == null) return + + val gc = graphicsConfiguration + var valid = true + + if (volatileImage == null || + volatileImage?.width != width || + volatileImage?.height != height) { + volatileImage = gc.createCompatibleVolatileImage(width, height, Transparency.TRANSLUCENT) + } + + do { + if (volatileImage?.validate(gc) == VolatileImage.IMAGE_INCOMPATIBLE) { + volatileImage = gc.createCompatibleVolatileImage(width, height, Transparency.TRANSLUCENT) + } + + volatileImage?.createGraphics()?.let { volatileG -> + volatileG.setRenderingHint( + RenderingHints.KEY_INTERPOLATION, + RenderingHints.VALUE_INTERPOLATION_BILINEAR + ) + volatileG.composite = AlphaComposite.SrcOver + volatileG.drawImage(currentImage, 0, 0, width, height, null) + volatileG.dispose() + } + + g.drawImage(volatileImage, 0, 0, null) + + valid = volatileImage?.contentsLost() != true + } while (!valid) + } +} diff --git a/cameraK/src/desktopMain/kotlin/com/kashif/cameraK/permissions/ProvidePermission.desktop.kt b/cameraK/src/desktopMain/kotlin/com/kashif/cameraK/permissions/ProvidePermission.desktop.kt new file mode 100644 index 0000000..8dee819 --- /dev/null +++ b/cameraK/src/desktopMain/kotlin/com/kashif/cameraK/permissions/ProvidePermission.desktop.kt @@ -0,0 +1,35 @@ +package com.kashif.cameraK.permissions + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember + +/** + * Factory function to provide platform-specific [Permissions] implementation. + */ +@Composable +actual fun providePermissions(): Permissions { + //not needed + return remember { + object : Permissions { + override fun hasCameraPermission(): Boolean { + return true + } + + override fun hasStoragePermission(): Boolean { + return true + } + + @Composable + override fun RequestCameraPermission(onGranted: () -> Unit, onDenied: () -> Unit) { + onGranted() + + } + + @Composable + override fun RequestStoragePermission(onGranted: () -> Unit, onDenied: () -> Unit) { + onGranted() + } + + } + } +} \ No newline at end of file diff --git a/cameraK/src/desktopMain/kotlin/com/kashif/cameraK/ui/CameraPreview.desktop.kt b/cameraK/src/desktopMain/kotlin/com/kashif/cameraK/ui/CameraPreview.desktop.kt new file mode 100644 index 0000000..de908a5 --- /dev/null +++ b/cameraK/src/desktopMain/kotlin/com/kashif/cameraK/ui/CameraPreview.desktop.kt @@ -0,0 +1,71 @@ +package com.kashif.cameraK.ui + +import androidx.compose.foundation.layout.BoxWithConstraints +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.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.awt.SwingPanel +import com.kashif.cameraK.builder.CameraControllerBuilder +import com.kashif.cameraK.controller.CameraController +import com.kashif.cameraK.controller.DesktopCameraControllerBuilder +import com.kashif.cameraK.controller.ImagePanel +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.consumeAsFlow +import kotlinx.coroutines.launch + +@Composable +actual fun expectCameraPreview( + modifier: Modifier, + cameraConfiguration: CameraControllerBuilder.() -> Unit, + onCameraControllerReady: (CameraController) -> Unit +) { + + + val cameraController = remember { + DesktopCameraControllerBuilder() + .apply(cameraConfiguration).build() + } + + + + BoxWithConstraints(modifier = modifier) { + val scope = rememberCoroutineScope() + + val frameChannel = cameraController.getFrameChannel() + var panel by remember { mutableStateOf(null) } + + DisposableEffect(Unit) { + cameraController.startSession() + cameraController.initializeControllerPlugins() + + val frameJob = scope.launch(Dispatchers.Main) { + frameChannel.consumeAsFlow().collect { image -> + panel?.updateImage(image) + + } + } + + onDispose { + frameJob.cancel() + cameraController.stopSession() + } + } + + // Camera Preview + SwingPanel( + modifier = modifier.fillMaxSize(), + factory = { + onCameraControllerReady(cameraController) + ImagePanel().also { panel = it } + }, + update = { } + ) + } +} \ No newline at end of file diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index abe24bb..05b7673 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -6,17 +6,20 @@ camera-extensions = "1.3.4" camera-lifecycle = "1.3.4" camera-view = "1.3.4" core = "3.4.1" -kotlin = "2.0.20" +coreVersion = "3.5.1" +javase = "3.5.1" +kotlin = "2.1.0" agp = "8.5.2" kotlinx-coroutines = "1.9.0-RC.2" kermit = "2.0.4" -compose-multiplatform = "1.7.0-beta02" +compose-multiplatform = "1.7.3" activity = "1.9.2" # https://developer.android.com/jetpack/androidx/releases/activity -coil-mp-version = "3.0.0-alpha10" -jetbrains-kotlin-jvm = "1.9.23" +coil-mp-version = "3.0.4" +jetbrains-kotlin-jvm = "2.0.21" androidx-activityCompose = "1.9.1" startupRuntime = "1.1.1" +javacvPlatform = "1.5.11" [libraries] @@ -28,6 +31,8 @@ camera-core = { module = "androidx.camera:camera-core", version.ref = "camera-co camera-extensions = { module = "androidx.camera:camera-extensions", version.ref = "camera-extensions" } camera-lifecycle = { module = "androidx.camera:camera-lifecycle", version.ref = "camera-lifecycle" } core = { module = "com.google.zxing:core", version.ref = "core" } +core-v351 = { module = "com.google.zxing:core", version.ref = "coreVersion" } +javase = { module = "com.google.zxing:javase", version.ref = "javase" } kotlinx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "kotlinx-coroutines" } kotlinx-coroutines-android = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-android", version.ref = "kotlinx-coroutines" } kotlinx-coroutines-swing = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-swing", version.ref = "kotlinx-coroutines" } @@ -41,6 +46,7 @@ coil3-compose-core = { module = "io.coil-kt.coil3:coil-compose-core", version.re coil3-ktor = { module = "io.coil-kt.coil3:coil-network-ktor2", version.ref = "coil-mp-version" } androidx-activityCompose = { module = "androidx.activity:activity-compose", version.ref = "androidx-activityCompose" } +javacv-platform = { module = "org.bytedeco:javacv-platform", version.ref = "javacvPlatform" } [plugins] diff --git a/qrScannerPlugin/build.gradle.kts b/qrScannerPlugin/build.gradle.kts index c92f2f3..74b09d5 100644 --- a/qrScannerPlugin/build.gradle.kts +++ b/qrScannerPlugin/build.gradle.kts @@ -18,7 +18,7 @@ kotlin { publishLibraryVariants("release", "debug") } - + jvm("desktop") listOf( iosX64(), iosArm64(), @@ -31,6 +31,13 @@ kotlin { } sourceSets { + val desktopMain by getting{ + dependencies { + implementation(libs.javase) + implementation(libs.core.v351) + } + } + commonMain.dependencies { api(projects.cameraK) implementation(libs.atomicfu) diff --git a/qrScannerPlugin/src/desktopMain/kotlin/com/kashif/qrscannerplugin/QRScanner.kt b/qrScannerPlugin/src/desktopMain/kotlin/com/kashif/qrscannerplugin/QRScanner.kt new file mode 100644 index 0000000..5f216cd --- /dev/null +++ b/qrScannerPlugin/src/desktopMain/kotlin/com/kashif/qrscannerplugin/QRScanner.kt @@ -0,0 +1,52 @@ +package com.kashif.qrscannerplugin + +import com.google.zxing.BarcodeFormat +import com.google.zxing.BinaryBitmap +import com.google.zxing.DecodeHintType +import com.google.zxing.MultiFormatReader +import com.google.zxing.NotFoundException +import com.google.zxing.client.j2se.BufferedImageLuminanceSource +import com.google.zxing.common.HybridBinarizer +import java.awt.image.BufferedImage +import java.util.concurrent.locks.ReentrantLock + + +class QRScanner { + private val reader = MultiFormatReader().apply { + val hints = mapOf( + DecodeHintType.POSSIBLE_FORMATS to listOf(BarcodeFormat.QR_CODE), + DecodeHintType.TRY_HARDER to true + ) + setHints(hints) + } + private val lock = ReentrantLock() + private var lastProcessTime = 0L + private val processInterval = 200L // Time between scans in milliseconds + + fun scanImage(image: BufferedImage): String? { + if (!lock.tryLock()) return null + + return try { + val currentTime = System.currentTimeMillis() + if (currentTime - lastProcessTime < processInterval) { + return null + } + + val source = BufferedImageLuminanceSource(image) + val bitmap = BinaryBitmap(HybridBinarizer(source)) + + try { + val result = reader.decode(bitmap) + lastProcessTime = currentTime + result.text + } catch (e: NotFoundException) { + null + } + } catch (e: Exception) { + println("QR scanning error: ${e.message}") + null + } finally { + lock.unlock() + } + } +} \ No newline at end of file diff --git a/qrScannerPlugin/src/desktopMain/kotlin/com/kashif/qrscannerplugin/QRScannerPlugin.desktop.kt b/qrScannerPlugin/src/desktopMain/kotlin/com/kashif/qrscannerplugin/QRScannerPlugin.desktop.kt new file mode 100644 index 0000000..aa65ad4 --- /dev/null +++ b/qrScannerPlugin/src/desktopMain/kotlin/com/kashif/qrscannerplugin/QRScannerPlugin.desktop.kt @@ -0,0 +1,32 @@ +package com.kashif.qrscannerplugin + +import com.kashif.cameraK.controller.CameraController +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.consumeAsFlow +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +/** + * Platform-specific function to start scanning for QR codes. + * + * @param controller The CameraController to be used for scanning. + * @param onQrScanner A callback function that is invoked when a QR code is scanned. + */ +actual fun startScanning( + controller: CameraController, + onQrScanner: (String) -> Unit +) { + val qrScanner = QRScanner() + val scope = CoroutineScope(Dispatchers.Default) + + scope.launch { + controller.getFrameChannel().consumeAsFlow().collect { image -> + qrScanner.scanImage(image)?.let { code -> + withContext(Dispatchers.Main) { + onQrScanner(code) + } + } + } + } +} \ No newline at end of file