diff --git a/experimental/examples/imageviewer/build.gradle.kts b/experimental/examples/imageviewer/build.gradle.kts
index 8d0c1f3d7c3..a955843486c 100644
--- a/experimental/examples/imageviewer/build.gradle.kts
+++ b/experimental/examples/imageviewer/build.gradle.kts
@@ -15,5 +15,6 @@ allprojects {
mavenCentral()
maven("https://maven.pkg.jetbrains.space/public/p/compose/dev")
mavenLocal()
+ maven("https://jitpack.io")
}
}
diff --git a/experimental/examples/imageviewer/desktopApp/build.gradle.kts b/experimental/examples/imageviewer/desktopApp/build.gradle.kts
index 95effc02638..1fdeee3232e 100755
--- a/experimental/examples/imageviewer/desktopApp/build.gradle.kts
+++ b/experimental/examples/imageviewer/desktopApp/build.gradle.kts
@@ -30,7 +30,14 @@ compose.desktop {
val iconsRoot = project.file("../common/src/desktopMain/resources/images")
macOS {
- iconFile.set(iconsRoot.resolve("icon-mac.icns"))
+ //iconFile.set(iconsRoot.resolve("icon-mac.icns")) does not exist
+ runtimeEntitlementsFile.set(project.file("runtime-entitlements.plist"))
+ infoPlist {
+ extraKeysRawXml = """
+ NSCameraUsageDescription
+ This app uses camera for capturing photos
+ """.trimIndent()
+ }
}
windows {
iconFile.set(iconsRoot.resolve("icon-windows.ico"))
diff --git a/experimental/examples/imageviewer/desktopApp/runtime-entitlements.plist b/experimental/examples/imageviewer/desktopApp/runtime-entitlements.plist
new file mode 100644
index 00000000000..336faf5d05c
--- /dev/null
+++ b/experimental/examples/imageviewer/desktopApp/runtime-entitlements.plist
@@ -0,0 +1,22 @@
+
+
+
+
+ com.apple.security.app-sandbox
+
+ com.apple.security.cs.allow-jit
+
+ com.apple.security.cs.allow-unsigned-executable-memory
+
+ com.apple.security.cs.disable-library-validation
+
+ com.apple.security.cs.allow-dyld-environment-variables
+
+ com.apple.security.cs.debugger
+
+ com.apple.security.device.audio-input
+
+ com.apple.security.device.camera
+
+
+
\ No newline at end of file
diff --git a/experimental/examples/imageviewer/shared/build.gradle.kts b/experimental/examples/imageviewer/shared/build.gradle.kts
index be5eaddcfdb..8e33c781ac9 100755
--- a/experimental/examples/imageviewer/shared/build.gradle.kts
+++ b/experimental/examples/imageviewer/shared/build.gradle.kts
@@ -65,6 +65,10 @@ kotlin {
implementation(compose.desktop.common)
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-swing:1.6.4")
implementation("io.ktor:ktor-client-cio:$ktorVersion")
+
+ implementation("com.github.sarxos:webcam-capture:0.3.12")
+ implementation("com.github.sarxos:webcam-capture-driver-gstreamer:0.3.12") // for Linux
+ implementation("com.github.eduramiba:webcam-capture-driver-native:-SNAPSHOT") // for MacOS and Windows
}
}
}
diff --git a/experimental/examples/imageviewer/shared/src/desktopMain/kotlin/example/imageviewer/utils/Webcam.kt b/experimental/examples/imageviewer/shared/src/desktopMain/kotlin/example/imageviewer/utils/Webcam.kt
new file mode 100644
index 00000000000..40bdfa0b149
--- /dev/null
+++ b/experimental/examples/imageviewer/shared/src/desktopMain/kotlin/example/imageviewer/utils/Webcam.kt
@@ -0,0 +1,226 @@
+package example.imageviewer.utils
+
+import androidx.compose.foundation.*
+import androidx.compose.foundation.layout.*
+import androidx.compose.material.*
+import androidx.compose.runtime.*
+import androidx.compose.ui.graphics.*
+import com.github.eduramiba.webcamcapture.drivers.NativeDriver
+import com.github.sarxos.webcam.*
+import com.github.sarxos.webcam.ds.gstreamer.GStreamerDriver
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
+import kotlinx.coroutines.yield
+import org.jetbrains.skia.Bitmap
+import org.jetbrains.skia.ColorAlphaType
+import org.jetbrains.skia.ImageInfo
+import java.awt.Dimension
+import java.awt.image.*
+import kotlin.math.max
+import kotlin.time.ExperimentalTime
+import kotlin.time.measureTimedValue
+
+private var driverInitialized = false
+
+@Composable
+internal fun rememberWebcamListState(): WebcamListState {
+ val webcamListState = remember { WebcamListState() }
+ webcamListState.setup()
+
+ return webcamListState
+}
+
+@Stable
+internal class WebcamListState {
+ private val webcamsMutable = mutableStateListOf()
+ val webcams: List = webcamsMutable
+
+ var isLoading by mutableStateOf(true)
+ private set
+ var defaultWebcam: Webcam? by mutableStateOf(null) // null when there is no camera.
+ private set
+
+ @Composable
+ fun setup() {
+ LaunchedEffect(Unit) {
+ isLoading = true
+ withContext(Dispatchers.IO) {
+ // check if running on ARM/MacOS and apply the native driver.
+ if(driverInitialized.not()) {
+ val os = System.getProperty("os.name")
+ val driver = if(os.contains("linux", ignoreCase = true)) {
+ GStreamerDriver()
+ } else {
+ // mac and windows
+ NativeDriver()
+ }
+
+ Webcam.setDriver(driver)
+ driverInitialized = true
+ }
+
+ Webcam.getWebcams().forEach { webcam ->
+ webcamsMutable.removeIf { it.name == webcam.name }
+ webcamsMutable.add(webcam)
+ }
+
+ defaultWebcam = Webcam.getDefault()
+ }
+ isLoading = false
+ }
+
+ DisposableEffect(Unit) {
+ val listener = object : WebcamDiscoveryListener {
+ override fun webcamFound(event: WebcamDiscoveryEvent) {
+ webcamsMutable.removeIf { it.name == event.webcam.name }
+ webcamsMutable.add(event.webcam)
+ }
+
+ override fun webcamGone(event: WebcamDiscoveryEvent) {
+ webcamsMutable.removeIf { it.name == event.webcam.name }
+ }
+ }
+ Webcam.addDiscoveryListener(listener)
+
+ onDispose {
+ Webcam.removeDiscoveryListener(listener)
+ }
+ }
+ }
+}
+
+@Composable
+internal fun rememberWebcamState(webcam: Webcam): WebcamState {
+ val state = remember {
+ webcam.viewSize = Dimension(1280, 720)
+ WebcamState(webcam)
+ }
+ state.setup()
+
+ return state
+}
+
+@Stable
+internal class WebcamState(webcam: Webcam) {
+ var webcam by mutableStateOf(webcam)
+ var resolutions by mutableStateOf(webcam.viewSizes.toList())
+ private set
+
+ var currentWebcamResolution by mutableStateOf(webcam.viewSize)
+
+ var lastFrame by mutableStateOf(null)
+ private set
+
+ var fpsLimitation: Int? by mutableStateOf(30) // if null, it will try the most FPS the computer can take.
+
+ var timeSpentPerFrame: Long by mutableStateOf(0L)
+ private set
+
+ @OptIn(ExperimentalTime::class)
+ @Composable
+ fun setup() {
+ val coroutineScope = rememberCoroutineScope()
+ DisposableEffect(webcam) {
+ coroutineScope.launch(Dispatchers.IO) {
+ webcam.lock.disable()
+ webcam.open(true)
+ }
+ onDispose {
+ webcam.close()
+ }
+ }
+
+ LaunchedEffect(webcam) {
+ resolutions = webcam.viewSizes.toList()
+ }
+
+ LaunchedEffect(currentWebcamResolution) {
+ requiredClose(webcam) {
+ viewSize = currentWebcamResolution
+ }
+ }
+
+ LaunchedEffect(webcam) {
+ withContext(Dispatchers.IO) {
+ while (true) {
+ val (imageBitmap, measure) = measureTimedValue {
+ webcam.imageBitmap()
+ }
+ if (imageBitmap != null) {
+ lastFrame = imageBitmap
+ timeSpentPerFrame = measure.inWholeMilliseconds
+
+ if(fpsLimitation != null) {
+ delay(max(fpsLimitation!! - timeSpentPerFrame, 1))
+ }
+ } else {
+ delay(50) // delay until pickup camera again
+ }
+ yield()
+ }
+ }
+ }
+ }
+}
+
+private fun requiredClose(webcam: Webcam, apply: Webcam.() -> Unit) {
+ val listener = object : WebcamListener {
+ override fun webcamOpen(we: WebcamEvent) {
+ we.source.removeWebcamListener(this)
+ }
+
+ override fun webcamClosed(we: WebcamEvent) {
+ we.source.apply()
+ we.source.open()
+ }
+
+ override fun webcamDisposed(we: WebcamEvent?) {}
+
+ override fun webcamImageObtained(we: WebcamEvent?) {}
+
+ }
+
+ webcam.addWebcamListener(listener)
+ webcam.close()
+}
+
+private fun Webcam.imageBitmap(): ImageBitmap? {
+ val buffer = imageBytes ?: return null
+ val arrayBuffer = ByteArray(buffer.capacity())
+
+
+ val width = viewSize.width
+ val height = viewSize.height
+
+ val bytesPerPixel = 4
+ val pixels = ByteArray(width * height * bytesPerPixel)
+
+ buffer.mark()
+ buffer.position(0)
+ buffer.get(arrayBuffer, 0, buffer.capacity())
+ buffer.reset()
+
+ // d15f35 -> 3558c7ff
+ // to not go through BufferedImage.toComposeImageBitmap
+ // instead we map directly de sRGB from Webcam Image Buffer
+ // this is way faster, probably there is faster approach.
+ var k = 0
+ for (i in 0 until buffer.capacity() step 3) {
+ val r = arrayBuffer.get(i)
+ val g = arrayBuffer.get(i + 1)
+ val b = arrayBuffer.get(i + 2)
+ pixels[k++] = b
+ pixels[k++] = g
+ pixels[k++] = r
+ pixels[k++] = 0xff.toByte()
+ }
+
+ val bitmap = Bitmap()
+
+ bitmap.allocPixels(ImageInfo.makeS32(width, height, ColorAlphaType.UNPREMUL))
+ bitmap.installPixels(pixels)
+
+ return bitmap.asComposeImageBitmap()
+}
diff --git a/experimental/examples/imageviewer/shared/src/desktopMain/kotlin/example/imageviewer/view/CameraView.desktop.kt b/experimental/examples/imageviewer/shared/src/desktopMain/kotlin/example/imageviewer/view/CameraView.desktop.kt
index 0051dd3be6b..3a3781a57ac 100644
--- a/experimental/examples/imageviewer/shared/src/desktopMain/kotlin/example/imageviewer/view/CameraView.desktop.kt
+++ b/experimental/examples/imageviewer/shared/src/desktopMain/kotlin/example/imageviewer/view/CameraView.desktop.kt
@@ -1,21 +1,122 @@
package example.imageviewer.view
+import androidx.compose.foundation.ContextMenuArea
+import androidx.compose.foundation.ContextMenuItem
+import androidx.compose.foundation.ContextMenuState
+import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.BoxScope
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.padding
+import androidx.compose.material3.Button
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
+import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.Modifier
+import androidx.compose.ui.geometry.Rect
import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.FilterQuality
+import androidx.compose.ui.input.pointer.PointerEventType
+import androidx.compose.ui.input.pointer.onPointerEvent
+import androidx.compose.ui.layout.ContentScale
+import androidx.compose.ui.unit.dp
+import example.imageviewer.utils.rememberWebcamListState
+import example.imageviewer.utils.rememberWebcamState
@Composable
internal actual fun CameraView(modifier: Modifier) {
Box(Modifier.fillMaxSize().background(Color.Black)) {
- Text(
- text = "Camera is not available on Desktop for now.",
- color = Color.White,
- modifier = Modifier.align(Alignment.Center)
- )
+ val webcamListState = rememberWebcamListState()
+ if (webcamListState.isLoading.not()) {
+ if (webcamListState.webcams.isNotEmpty()) {
+ val webcamState = rememberWebcamState(webcamListState.defaultWebcam!!)
+
+ val lastFrame = webcamState.lastFrame
+ if (lastFrame != null) {
+ Image(
+ modifier = Modifier.matchParentSize(),
+ bitmap = lastFrame,
+ contentDescription = "",
+ contentScale = ContentScale.Crop,
+ filterQuality = FilterQuality.High
+ )
+ } else {
+ CameraText(text = "Camera buffer loading.")
+ }
+
+ Row(
+ modifier = Modifier.align(Alignment.BottomCenter).padding(bottom = 32.dp)
+ ) {
+ if(lastFrame != null) {
+ Button(
+ onClick = {
+ //todo pass image to gallery page
+ },
+ ) {
+ Text("Take a photo")
+ }
+ Spacer(Modifier.padding(horizontal = 8.dp))
+ }
+ if(webcamListState.webcams.size > 1) {
+ EasyDropdown(
+ selectedItem = webcamState.webcam,
+ items = webcamListState.webcams,
+ itemName = { webcam -> webcam.name },
+ onSelected = { webcam -> webcamState.webcam = webcam }
+ )
+ }
+ }
+ } else {
+ CameraText(text = "Camera not found, connect one.")
+ }
+ } else {
+ CameraText(text = "Camera is loading.")
+ }
+
}
}
+
+@Composable
+private fun BoxScope.CameraText(text: String) {
+ Text(
+ text = text,
+ color = Color.White,
+ modifier = Modifier.align(Alignment.Center)
+ )
+}
+
+@OptIn(ExperimentalComposeUiApi::class)
+@Composable
+private fun EasyDropdown(
+ selectedItem: T,
+ items: List,
+ itemName: (T) -> String,
+ onSelected: (T) -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ var isOpen by remember { mutableStateOf(false) }
+ val state = remember { ContextMenuState() }
+ Button(
+ onClick = { isOpen = true },
+ modifier = modifier.onPointerEvent(PointerEventType.Press) {
+ state.status =
+ ContextMenuState.Status.Open(Rect(it.changes[0].position, 0f))
+ },
+ ) {
+ val displayName = remember(selectedItem) { itemName(selectedItem) }
+ Text(displayName)
+ }
+ val items = remember(items) {
+ items.map { ContextMenuItem(itemName(it), { onSelected(it) }) }
+ }
+
+ ContextMenuArea( { items }, state) {}
+}
\ No newline at end of file