Skip to content

Commit

Permalink
Implement camera fallback for iOS simulator
Browse files Browse the repository at this point in the history
  • Loading branch information
shepeliev committed Sep 19, 2024
1 parent 13c6a08 commit 04d68fd
Show file tree
Hide file tree
Showing 11 changed files with 298 additions and 176 deletions.
3 changes: 2 additions & 1 deletion sample/composeApp/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ kotlin {
framework {
baseName = "ComposeApp"
isStatic = true
export(project(":webrtc-kmp"))
}

xcodeConfigurationToNativeBuildType["CUSTOM_DEBUG"] = NativeBuildType.DEBUG
Expand Down Expand Up @@ -80,7 +81,7 @@ kotlin {
implementation(compose.components.uiToolingPreview)
implementation(libs.kotlin.coroutines)
implementation(libs.kermit)
implementation(project(":webrtc-kmp"))
api(project(":webrtc-kmp"))
}

androidMain.dependencies {
Expand Down
12 changes: 12 additions & 0 deletions sample/iosApp/iosApp.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
2152FB042600AC8F00CF470E /* iOSApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2152FB032600AC8F00CF470E /* iOSApp.swift */; };
7555FF83242A565900829871 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7555FF82242A565900829871 /* ContentView.swift */; };
BACABBB76BD57FDE145E548F /* Pods_iosApp.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = D465BB72350B93DD163014A3 /* Pods_iosApp.framework */; };
FA2437792C9C72C100AA992D /* simulator-camera.mp4 in Resources */ = {isa = PBXBuildFile; fileRef = FA2437782C9C72C100AA992D /* simulator-camera.mp4 */; };
/* End PBXBuildFile section */

/* Begin PBXFileReference section */
Expand All @@ -25,6 +26,7 @@
BE8209C28DF6945B2E9C887E /* Pods-iosApp.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-iosApp.debug.xcconfig"; path = "Target Support Files/Pods-iosApp/Pods-iosApp.debug.xcconfig"; sourceTree = "<group>"; };
D465BB72350B93DD163014A3 /* Pods_iosApp.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_iosApp.framework; sourceTree = BUILT_PRODUCTS_DIR; };
E1B425F280C2C5293311AC4F /* Pods-iosApp.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-iosApp.release.xcconfig"; path = "Target Support Files/Pods-iosApp/Pods-iosApp.release.xcconfig"; sourceTree = "<group>"; };
FA2437782C9C72C100AA992D /* simulator-camera.mp4 */ = {isa = PBXFileReference; lastKnownFileType = file; path = "simulator-camera.mp4"; sourceTree = "<group>"; };
/* End PBXFileReference section */

/* Begin PBXFrameworksBuildPhase section */
Expand Down Expand Up @@ -86,6 +88,7 @@
7555FF7D242A565900829871 /* iosApp */ = {
isa = PBXGroup;
children = (
FA2437732C9C614800AA992D /* Resources */,
058557BA273AAA24004C7B11 /* Assets.xcassets */,
7555FF82242A565900829871 /* ContentView.swift */,
7555FF8C242A565B00829871 /* Info.plist */,
Expand All @@ -103,6 +106,14 @@
path = Configuration;
sourceTree = "<group>";
};
FA2437732C9C614800AA992D /* Resources */ = {
isa = PBXGroup;
children = (
FA2437782C9C72C100AA992D /* simulator-camera.mp4 */,
);
path = Resources;
sourceTree = "<group>";
};
/* End PBXGroup section */

/* Begin PBXNativeTarget section */
Expand Down Expand Up @@ -165,6 +176,7 @@
buildActionMask = 2147483647;
files = (
058557D9273AAEEB004C7B11 /* Preview Assets.xcassets in Resources */,
FA2437792C9C72C100AA992D /* simulator-camera.mp4 in Resources */,
058557BB273AAA24004C7B11 /* Assets.xcassets in Resources */,
);
runOnlyForDeploymentPostprocessing = 0;
Expand Down
Binary file not shown.
7 changes: 6 additions & 1 deletion sample/iosApp/iosApp/iOSApp.swift
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import SwiftUI
import ComposeApp

@main
struct iOSApp: App {
Expand All @@ -7,4 +8,8 @@ struct iOSApp: App {
ContentView()
}
}
}

init() {
WebRtc.shared.configurePeerConnectionFactory(loggingSeverity: .rtcloggingseveritywarning)
}
}
12 changes: 10 additions & 2 deletions webrtc-kmp/build.gradle.kts
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import org.jetbrains.kotlin.gradle.ExperimentalKotlinGradlePluginApi
import org.jetbrains.kotlin.gradle.plugin.KotlinSourceSetTree
import org.jetbrains.kotlin.gradle.plugin.mpp.KotlinNativeTarget
import org.jetbrains.kotlin.gradle.targets.js.dsl.ExperimentalWasmDsl
import org.jetbrains.kotlin.gradle.targets.js.webpack.KotlinWebpackConfig

