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