Expand Down Expand Up @@ -68,7 +67,7 @@ kotlin {
common {
group("jsAndWasmJs") {
withJs()
withWasm()
withWasmJs()
}
}
}
Expand All @@ -94,6 +93,15 @@ kotlin {
implementation(kotlin("test-annotations-common"))
implementation(libs.kotlin.coroutines.test)
}

val iosX64AndSimulatorArm64Main by creating {
dependsOn(iosMain.get())
}

val iosX64Main by getting
iosX64Main.dependsOn(iosX64AndSimulatorArm64Main)
val iosSimulatorArm64Main by getting
iosSimulatorArm64Main.dependsOn(iosX64AndSimulatorArm64Main)
}
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,171 @@
package com.shepeliev.webrtckmp.video

import WebRTC.RTCCameraVideoCapturer
import WebRTC.RTCVideoCapturerDelegateProtocol
import com.shepeliev.webrtckmp.CameraVideoCapturerException
import com.shepeliev.webrtckmp.DEFAULT_FRAME_RATE
import com.shepeliev.webrtckmp.DEFAULT_VIDEO_HEIGHT
import com.shepeliev.webrtckmp.DEFAULT_VIDEO_WIDTH
import com.shepeliev.webrtckmp.FacingMode
import com.shepeliev.webrtckmp.MediaTrackConstraints
import com.shepeliev.webrtckmp.value
import kotlinx.cinterop.ExperimentalForeignApi
import kotlinx.cinterop.useContents
import platform.AVFoundation.AVCaptureDevice
import platform.AVFoundation.AVCaptureDeviceFormat
import platform.AVFoundation.AVCaptureDevicePosition
import platform.AVFoundation.AVCaptureDevicePositionBack
import platform.AVFoundation.AVCaptureDevicePositionFront
import platform.AVFoundation.AVFrameRateRange
import platform.AVFoundation.position
import platform.CoreMedia.CMFormatDescriptionGetMediaSubType
import platform.CoreMedia.CMVideoFormatDescriptionGetDimensions
import kotlin.math.abs

@OptIn(ExperimentalForeignApi::class)
internal actual class CameraVideoCapturerController actual constructor(
private val constraints: MediaTrackConstraints,
private val videoCapturerDelegate: RTCVideoCapturerDelegateProtocol
) : VideoCapturerController() {
private var videoCapturer: RTCCameraVideoCapturer? = null
private var position: AVCaptureDevicePosition = AVCaptureDevicePositionBack
private lateinit var device: AVCaptureDevice
private lateinit var format: AVCaptureDeviceFormat
private var fps: Long = -1

actual override fun startCapture() {
if (videoCapturer != null) return
videoCapturer = RTCCameraVideoCapturer(videoCapturerDelegate)
if (!this::device.isInitialized) selectDevice()
selectFormat()
selectFps()

var width: Int? = null
var height: Int? = null
CMVideoFormatDescriptionGetDimensions(format.formatDescription).useContents {
width = this.width
height = this.height
}

settings = settings.copy(
deviceId = device.uniqueID,
facingMode = device.position.toFacingMode(),
width = width,
height = height,
frameRate = fps.toDouble()
)

videoCapturer?.startCaptureWithDevice(device, format, fps)
}

actual override fun stopCapture() {
videoCapturer?.stopCapture()
videoCapturer = null
}

private fun selectDevice() {
position = when (constraints.facingMode?.value) {
FacingMode.User -> AVCaptureDevicePositionFront
FacingMode.Environment -> AVCaptureDevicePositionBack
null -> AVCaptureDevicePositionFront
}

val searchCriteria: (Any?) -> Boolean = when {
constraints.deviceId != null -> {
{ (it as AVCaptureDevice).uniqueID == constraints.deviceId }
}

else -> {
{ (it as AVCaptureDevice).position == position }
}
}

device = RTCCameraVideoCapturer.captureDevices()
.firstOrNull(searchCriteria) as? AVCaptureDevice
?: throw CameraVideoCapturerException.notFound(constraints)

settings = settings.copy(
deviceId = device.uniqueID,
facingMode = device.position.toFacingMode()
)
}

private fun selectFormat() {
val targetWidth = constraints.width?.value ?: DEFAULT_VIDEO_WIDTH
val targetHeight = constraints.height?.value ?: DEFAULT_VIDEO_HEIGHT
val formats = RTCCameraVideoCapturer.supportedFormatsForDevice(device)

format = formats.fold(Pair(Int.MAX_VALUE, null as AVCaptureDeviceFormat?)) { acc, fmt ->
val format = fmt as AVCaptureDeviceFormat
val (currentDiff, currentFormat) = acc

var diff = currentDiff
CMVideoFormatDescriptionGetDimensions(format.formatDescription).useContents {
diff = abs(targetWidth - width) + abs(targetHeight - height)
}
val pixelFormat = CMFormatDescriptionGetMediaSubType(format.formatDescription)
if (diff < currentDiff) {
return@fold Pair(diff, format)
} else if (diff == currentDiff && pixelFormat == videoCapturer!!.preferredOutputPixelFormat()) {
return@fold Pair(currentDiff, format)
}
Pair(0, currentFormat)
}.second ?: throw CameraVideoCapturerException(
"No valid video format for device $device. Requested video frame size: ${targetWidth}x$targetHeight"
)
}

private fun selectFps() {
val requestedFps = constraints.frameRate?.value ?: DEFAULT_FRAME_RATE

val maxSupportedFrameRate = format.videoSupportedFrameRateRanges.fold(0.0) { acc, range ->
val fpsRange = range as AVFrameRateRange
maxOf(acc, fpsRange.maxFrameRate)
}

fps = minOf(maxSupportedFrameRate, requestedFps.toDouble()).toLong()
}

actual fun switchCamera() {
checkNotNull(videoCapturer) { "Video capturing is not started." }
val captureDevices = RTCCameraVideoCapturer.captureDevices()
if (captureDevices.size < 2) {
throw CameraVideoCapturerException("No other camera device found.")
}

stopCapture()
val deviceIndex = captureDevices.indexOfFirst {
(it as AVCaptureDevice).uniqueID == device.uniqueID
}
device = captureDevices[(deviceIndex + 1) % captureDevices.size] as AVCaptureDevice
startCapture()

settings = settings.copy(
deviceId = device.uniqueID,
facingMode = device.position.toFacingMode()
)
}

actual fun switchCamera(deviceId: String) {
checkNotNull(videoCapturer) { "Video capturing is not started." }

stopCapture()
device = RTCCameraVideoCapturer.captureDevices()
.firstOrNull { (it as AVCaptureDevice).uniqueID == deviceId } as? AVCaptureDevice
?: throw CameraVideoCapturerException.notFound(deviceId)
startCapture()

settings = settings.copy(
deviceId = device.uniqueID,
facingMode = device.position.toFacingMode()
)
}

private fun AVCaptureDevicePosition.toFacingMode(): FacingMode? {
return when (this) {
AVCaptureDevicePositionFront -> FacingMode.User
AVCaptureDevicePositionBack -> FacingMode.Environment
else -> null
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ import WebRTC.RTCMediaConstraints
import com.shepeliev.webrtckmp.video.CameraVideoCapturerController
import kotlinx.cinterop.ExperimentalForeignApi
import platform.AVFoundation.AVCaptureDevice
import platform.Foundation.NSBundle
import platform.Foundation.NSFileManager
import platform.Foundation.NSUUID

internal actual val mediaDevices: MediaDevices = MediaDevicesImpl
Expand All @@ -22,8 +24,13 @@ private object MediaDevicesImpl : MediaDevices {
mandatoryConstraints = audioConstraints.toMandatoryMap(),
optionalConstraints = audioConstraints.toOptionalMap()
)
val audioSource = WebRtc.peerConnectionFactory.audioSourceWithConstraints(mediaConstraints)
val track = WebRtc.peerConnectionFactory.audioTrackWithSource(audioSource, NSUUID.UUID().UUIDString())
val audioSource =
WebRtc.peerConnectionFactory.audioSourceWithConstraints(mediaConstraints)

val track = WebRtc.peerConnectionFactory.audioTrackWithSource(
source = audioSource,
trackId = NSUUID.UUID().UUIDString()
)
LocalAudioStreamTrack(track, constraints.audio)
}

Expand Down Expand Up @@ -54,13 +61,30 @@ private object MediaDevicesImpl : MediaDevices {
override suspend fun supportsDisplayMedia(): Boolean = false

override suspend fun enumerateDevices(): List<MediaDeviceInfo> {
return RTCCameraVideoCapturer.captureDevices().map {
val captureDevices = RTCCameraVideoCapturer.captureDevices().map {
val device = it as AVCaptureDevice
MediaDeviceInfo(
deviceId = device.uniqueID,
label = device.localizedName,
kind = MediaDeviceKind.VideoInput
)
}
val fallbackDeviceInfo = getFallbackMediaDeviceInfo()
return fallbackDeviceInfo?.let { captureDevices + it } ?: captureDevices
}

private fun getFallbackMediaDeviceInfo(): MediaDeviceInfo? {
val nameComponents = WebRtc.simulatorCameraFallbackFileName.split(".")
if (nameComponents.size != 2) return null

val path =
NSBundle.mainBundle.pathForResource(nameComponents[0], nameComponents[1]) ?: return null
if (!NSFileManager.defaultManager.fileExistsAtPath(path)) return null

return MediaDeviceInfo(
deviceId = WebRtc.simulatorCameraFallbackFileName,
label = "Simulator Camera",
kind = MediaDeviceKind.VideoInput
)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,11 @@ object WebRtc {
internal val peerConnectionFactory: RTCPeerConnectionFactory
get() = _peerConnectionFactory ?: createPeerConnectionFactory().also { _peerConnectionFactory = it }

/**
* The name of the bundled video file to use as a fallback for the camera in the iOS simulator.
*/
var simulatorCameraFallbackFileName: String = "simulator-camera.mp4"

@Suppress("unused")
fun configurePeerConnectionFactory(loggingSeverity: RTCLoggingSeverity) {
configurePeerConnectionFactoryInternal(loggingSeverity, null, null, null, null)
Expand Down
Loading

0 comments on commit 04d68fd

Please sign in to comment.