diff --git a/README.md b/README.md index 75e8f5a1..0b264fb9 100644 --- a/README.md +++ b/README.md @@ -5,11 +5,11 @@ It supports Android, iOS, JS. Other platforms - PRs are welcome. ## API implementation map - API | Android | iOS | JS/WasmJS - :-: |:------------------:| :-: | :---: - Audio/Video | :white_check_mark: | :white_check_mark: | :white_check_mark: - Data channel | :white_check_mark: | :white_check_mark: | :white_check_mark: - Screen Capture | :white_check_mark: | | :white_check_mark: +| API | Android | iOS | JS/WasmJS | JVM | +|:--------------:|:------------------:|:------------------:|:------------------:|:------------------:| +| Audio/Video | :white_check_mark: | :white_check_mark: | :white_check_mark: | :white_check_mark: | +| Data channel | :white_check_mark: | :white_check_mark: | :white_check_mark: | :white_check_mark: | +| Screen Capture | :white_check_mark: | | :white_check_mark: | :white_check_mark: | ## WebRTC revision Current revision: M125 @@ -34,31 +34,58 @@ the WebRTC SDK using CocoaPods in `build.gradle.kts`: ```kotlin kotlin { - cocoapods { - version = "1.0.0" - summary = "Shared module" - homepage = "not published" - ios.deploymentTarget = "13.0" - - pod("WebRTC-SDK") { - version = "125.6422.05" - moduleName = "WebRTC" - } - - podfile = project.file("../iosApp/Podfile") - - framework { - baseName = "shared" - isStatic = true + cocoapods { + version = "1.0.0" + summary = "Shared module" + homepage = "not published" + ios.deploymentTarget = "13.0" + + pod("WebRTC-SDK") { + version = "125.6422.05" + moduleName = "WebRTC" + } + + podfile = project.file("../iosApp/Podfile") + + framework { + baseName = "shared" + isStatic = true + } + + xcodeConfigurationToNativeBuildType["CUSTOM_DEBUG"] = NativeBuildType.DEBUG + xcodeConfigurationToNativeBuildType["CUSTOM_RELEASE"] = NativeBuildType.RELEASE } - - xcodeConfigurationToNativeBuildType["CUSTOM_DEBUG"] = NativeBuildType.DEBUG - xcodeConfigurationToNativeBuildType["CUSTOM_RELEASE"] = NativeBuildType.RELEASE - } iosX64() iosArm64() iosSimulatorArm64() + jvm() + +} + +dependencies { + // Choose the right architecture for your system + // see https://github.com/devopvoid/webrtc-java for supported platforms + val osName = System.getProperty("os.name").lowercase() + val hostOS = if (osName.contains("mac")) { + "macos" + } else if (osName.contains("linux")) { + "linux" + } else if (osName.contains("windows")) { + "windows" + } else { + throw IllegalStateException("Unsupported OS: $osName") + } + val hostArch = when (val arch = System.getProperty("os.arch").lowercase()) { + "amd64" -> "x86_64" + else -> arch + } + jvmMainImplementation( + group = "dev.onvoid.webrtc", + name = "webrtc-java", + version = "0.8.0", + classifier = "$hostOS-$hostArch" + ) } ``` diff --git a/gradle.properties b/gradle.properties index 521a572d..0e1ab8bf 100644 --- a/gradle.properties +++ b/gradle.properties @@ -2,7 +2,7 @@ org.gradle.caching=true org.gradle.daemon=true org.gradle.parallel=true -org.gradle.jvmargs=-Xmx2g +org.gradle.jvmargs=-Xmx4g # Kotlin kotlin.code.style=official diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 24bd1a7f..bf5c2473 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -12,9 +12,11 @@ androidx-test-runner = "1.6.2" androidx-test-rules = "1.6.1" accompanist-permision = "0.34.0" kermit = "2.0.3" +bouncy-castle = "1.77" kotlin-wrappers = "1.0.0-pre.732" webrtc-android-sdk = "125.6422.05" webrtc-ios-sdk = "125.6422.05" +webrtc-java-sdk = "0.8.0" #Android minSdk = "21" @@ -28,10 +30,12 @@ nexus = "1.3.0" compose-plugin = "1.6.11" [libraries] -webrtc-sdk = { module = "io.github.webrtc-sdk:android", version.ref = "webrtc-android-sdk" } +webrtc-android = { module = "io.github.webrtc-sdk:android", version.ref = "webrtc-android-sdk" } +webrtc-java = { module = "dev.onvoid.webrtc:webrtc-java", version.ref = "webrtc-java-sdk" } kotlin-coroutines = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "kotlin-coroutines" } kotlin-coroutines-android = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-android", version.ref = "kotlin-coroutines" } kotlin-coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "kotlin-coroutines" } +kotlin-coroutines-swing = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-swing", version.ref = "kotlin-coroutines" } androidx-coreKtx = { module = "androidx.core:core-ktx", version.ref = "androidx-core" } androidx-appcompat = { module = "androidx.appcompat:appcompat", version.ref = "androidx-appcompat" } androidx-lifecycle-runtime = { module = "androidx.lifecycle:lifecycle-runtime-ktx", version.ref = "androidx-lifecycle" } @@ -40,6 +44,7 @@ androidx-material = { module = "com.google.android.material:material", version.r androidx-startup = { module = "androidx.startup:startup-runtime", version.ref = "androidx-startup" } accompanist-permissions = { module = "com.google.accompanist:accompanist-permissions", version.ref = "accompanist-permision" } kermit = { module = "co.touchlab:kermit", version.ref = "kermit" } +java-bouncycastle = { module = "org.bouncycastle:bcpkix-jdk18on", version.ref = "bouncy-castle" } androidx-test-core = { module = "androidx.test:core", version.ref = "androidx-test-core" } androidx-test-runner = { module = "androidx.test:runner", version.ref = "androidx-test-runner" } androidx-test-rules = { module = "androidx.test:rules", version.ref = "androidx-test-rules" } diff --git a/sample/README.md b/sample/README.md index 4e3ea2e9..72dfb651 100644 --- a/sample/README.md +++ b/sample/README.md @@ -39,3 +39,9 @@ Open `sample/iosApp/iosApp.xcworkspace` in XCode build and run ```bash ./gradlew sample:composeApp:wasmJsBrowserRun ``` + +### JVM Desktop + +```bash +./gradlew ":sample:composeApp:run" -DmainClass="MainKt" --quiet +``` \ No newline at end of file diff --git a/sample/composeApp/build.gradle.kts b/sample/composeApp/build.gradle.kts index 837f7579..fe601383 100644 --- a/sample/composeApp/build.gradle.kts +++ b/sample/composeApp/build.gradle.kts @@ -1,3 +1,5 @@ +import org.jetbrains.compose.desktop.application.dsl.TargetFormat +import org.jetbrains.kotlin.gradle.plugin.mpp.KotlinNativeTarget import org.jetbrains.kotlin.gradle.ExperimentalKotlinGradlePluginApi import org.jetbrains.kotlin.gradle.ExperimentalWasmDsl import org.jetbrains.kotlin.gradle.dsl.JvmTarget @@ -55,6 +57,12 @@ kotlin { } } + jvm { + compilations.all { + kotlinOptions.jvmTarget = "17" + } + } + @OptIn(ExperimentalWasmDsl::class) wasmJs { moduleName = "composeApp" @@ -99,6 +107,24 @@ kotlin { implementation(libs.kotlin.wrappers.reactDom) implementation(libs.kotlin.wrappers.emotion) } + + jvmMain.dependencies { + implementation(compose.desktop.currentOs) + implementation(libs.kotlin.coroutines.swing) + + val osName = System.getProperty("os.name") + val hostOS = when { + osName == "Mac OS X" -> "macos" + osName.startsWith("Win") -> "windows" + osName.startsWith("Linux") -> "linux" + else -> error("Unsupported OS: $osName") + } + val hostArch = when (val arch = System.getProperty("os.arch").lowercase()) { + "amd64" -> "x86_64" + else -> arch + } + implementation("${libs.webrtc.java.get()}:$hostOS-$hostArch") + } } } @@ -135,3 +161,14 @@ android { debugImplementation(compose.uiTooling) } } + +compose.desktop { + application { + mainClass = "MainKt" + nativeDistributions { + targetFormats(TargetFormat.Dmg, TargetFormat.Msi, TargetFormat.Deb) + packageName = "KMPTemplate" + packageVersion = "1.0.0" + } + } +} diff --git a/sample/composeApp/composeApp.podspec b/sample/composeApp/composeApp.podspec index 14bdc76e..92f0ac47 100644 --- a/sample/composeApp/composeApp.podspec +++ b/sample/composeApp/composeApp.podspec @@ -50,5 +50,5 @@ Pod::Spec.new do |spec| SCRIPT } ] - spec.resources = ['build/compose/cocoapods/compose-resources'] + spec.resources = ['build\compose\cocoapods\compose-resources'] end \ No newline at end of file diff --git a/sample/composeApp/src/androidMain/kotlin/DeviceSelectButton.android.kt b/sample/composeApp/src/androidMain/kotlin/DeviceSelectButton.android.kt new file mode 100644 index 00000000..cedc4e9f --- /dev/null +++ b/sample/composeApp/src/androidMain/kotlin/DeviceSelectButton.android.kt @@ -0,0 +1,25 @@ +import androidx.compose.material.Button +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Modifier +import com.shepeliev.webrtckmp.MediaStream +import com.shepeliev.webrtckmp.videoTracks +import kotlinx.coroutines.launch + +@Composable +actual fun DeviceSelectButton( + modifier: Modifier, + localStream: MediaStream +) { + val scope = rememberCoroutineScope() + + Button( + onClick = { + scope.launch { localStream.videoTracks.firstOrNull()?.switchCamera() } + }, + modifier = modifier, + ) { + Text("Switch Camera") + } +} \ No newline at end of file diff --git a/sample/composeApp/src/androidMain/kotlin/StartButton.android.kt b/sample/composeApp/src/androidMain/kotlin/StartButton.android.kt index c22227c5..f40cb8a1 100644 --- a/sample/composeApp/src/androidMain/kotlin/StartButton.android.kt +++ b/sample/composeApp/src/androidMain/kotlin/StartButton.android.kt @@ -6,16 +6,31 @@ import android.provider.Settings import androidx.compose.material.Button import androidx.compose.material.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import androidx.core.content.edit import com.google.accompanist.permissions.ExperimentalPermissionsApi import com.google.accompanist.permissions.rememberMultiplePermissionsState +import com.shepeliev.webrtckmp.MediaDevices +import com.shepeliev.webrtckmp.MediaStream +import kotlinx.coroutines.launch @OptIn(ExperimentalPermissionsApi::class) @Composable -actual fun StartButton(onClick: () -> Unit, modifier: Modifier) { +actual fun StartButton(setLocalStream: (MediaStream?) -> Unit, modifier: Modifier) { val context = LocalContext.current + val scope = rememberCoroutineScope() + + val openMediaStreams = remember { + { + scope.launch { + val stream = MediaDevices.getUserMedia(audio = true, video = true) + setLocalStream(stream) + } + } + } val permissions = rememberMultiplePermissionsState( listOf( @@ -24,13 +39,13 @@ actual fun StartButton(onClick: () -> Unit, modifier: Modifier) { ) ) { if (it.all { (_, granted) -> granted }) { - onClick() + openMediaStreams() } } Button(onClick = { if (permissions.allPermissionsGranted) { - onClick() + openMediaStreams() } else { val prefs = PreferenceManager.getDefaultSharedPreferences(context) val permissionsRequested = prefs.getBoolean("permissionsRequested", false) diff --git a/sample/composeApp/src/commonMain/kotlin/App.kt b/sample/composeApp/src/commonMain/kotlin/App.kt index 424940ba..94ba43d6 100644 --- a/sample/composeApp/src/commonMain/kotlin/App.kt +++ b/sample/composeApp/src/commonMain/kotlin/App.kt @@ -17,12 +17,10 @@ import androidx.compose.ui.unit.dp import co.touchlab.kermit.Logger import co.touchlab.kermit.platformLogWriter import com.shepeliev.webrtckmp.AudioStreamTrack -import com.shepeliev.webrtckmp.MediaDevices import com.shepeliev.webrtckmp.MediaStream import com.shepeliev.webrtckmp.PeerConnection import com.shepeliev.webrtckmp.VideoStreamTrack import com.shepeliev.webrtckmp.videoTracks -import kotlinx.coroutines.launch import org.jetbrains.compose.ui.tooling.preview.Preview @Composable @@ -84,12 +82,7 @@ fun App() { Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { if (localStream == null) { - StartButton(onClick = { - scope.launch { - val stream = MediaDevices.getUserMedia(audio = true, video = true) - setLocalStream(stream) - } - }) + StartButton(setLocalStream = setLocalStream) } else { StopButton( onClick = { @@ -102,10 +95,8 @@ fun App() { } ) - SwitchCameraButton( - onClick = { - scope.launch { localStream.videoTracks.firstOrNull()?.switchCamera() } - } + DeviceSelectButton( + localStream = localStream, ) } if (peerConnections == null) { @@ -125,15 +116,16 @@ fun App() { } } + @Composable -private fun CallButton(onClick: () -> Unit, modifier: Modifier = Modifier) { +internal fun CallButton(onClick: () -> Unit, modifier: Modifier = Modifier) { Button(onClick, modifier = modifier) { Text("Call") } } @Composable -private fun HangupButton(onClick: () -> Unit, modifier: Modifier = Modifier) { +internal fun HangupButton(onClick: () -> Unit, modifier: Modifier = Modifier) { Button(onClick, modifier = modifier) { Text("Hangup") } @@ -147,7 +139,7 @@ private fun SwitchCameraButton(onClick: () -> Unit, modifier: Modifier = Modifie } @Composable -private fun StopButton(onClick: () -> Unit, modifier: Modifier = Modifier) { +internal fun StopButton(onClick: () -> Unit, modifier: Modifier = Modifier) { Button(onClick = onClick, modifier = modifier) { Text("Stop") } diff --git a/sample/composeApp/src/commonMain/kotlin/DeviceSelectButton.kt b/sample/composeApp/src/commonMain/kotlin/DeviceSelectButton.kt new file mode 100644 index 00000000..6f6def10 --- /dev/null +++ b/sample/composeApp/src/commonMain/kotlin/DeviceSelectButton.kt @@ -0,0 +1,9 @@ +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import com.shepeliev.webrtckmp.MediaStream + +@Composable +expect fun DeviceSelectButton( + modifier: Modifier = Modifier, + localStream: MediaStream, +) \ No newline at end of file diff --git a/sample/composeApp/src/commonMain/kotlin/StartButton.kt b/sample/composeApp/src/commonMain/kotlin/StartButton.kt index 331c7c98..1787a9e8 100644 --- a/sample/composeApp/src/commonMain/kotlin/StartButton.kt +++ b/sample/composeApp/src/commonMain/kotlin/StartButton.kt @@ -1,5 +1,6 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier +import com.shepeliev.webrtckmp.MediaStream @Composable -expect fun StartButton(onClick: () -> Unit, modifier: Modifier = Modifier) +expect fun StartButton(setLocalStream: (MediaStream?) -> Unit, modifier: Modifier = Modifier) diff --git a/sample/composeApp/src/iosMain/kotlin/DeviceSelectButton.ios.kt b/sample/composeApp/src/iosMain/kotlin/DeviceSelectButton.ios.kt new file mode 100644 index 00000000..cedc4e9f --- /dev/null +++ b/sample/composeApp/src/iosMain/kotlin/DeviceSelectButton.ios.kt @@ -0,0 +1,25 @@ +import androidx.compose.material.Button +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Modifier +import com.shepeliev.webrtckmp.MediaStream +import com.shepeliev.webrtckmp.videoTracks +import kotlinx.coroutines.launch + +@Composable +actual fun DeviceSelectButton( + modifier: Modifier, + localStream: MediaStream +) { + val scope = rememberCoroutineScope() + + Button( + onClick = { + scope.launch { localStream.videoTracks.firstOrNull()?.switchCamera() } + }, + modifier = modifier, + ) { + Text("Switch Camera") + } +} \ No newline at end of file diff --git a/sample/composeApp/src/iosMain/kotlin/StartButton.ios.kt b/sample/composeApp/src/iosMain/kotlin/StartButton.ios.kt index 62dc1b82..f551ef47 100644 --- a/sample/composeApp/src/iosMain/kotlin/StartButton.ios.kt +++ b/sample/composeApp/src/iosMain/kotlin/StartButton.ios.kt @@ -1,11 +1,30 @@ import androidx.compose.material.Button import androidx.compose.material.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Modifier +import com.shepeliev.webrtckmp.MediaDevices +import com.shepeliev.webrtckmp.MediaStream +import kotlinx.coroutines.launch @Composable -actual fun StartButton(onClick: () -> Unit, modifier: Modifier) { - Button(onClick = onClick, modifier = modifier) { +actual fun StartButton(setLocalStream: (MediaStream?) -> Unit, modifier: Modifier) { + val scope = rememberCoroutineScope() + + val openMediaStreams = remember<() -> Unit> { + { + scope.launch { + val stream = MediaDevices.getUserMedia(audio = true, video = true) + setLocalStream(stream) + } + } + } + + Button( + onClick = openMediaStreams, + modifier = modifier, + ) { Text("Start") } } diff --git a/sample/composeApp/src/jsMain/kotlin/DeviceSelectButton.js.kt b/sample/composeApp/src/jsMain/kotlin/DeviceSelectButton.js.kt new file mode 100644 index 00000000..642bf35c --- /dev/null +++ b/sample/composeApp/src/jsMain/kotlin/DeviceSelectButton.js.kt @@ -0,0 +1,11 @@ +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import com.shepeliev.webrtckmp.MediaStream + +@Composable +actual fun DeviceSelectButton( + modifier: Modifier, + localStream: MediaStream +) { + // NOOP +} \ No newline at end of file diff --git a/sample/composeApp/src/jsMain/kotlin/StartButton.js.kt b/sample/composeApp/src/jsMain/kotlin/StartButton.js.kt index 56a0aa63..f866c408 100644 --- a/sample/composeApp/src/jsMain/kotlin/StartButton.js.kt +++ b/sample/composeApp/src/jsMain/kotlin/StartButton.js.kt @@ -1,7 +1,8 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier +import com.shepeliev.webrtckmp.MediaStream @Composable -actual fun StartButton(onClick: () -> Unit, modifier: Modifier) { +actual fun StartButton(setLocalStream: (MediaStream?) -> Unit, modifier: Modifier) { // Dummy actual for JS } diff --git a/sample/composeApp/src/jvmMain/kotlin/DeviceSelectButton.jvm.kt b/sample/composeApp/src/jvmMain/kotlin/DeviceSelectButton.jvm.kt new file mode 100644 index 00000000..5f82e859 --- /dev/null +++ b/sample/composeApp/src/jvmMain/kotlin/DeviceSelectButton.jvm.kt @@ -0,0 +1,55 @@ +import androidx.compose.material.Button +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +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 com.shepeliev.webrtckmp.MediaStream +import com.shepeliev.webrtckmp.MediaTrackConstraints +import com.shepeliev.webrtckmp.WebRtc +import com.shepeliev.webrtckmp.videoTracks +import kotlinx.coroutines.launch + +@Composable +actual fun DeviceSelectButton( + modifier: Modifier, + localStream: MediaStream, +) { + val scope = rememberCoroutineScope() + var showDeviceSelect by remember { mutableStateOf(false) } + + if (showDeviceSelect) { + SelectDevicesDialog( + onSelected = { camera, microphone, speaker -> + camera?.let { + scope.launch { + localStream.videoTracks.firstOrNull()?.switchCamera( + deviceId = it.deviceId, + ) + } + } + microphone?.let { + WebRtc.setAudioInputDevice(it) + } + speaker?.let { + WebRtc.setAudioOutputDevice(it) + } + showDeviceSelect = false + }, + onDismiss = { + showDeviceSelect = false + } + ) + } + + Button( + onClick = { + showDeviceSelect = true + } + ) { + Text("Input/Output") + } +} \ No newline at end of file diff --git a/sample/composeApp/src/jvmMain/kotlin/DeviceSelectDialog.kt b/sample/composeApp/src/jvmMain/kotlin/DeviceSelectDialog.kt new file mode 100644 index 00000000..15594be8 --- /dev/null +++ b/sample/composeApp/src/jvmMain/kotlin/DeviceSelectDialog.kt @@ -0,0 +1,174 @@ +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.material.AlertDialog +import androidx.compose.material.Button +import androidx.compose.material.CircularProgressIndicator +import androidx.compose.material.DropdownMenu +import androidx.compose.material.DropdownMenuItem +import androidx.compose.material.Surface +import androidx.compose.material.Text +import androidx.compose.material.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.window.Dialog +import com.shepeliev.webrtckmp.MediaDeviceInfo +import com.shepeliev.webrtckmp.MediaDeviceKind +import com.shepeliev.webrtckmp.MediaDevices +import com.shepeliev.webrtckmp.WebRtc + +@Composable +internal fun SelectDevicesDialog( + modifier: Modifier = Modifier, + onSelected: (camera: MediaDeviceInfo?, microphone: MediaDeviceInfo?, speaker: MediaDeviceInfo?) -> Unit, + onDismiss: () -> Unit, +) { + var camera: MediaDeviceInfo? by remember { mutableStateOf(null) } + var microphone: MediaDeviceInfo? by remember { mutableStateOf(null) } + var speaker: MediaDeviceInfo? by remember { mutableStateOf(WebRtc.getDefaultAudioOutput()) } + + var cameraList: Set by remember { mutableStateOf(emptySet()) } + var microphoneList: Set by remember { mutableStateOf(emptySet()) } + var speakerList: Set by remember { mutableStateOf(emptySet()) } + + suspend fun refreshDevices() { + val devices = MediaDevices.enumerateDevices() + cameraList = devices.filter { it.kind == MediaDeviceKind.VideoInput }.toSet() + microphoneList = devices.filter { it.kind == MediaDeviceKind.AudioInput }.toSet() + speakerList = devices.filter { it.kind == MediaDeviceKind.AudioOutput }.toSet() + + if (camera == null || !cameraList.any { it.deviceId == camera?.deviceId }) { + camera = cameraList.firstOrNull() + } + + if (microphone == null || !microphoneList.any { it.deviceId == microphone?.deviceId }) { + microphone = microphoneList.firstOrNull() + } + + if (speaker == null || !speakerList.any { it.deviceId == speaker?.deviceId }) { + speaker = WebRtc.getDefaultAudioOutput() + } + } + + LaunchedEffect(Unit) { + refreshDevices() + } + + Dialog( + onDismissRequest = onDismiss, + ) { + Surface{ + Column( + modifier = modifier, + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Text("Select Camera") + DeviceSelector( + current = camera, + list = cameraList, + ) { + camera = it + } + } + + Column( + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Text("Select Microphone") + DeviceSelector( + current = microphone, + list = microphoneList, + ) { device -> + if (device != null) { + microphone = device + WebRtc.setAudioInputDevice(device) + } + } + } + + Column( + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Text("Select Speaker") + DeviceSelector( + current = speaker, + list = speakerList, + ) { device -> + if (device != null) { + speaker = device + WebRtc.setAudioOutputDevice(device) + } + } + } + + Button( + onClick = { + onSelected( + camera, + microphone, + speaker, + ) + } + ) { + Text("Select") + } + + TextButton( + onClick = onDismiss, + ) { + Text("Cancel") + } + } + } + } +} + +@Composable +private fun DeviceSelector( + modifier: Modifier = Modifier, + list: Set?, + current: MediaDeviceInfo?, + allowSelectNone: Boolean = false, + onSelected: (MediaDeviceInfo?) -> Unit, +) { + var isOpen by remember { mutableStateOf(false) } + list?.let { devices -> + TextButton( + modifier = modifier, + onClick = { + isOpen = true + } + ) { + Text(current?.label ?: "None") + } + DropdownMenu( + expanded = isOpen, + onDismissRequest = { isOpen = false }) { + if (allowSelectNone) { + DropdownMenuItem(onClick = { + onSelected(null) + isOpen = false + }) { + Text(text = "None") + } + } + + devices.forEach { device -> + DropdownMenuItem(onClick = { + onSelected(device) + isOpen = false + }) { + Text(text = device.label) + } + } + } + } ?: CircularProgressIndicator() +} \ No newline at end of file diff --git a/sample/composeApp/src/jvmMain/kotlin/Main.kt b/sample/composeApp/src/jvmMain/kotlin/Main.kt new file mode 100644 index 00000000..7e372924 --- /dev/null +++ b/sample/composeApp/src/jvmMain/kotlin/Main.kt @@ -0,0 +1,62 @@ +import androidx.compose.foundation.layout.padding +import androidx.compose.material.Text +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.compose.ui.ExperimentalComposeUiApi +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.LocalWindowExceptionHandlerFactory +import androidx.compose.ui.window.Window +import androidx.compose.ui.window.WindowExceptionHandler +import androidx.compose.ui.window.WindowExceptionHandlerFactory +import androidx.compose.ui.window.WindowState +import androidx.compose.ui.window.application +import androidx.compose.ui.window.singleWindowApplication +import com.shepeliev.webrtckmp.WebRtc +import dev.onvoid.webrtc.logging.Logging +import java.awt.event.WindowEvent +import kotlin.system.exitProcess + + +@OptIn(ExperimentalComposeUiApi::class) +fun main() { + var lastError: Throwable? by mutableStateOf(null) + + WebRtc.configureBuilder { + loggingSeverity = Logging.Severity.VERBOSE + } + + application(exitProcessOnExit = false) { + System.setProperty("compose.interop.blending", "true") + + CompositionLocalProvider( + LocalWindowExceptionHandlerFactory provides WindowExceptionHandlerFactory { window -> + WindowExceptionHandler { + lastError = it + window.dispatchEvent(WindowEvent(window, WindowEvent.WINDOW_CLOSING)) + } + } + ) { + Window(onCloseRequest = ::exitApplication) { + App() + } + } + } + + if (lastError != null) { + lastError?.printStackTrace() + singleWindowApplication( + state = WindowState(width = 200.dp, height = Dp.Unspecified), + exitProcessOnExit = false + ) { + Text(lastError?.message ?: "Unknown error", Modifier.padding(8.dp)) + } + + exitProcess(1) + } else { + exitProcess(0) + } +} diff --git a/sample/composeApp/src/jvmMain/kotlin/StartButton.jvm.kt b/sample/composeApp/src/jvmMain/kotlin/StartButton.jvm.kt new file mode 100644 index 00000000..e5659c22 --- /dev/null +++ b/sample/composeApp/src/jvmMain/kotlin/StartButton.jvm.kt @@ -0,0 +1,62 @@ +import androidx.compose.material.Button +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +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 com.shepeliev.webrtckmp.MediaDevices +import com.shepeliev.webrtckmp.MediaStream +import com.shepeliev.webrtckmp.WebRtc +import kotlinx.coroutines.launch + +@Composable +actual fun StartButton( + setLocalStream: (MediaStream?) -> Unit, + modifier: Modifier, +) { + val scope = rememberCoroutineScope() + var showDeviceSelect by remember { mutableStateOf(false) } + + // TODO permissions + + if (showDeviceSelect) { + SelectDevicesDialog( + onSelected = { camera, microphone, speaker -> + scope.launch { + val stream = MediaDevices.getUserMedia { + camera?.let { + video { + deviceId(it.deviceId) + } + } + microphone?.let { + audio { + deviceId(it.deviceId) + } + } + + speaker?.let { + WebRtc.setAudioOutputDevice(it) + } + } + setLocalStream(stream) + showDeviceSelect = false + } + }, + onDismiss = { + showDeviceSelect = false + } + ) + } + + Button( + onClick = { + showDeviceSelect = true + } + ) { + Text("Start") + } +} diff --git a/sample/composeApp/src/jvmMain/kotlin/Video.jvm.kt b/sample/composeApp/src/jvmMain/kotlin/Video.jvm.kt new file mode 100644 index 00000000..aa9c5cc5 --- /dev/null +++ b/sample/composeApp/src/jvmMain/kotlin/Video.jvm.kt @@ -0,0 +1,125 @@ +import androidx.compose.foundation.Canvas +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.collectAsState +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.asComposeImageBitmap +import androidx.compose.ui.unit.IntSize +import com.shepeliev.webrtckmp.AudioStreamTrack +import com.shepeliev.webrtckmp.VideoStreamTrack +import dev.onvoid.webrtc.media.FourCC +import dev.onvoid.webrtc.media.video.VideoBufferConverter +import dev.onvoid.webrtc.media.video.VideoFrame +import dev.onvoid.webrtc.media.video.VideoTrackSink +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import org.jetbrains.skia.Bitmap +import org.jetbrains.skia.ColorAlphaType +import org.jetbrains.skia.ColorType +import org.jetbrains.skia.ImageInfo +import java.nio.ByteBuffer + + +@Composable +actual fun Video( + videoTrack: VideoStreamTrack, + modifier: Modifier, audioTrack: AudioStreamTrack?, +) { + val renderer = remember(videoTrack) { VideoRenderer(track = videoTrack) } + + DisposableEffect(videoTrack) { + renderer.start() + + onDispose { + renderer.stop() + } + } + + val image = renderer.image.collectAsState() + + BoxWithConstraints( + modifier = modifier, + ) { + val constraints = this.constraints + Canvas(modifier = Modifier.fillMaxSize()) { + drawRoundRect( + color = Color.Black, + size = Size( + width = constraints.maxWidth.toFloat(), + height = constraints.maxHeight.toFloat(), + ) + ) + image.value?.let { + drawImage( + image = it.asComposeImageBitmap(), + dstSize = IntSize( + width = constraints.maxWidth, + height = constraints.maxHeight, + ), + ) + } + } + } +} + +private class VideoRenderer( + private val track: VideoStreamTrack, +) : VideoTrackSink { + + private val _image = MutableStateFlow(null) + val image = _image.asStateFlow() + private var byteBuffer: ByteBuffer? = null + private var frameBuffer: Bitmap? = null + + fun start() { + track.addSink(this) + track.enabled = true + } + + fun stop() { + track.removeSink(this) + _image.value = null + byteBuffer = null + frameBuffer = null + } + + override fun onVideoFrame(frame: VideoFrame) { + try { + frame.retain() + + val buffer = frame.buffer + val width = buffer.width + val height = buffer.height + + if (frameBuffer == null || frameBuffer?.width != width || frameBuffer?.height != height) { + byteBuffer = ByteBuffer.allocate(width * height * 4) + frameBuffer = Bitmap().apply { + allocPixels( + ImageInfo( + width = width, + height = height, + colorType = ColorType.RGBA_8888, + alphaType = ColorAlphaType.OPAQUE + ) + ) + } + } + + byteBuffer?.let { + VideoBufferConverter.convertFromI420(buffer, it, FourCC.ABGR) + frameBuffer?.installPixels(it.array()) + _image.value = frameBuffer?.makeClone() + } + } catch (ex: Exception) { + println(ex) + ex.printStackTrace() + } finally { + frame.release() + } + } +} \ No newline at end of file diff --git a/sample/composeApp/src/wasmJsMain/kotlin/DeviceSelectButton.wasmJs.kt b/sample/composeApp/src/wasmJsMain/kotlin/DeviceSelectButton.wasmJs.kt new file mode 100644 index 00000000..642bf35c --- /dev/null +++ b/sample/composeApp/src/wasmJsMain/kotlin/DeviceSelectButton.wasmJs.kt @@ -0,0 +1,11 @@ +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import com.shepeliev.webrtckmp.MediaStream + +@Composable +actual fun DeviceSelectButton( + modifier: Modifier, + localStream: MediaStream +) { + // NOOP +} \ No newline at end of file diff --git a/sample/composeApp/src/wasmJsMain/kotlin/StartButton.wasmJs.kt b/sample/composeApp/src/wasmJsMain/kotlin/StartButton.wasmJs.kt index 62dc1b82..c0b1c224 100644 --- a/sample/composeApp/src/wasmJsMain/kotlin/StartButton.wasmJs.kt +++ b/sample/composeApp/src/wasmJsMain/kotlin/StartButton.wasmJs.kt @@ -1,11 +1,25 @@ import androidx.compose.material.Button import androidx.compose.material.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Modifier +import com.shepeliev.webrtckmp.MediaDevices +import com.shepeliev.webrtckmp.MediaStream +import kotlinx.coroutines.launch @Composable -actual fun StartButton(onClick: () -> Unit, modifier: Modifier) { - Button(onClick = onClick, modifier = modifier) { +actual fun StartButton(setLocalStream: (MediaStream?) -> Unit, modifier: Modifier) { + val scope = rememberCoroutineScope() + + Button( + onClick = { + scope.launch { + val stream = MediaDevices.getUserMedia(audio = true, video = true) + setLocalStream(stream) + } + }, + modifier = modifier, + ) { Text("Start") } } diff --git a/settings.gradle.kts b/settings.gradle.kts index d8fa1376..1fc8249f 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -16,5 +16,6 @@ pluginManagement { } rootProject.name = "webrtc-kmp" + include(":webrtc-kmp") include(":sample:composeApp") diff --git a/webrtc-kmp/build.gradle.kts b/webrtc-kmp/build.gradle.kts index 8fad17da..750f6795 100644 --- a/webrtc-kmp/build.gradle.kts +++ b/webrtc-kmp/build.gradle.kts @@ -50,7 +50,7 @@ kotlin { jvmTarget = JvmTarget.JVM_1_8 } } - + jvm() iosX64() iosArm64() iosSimulatorArm64() @@ -91,7 +91,7 @@ kotlin { } androidMain.dependencies { - api(libs.webrtc.sdk) + api(libs.webrtc.android) implementation(libs.kotlin.coroutines.android) implementation(libs.androidx.coreKtx) implementation(libs.androidx.startup) @@ -107,6 +107,25 @@ kotlin { implementation(libs.kotlin.coroutines.test) } + jvmMain.dependencies { + api(libs.webrtc.java) + implementation(libs.java.bouncycastle) + } + jvmTest.dependencies { + val osName = System.getProperty("os.name") + val hostOS = when { + osName == "Mac OS X" -> "macos" + osName.startsWith("Win") -> "windows" + osName.startsWith("Linux") -> "linux" + else -> error("Unsupported OS: $osName") + } + val hostArch = when (val arch = System.getProperty("os.arch").lowercase()) { + "amd64" -> "x86_64" + else -> arch + } + implementation("${libs.webrtc.java.get()}:$hostOS-$hostArch") + } + val iosX64AndSimulatorArm64Main by creating { dependsOn(iosMain.get()) } diff --git a/webrtc-kmp/src/androidInstrumentedTest/kotlin/com/shepeliev/webrtckmp/TestUtils.android.kt b/webrtc-kmp/src/androidInstrumentedTest/kotlin/com/shepeliev/webrtckmp/TestUtils.android.kt new file mode 100644 index 00000000..b712b925 --- /dev/null +++ b/webrtc-kmp/src/androidInstrumentedTest/kotlin/com/shepeliev/webrtckmp/TestUtils.android.kt @@ -0,0 +1,4 @@ +package com.shepeliev.webrtckmp + +actual fun setupMocks() { +} \ No newline at end of file diff --git a/webrtc-kmp/src/androidMain/kotlin/com/shepeliev/webrtckmp/MediaStreamTrackImpl.kt b/webrtc-kmp/src/androidMain/kotlin/com/shepeliev/webrtckmp/MediaStreamTrackImpl.kt index 1d32eeae..25066692 100644 --- a/webrtc-kmp/src/androidMain/kotlin/com/shepeliev/webrtckmp/MediaStreamTrackImpl.kt +++ b/webrtc-kmp/src/androidMain/kotlin/com/shepeliev/webrtckmp/MediaStreamTrackImpl.kt @@ -64,7 +64,7 @@ internal abstract class MediaStreamTrackImpl( private fun getInitialState(): MediaStreamTrackState { return when (checkNotNull(android.state())) { AndroidMediaStreamTrack.State.LIVE -> MediaStreamTrackState.Live(muted = false) - AndroidMediaStreamTrack.State.ENDED -> MediaStreamTrackState.Live(muted = false) + AndroidMediaStreamTrack.State.ENDED -> MediaStreamTrackState.Ended(muted = false) } } } diff --git a/webrtc-kmp/src/androidUnitTest/kotlin/com/shepeliev/webrtckmp/TestUtils.kt b/webrtc-kmp/src/androidUnitTest/kotlin/com/shepeliev/webrtckmp/TestUtils.kt index 90009785..9576860f 100644 --- a/webrtc-kmp/src/androidUnitTest/kotlin/com/shepeliev/webrtckmp/TestUtils.kt +++ b/webrtc-kmp/src/androidUnitTest/kotlin/com/shepeliev/webrtckmp/TestUtils.kt @@ -1,17 +1,3 @@ package com.shepeliev.webrtckmp -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.coroutineScope -import kotlinx.coroutines.runBlocking -import kotlinx.coroutines.withTimeout - -actual fun runTest( - timeout: Long, - block: suspend CoroutineScope.() -> Unit -) { - runBlocking { - withTimeout(timeout) { - coroutineScope { block() } - } - } -} +actual fun setupMocks() {} diff --git a/webrtc-kmp/src/commonMain/kotlin/com/shepeliev/webrtckmp/MediaDeviceInfo.kt b/webrtc-kmp/src/commonMain/kotlin/com/shepeliev/webrtckmp/MediaDeviceInfo.kt index a6dc0a88..43b37ce5 100644 --- a/webrtc-kmp/src/commonMain/kotlin/com/shepeliev/webrtckmp/MediaDeviceInfo.kt +++ b/webrtc-kmp/src/commonMain/kotlin/com/shepeliev/webrtckmp/MediaDeviceInfo.kt @@ -6,4 +6,4 @@ data class MediaDeviceInfo( val kind: MediaDeviceKind, ) -enum class MediaDeviceKind { VideoInput, AudioInput } +enum class MediaDeviceKind { VideoInput, AudioInput, AudioOutput } diff --git a/webrtc-kmp/src/commonTest/kotlin/com/shepeliev/webrtckmp/DataChannelTest.kt b/webrtc-kmp/src/commonTest/kotlin/com/shepeliev/webrtckmp/DataChannelTest.kt index 9b337b6b..cbcaad29 100644 --- a/webrtc-kmp/src/commonTest/kotlin/com/shepeliev/webrtckmp/DataChannelTest.kt +++ b/webrtc-kmp/src/commonTest/kotlin/com/shepeliev/webrtckmp/DataChannelTest.kt @@ -3,14 +3,11 @@ package com.shepeliev.webrtckmp import kotlinx.coroutines.CoroutineStart import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.Job import kotlinx.coroutines.async import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.onEach -import kotlinx.coroutines.flow.take -import kotlinx.coroutines.launch import kotlinx.coroutines.test.StandardTestDispatcher import kotlinx.coroutines.test.TestScope import kotlinx.coroutines.test.resetMain @@ -29,6 +26,7 @@ class DataChannelTest { @BeforeTest fun setUp() { + setupMocks() Dispatchers.setMain(StandardTestDispatcher(scope.testScheduler)) } diff --git a/webrtc-kmp/src/commonTest/kotlin/com/shepeliev/webrtckmp/IceCandidateTest.kt b/webrtc-kmp/src/commonTest/kotlin/com/shepeliev/webrtckmp/IceCandidateTest.kt index 8b8410a6..103e4021 100644 --- a/webrtc-kmp/src/commonTest/kotlin/com/shepeliev/webrtckmp/IceCandidateTest.kt +++ b/webrtc-kmp/src/commonTest/kotlin/com/shepeliev/webrtckmp/IceCandidateTest.kt @@ -1,10 +1,16 @@ package com.shepeliev.webrtckmp +import kotlin.test.BeforeTest import kotlin.test.Test import kotlin.test.assertEquals open class IceCandidateTest { + @BeforeTest + fun setup() { + setupMocks() + } + @Test fun should_be_constructed_successfully() { val sdpMid = "sdpMid" diff --git a/webrtc-kmp/src/commonTest/kotlin/com/shepeliev/webrtckmp/MediaDevicesTests.kt b/webrtc-kmp/src/commonTest/kotlin/com/shepeliev/webrtckmp/MediaDevicesTests.kt new file mode 100644 index 00000000..cc0c39fa --- /dev/null +++ b/webrtc-kmp/src/commonTest/kotlin/com/shepeliev/webrtckmp/MediaDevicesTests.kt @@ -0,0 +1,41 @@ +package com.shepeliev.webrtckmp + +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import kotlin.test.BeforeTest +import kotlin.test.Ignore +import kotlin.test.Test +import kotlin.test.assertTrue +import kotlin.time.Duration.Companion.seconds + +@OptIn(ExperimentalCoroutinesApi::class) +@Ignore // for local tests only +class MediaDevicesTests { + + private val scope = TestScope() + + @BeforeTest + fun setup() { + setupMocks() + Dispatchers.setMain(StandardTestDispatcher(scope.testScheduler)) + } + @Test + fun enumerateDevices() = runTest(timeout = 5.seconds) { + val devices = MediaDevices.enumerateDevices() + assertTrue(devices.isNotEmpty()) + } + + @Test + fun getUserMedia() = runTest(timeout = 5.seconds) { + val mediaStream = MediaDevices.getUserMedia { + audio() + video() + } + + mediaStream.release() + } +} diff --git a/webrtc-kmp/src/commonTest/kotlin/com/shepeliev/webrtckmp/MediaStreamConstraintsBuilderTest.kt b/webrtc-kmp/src/commonTest/kotlin/com/shepeliev/webrtckmp/MediaStreamConstraintsBuilderTest.kt index cdcb7777..d4c76537 100644 --- a/webrtc-kmp/src/commonTest/kotlin/com/shepeliev/webrtckmp/MediaStreamConstraintsBuilderTest.kt +++ b/webrtc-kmp/src/commonTest/kotlin/com/shepeliev/webrtckmp/MediaStreamConstraintsBuilderTest.kt @@ -1,9 +1,16 @@ package com.shepeliev.webrtckmp +import kotlin.test.BeforeTest import kotlin.test.Test import kotlin.test.assertEquals class MediaStreamConstraintsBuilderTest { + + @BeforeTest + fun setup() { + setupMocks() + } + @Test fun should_work() { val builder = MediaStreamConstraintsBuilder() diff --git a/webrtc-kmp/src/commonTest/kotlin/com/shepeliev/webrtckmp/PeerConnectionTest.kt b/webrtc-kmp/src/commonTest/kotlin/com/shepeliev/webrtckmp/PeerConnectionTest.kt index 7f13b013..1a5c7879 100644 --- a/webrtc-kmp/src/commonTest/kotlin/com/shepeliev/webrtckmp/PeerConnectionTest.kt +++ b/webrtc-kmp/src/commonTest/kotlin/com/shepeliev/webrtckmp/PeerConnectionTest.kt @@ -28,6 +28,7 @@ class PeerConnectionTest { @BeforeTest fun setUp() { + setupMocks() Dispatchers.setMain(StandardTestDispatcher(scope.testScheduler)) } diff --git a/webrtc-kmp/src/commonTest/kotlin/com/shepeliev/webrtckmp/TestUtils.kt b/webrtc-kmp/src/commonTest/kotlin/com/shepeliev/webrtckmp/TestUtils.kt new file mode 100644 index 00000000..fccd5ef3 --- /dev/null +++ b/webrtc-kmp/src/commonTest/kotlin/com/shepeliev/webrtckmp/TestUtils.kt @@ -0,0 +1,3 @@ +package com.shepeliev.webrtckmp + +expect fun setupMocks() \ No newline at end of file diff --git a/webrtc-kmp/src/iosMain/kotlin/com/shepeliev/webrtckmp/IosCompletionHandler.kt b/webrtc-kmp/src/iosMain/kotlin/com/shepeliev/webrtckmp/IosCompletionHandler.kt index 5ee98b63..fd4f9d2e 100644 --- a/webrtc-kmp/src/iosMain/kotlin/com/shepeliev/webrtckmp/IosCompletionHandler.kt +++ b/webrtc-kmp/src/iosMain/kotlin/com/shepeliev/webrtckmp/IosCompletionHandler.kt @@ -1,3 +1,5 @@ +@file:OptIn(kotlinx.cinterop.ExperimentalForeignApi::class) + package com.shepeliev.webrtckmp import kotlinx.coroutines.CompletableDeferred diff --git a/webrtc-kmp/src/iosTest/kotlin/com/shepeliev/webrtckmp/TestUtils.ios.kt b/webrtc-kmp/src/iosTest/kotlin/com/shepeliev/webrtckmp/TestUtils.ios.kt new file mode 100644 index 00000000..b712b925 --- /dev/null +++ b/webrtc-kmp/src/iosTest/kotlin/com/shepeliev/webrtckmp/TestUtils.ios.kt @@ -0,0 +1,4 @@ +package com.shepeliev.webrtckmp + +actual fun setupMocks() { +} \ No newline at end of file diff --git a/webrtc-kmp/src/jsMain/kotlin/com/shepeliev/webrtckmp/MediaDevices.js.kt b/webrtc-kmp/src/jsMain/kotlin/com/shepeliev/webrtckmp/MediaDevices.js.kt index 5ad1d80e..94b86311 100644 --- a/webrtc-kmp/src/jsMain/kotlin/com/shepeliev/webrtckmp/MediaDevices.js.kt +++ b/webrtc-kmp/src/jsMain/kotlin/com/shepeliev/webrtckmp/MediaDevices.js.kt @@ -4,6 +4,7 @@ import com.shepeliev.webrtckmp.externals.PlatformMediaStream import kotlinx.browser.window import kotlinx.coroutines.await import org.w3c.dom.mediacapture.AUDIOINPUT +import org.w3c.dom.mediacapture.AUDIOOUTPUT import org.w3c.dom.mediacapture.VIDEOINPUT import kotlin.js.Json import kotlin.js.json @@ -39,6 +40,7 @@ private object MediaDevicesImpl : MediaDevices { return devices.map { val kind = when (it.kind) { JsMediaDeviceKind.AUDIOINPUT -> MediaDeviceKind.AudioInput + JsMediaDeviceKind.AUDIOOUTPUT -> MediaDeviceKind.AudioOutput JsMediaDeviceKind.VIDEOINPUT -> MediaDeviceKind.VideoInput else -> error("Unknown media device kind: ${it.kind}") } diff --git a/webrtc-kmp/src/jsTest/kotlin/com/shepeliev/webrtckmp/TestUtils.js.kt b/webrtc-kmp/src/jsTest/kotlin/com/shepeliev/webrtckmp/TestUtils.js.kt new file mode 100644 index 00000000..b712b925 --- /dev/null +++ b/webrtc-kmp/src/jsTest/kotlin/com/shepeliev/webrtckmp/TestUtils.js.kt @@ -0,0 +1,4 @@ +package com.shepeliev.webrtckmp + +actual fun setupMocks() { +} \ No newline at end of file diff --git a/webrtc-kmp/src/jvmMain/kotlin/com/shepeliev/webrtckmp/ByteBuffer.kt b/webrtc-kmp/src/jvmMain/kotlin/com/shepeliev/webrtckmp/ByteBuffer.kt new file mode 100644 index 00000000..b7207c91 --- /dev/null +++ b/webrtc-kmp/src/jvmMain/kotlin/com/shepeliev/webrtckmp/ByteBuffer.kt @@ -0,0 +1,10 @@ +package com.shepeliev.webrtckmp + +import java.nio.ByteBuffer + +fun ByteBuffer.toByteArray(): ByteArray { + val bytes = ByteArray(remaining()) + get(bytes) + rewind() + return bytes +} diff --git a/webrtc-kmp/src/jvmMain/kotlin/com/shepeliev/webrtckmp/DataChannel.kt b/webrtc-kmp/src/jvmMain/kotlin/com/shepeliev/webrtckmp/DataChannel.kt new file mode 100644 index 00000000..01e63246 --- /dev/null +++ b/webrtc-kmp/src/jvmMain/kotlin/com/shepeliev/webrtckmp/DataChannel.kt @@ -0,0 +1,91 @@ +package com.shepeliev.webrtckmp + +import dev.onvoid.webrtc.RTCDataChannelBuffer +import dev.onvoid.webrtc.RTCDataChannelObserver +import dev.onvoid.webrtc.RTCDataChannelState +import kotlinx.coroutines.channels.awaitClose +import kotlinx.coroutines.channels.trySendBlocking +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.callbackFlow +import kotlinx.coroutines.flow.emptyFlow +import kotlinx.coroutines.flow.filter +import kotlinx.coroutines.flow.filterNotNull +import kotlinx.coroutines.flow.map +import java.nio.ByteBuffer +import dev.onvoid.webrtc.RTCDataChannel as NativeDataChannel + +actual class DataChannel(val native: NativeDataChannel) { + + actual val label: String + get() = native.label + + actual val id: Int + get() = native.id + + actual val readyState: DataChannelState + get() = native.state.toCommon() + + actual val bufferedAmount: Long + get() = native.bufferedAmount + + private val dataChannelEvent = callbackFlow { + val observer = object : RTCDataChannelObserver { + override fun onBufferedAmountChange(p0: Long) { + // not implemented + } + + override fun onStateChange() { + trySendBlocking(DataChannelEvent.StateChanged) + } + + override fun onMessage(buffer: RTCDataChannelBuffer) { + trySendBlocking(DataChannelEvent.MessageReceived(buffer)) + } + } + + native.registerObserver(observer) + + awaitClose { native.unregisterObserver() } + } + + actual val onOpen: Flow = dataChannelEvent + .filter { it is DataChannelEvent.StateChanged && native.state == RTCDataChannelState.OPEN } + .map { } + + actual val onClosing: Flow = dataChannelEvent + .filter { it is DataChannelEvent.StateChanged && native.state == RTCDataChannelState.CLOSING } + .map { } + + actual val onClose: Flow = dataChannelEvent + .filter { it is DataChannelEvent.StateChanged && native.state == RTCDataChannelState.CLOSED } + .map { } + + actual val onError: Flow = emptyFlow() + + actual val onMessage: Flow = dataChannelEvent + .map { it as? DataChannelEvent.MessageReceived } + .filterNotNull() + .map { it.buffer.data.toByteArray() } + + actual fun send(data: ByteArray): Boolean { + val buffer = RTCDataChannelBuffer(ByteBuffer.wrap(data), true) + native.send(buffer) + return true + } + + actual fun close() = native.dispose() + + private fun RTCDataChannelState.toCommon(): DataChannelState { + return when (this) { + RTCDataChannelState.CONNECTING -> DataChannelState.Connecting + RTCDataChannelState.OPEN -> DataChannelState.Open + RTCDataChannelState.CLOSING -> DataChannelState.Closing + RTCDataChannelState.CLOSED -> DataChannelState.Closed + } + } + + private sealed interface DataChannelEvent { + object StateChanged : DataChannelEvent + data class MessageReceived(val buffer: RTCDataChannelBuffer) : DataChannelEvent + } +} diff --git a/webrtc-kmp/src/jvmMain/kotlin/com/shepeliev/webrtckmp/DesktopVideoStreamTrack.kt b/webrtc-kmp/src/jvmMain/kotlin/com/shepeliev/webrtckmp/DesktopVideoStreamTrack.kt new file mode 100644 index 00000000..03320db1 --- /dev/null +++ b/webrtc-kmp/src/jvmMain/kotlin/com/shepeliev/webrtckmp/DesktopVideoStreamTrack.kt @@ -0,0 +1,26 @@ +package com.shepeliev.webrtckmp + +import dev.onvoid.webrtc.media.video.VideoDesktopSource +import dev.onvoid.webrtc.media.video.VideoTrack + +internal class DesktopVideoStreamTrack( + native: VideoTrack, + private val videoSource: VideoDesktopSource, + override val settings: MediaTrackSettings, +) : RenderedVideoStreamTrack(native), VideoStreamTrack { + + init { + videoSource.start() + } + + override suspend fun switchCamera(deviceId: String?) {} + + override fun onSetEnabled(enabled: Boolean) { + native.isEnabled = enabled + } + + override fun onStop() { + videoSource.stop() + videoSource.dispose() + } +} diff --git a/webrtc-kmp/src/jvmMain/kotlin/com/shepeliev/webrtckmp/DtmfSender.kt b/webrtc-kmp/src/jvmMain/kotlin/com/shepeliev/webrtckmp/DtmfSender.kt new file mode 100644 index 00000000..3bd92724 --- /dev/null +++ b/webrtc-kmp/src/jvmMain/kotlin/com/shepeliev/webrtckmp/DtmfSender.kt @@ -0,0 +1,19 @@ +package com.shepeliev.webrtckmp + +actual class DtmfSender { + + actual val canInsertDtmf: Boolean + get() = false + + actual val duration: Int + get() = 0 + + actual val interToneGap: Int + get() = 0 + + actual fun insertDtmf(tones: String, durationMs: Int, interToneGapMs: Int): Boolean { + TODO("Not yet implemented for JVM platform") + } + + actual fun tones(): String = "" +} diff --git a/webrtc-kmp/src/jvmMain/kotlin/com/shepeliev/webrtckmp/IceCandidate.kt b/webrtc-kmp/src/jvmMain/kotlin/com/shepeliev/webrtckmp/IceCandidate.kt new file mode 100644 index 00000000..c22f161a --- /dev/null +++ b/webrtc-kmp/src/jvmMain/kotlin/com/shepeliev/webrtckmp/IceCandidate.kt @@ -0,0 +1,15 @@ +package com.shepeliev.webrtckmp + +import dev.onvoid.webrtc.RTCIceCandidate as NativeIceCandidate + +actual class IceCandidate internal constructor(val native: NativeIceCandidate) { + actual constructor(sdpMid: String, sdpMLineIndex: Int, candidate: String) : this( + NativeIceCandidate(sdpMid, sdpMLineIndex, candidate) + ) + + actual val sdpMid: String = native.sdpMid + actual val sdpMLineIndex: Int = native.sdpMLineIndex + actual val candidate: String = native.sdp + + actual override fun toString(): String = native.toString() +} diff --git a/webrtc-kmp/src/jvmMain/kotlin/com/shepeliev/webrtckmp/IceServer.kt b/webrtc-kmp/src/jvmMain/kotlin/com/shepeliev/webrtckmp/IceServer.kt new file mode 100644 index 00000000..bc503aa7 --- /dev/null +++ b/webrtc-kmp/src/jvmMain/kotlin/com/shepeliev/webrtckmp/IceServer.kt @@ -0,0 +1,24 @@ +package com.shepeliev.webrtckmp + +import dev.onvoid.webrtc.RTCIceServer +import dev.onvoid.webrtc.TlsCertPolicy as NativeTlsCertPolicy + +fun IceServer.asNative(): RTCIceServer = RTCIceServer().apply { + urls = this@asNative.urls + username = this@asNative.username + password = this@asNative.password + tlsCertPolicy = this@asNative.tlsCertPolicy.asNative() + hostname = this@asNative.hostname + tlsAlpnProtocols = this@asNative.tlsAlpnProtocols + tlsEllipticCurves = this@asNative.tlsEllipticCurves +} + +private fun TlsCertPolicy.asNative(): NativeTlsCertPolicy { + return when (this) { + TlsCertPolicy.TlsCertPolicySecure -> NativeTlsCertPolicy.SECURE + + TlsCertPolicy.TlsCertPolicyInsecureNoCheck -> { + NativeTlsCertPolicy.INSECURE_NO_CHECK + } + } +} diff --git a/webrtc-kmp/src/jvmMain/kotlin/com/shepeliev/webrtckmp/LocalAudioStreamTrack.kt b/webrtc-kmp/src/jvmMain/kotlin/com/shepeliev/webrtckmp/LocalAudioStreamTrack.kt new file mode 100644 index 00000000..2e220f89 --- /dev/null +++ b/webrtc-kmp/src/jvmMain/kotlin/com/shepeliev/webrtckmp/LocalAudioStreamTrack.kt @@ -0,0 +1,10 @@ +package com.shepeliev.webrtckmp + +import dev.onvoid.webrtc.media.audio.AudioTrack +import dev.onvoid.webrtc.media.audio.AudioTrackSource + +internal class LocalAudioStreamTrack( + native: AudioTrack, + private val audioSource: AudioTrackSource, + override val constraints: MediaTrackConstraints, +) : MediaStreamTrackImpl(native), AudioStreamTrack diff --git a/webrtc-kmp/src/jvmMain/kotlin/com/shepeliev/webrtckmp/LocalVideoStreamTrack.kt b/webrtc-kmp/src/jvmMain/kotlin/com/shepeliev/webrtckmp/LocalVideoStreamTrack.kt new file mode 100644 index 00000000..4877bf51 --- /dev/null +++ b/webrtc-kmp/src/jvmMain/kotlin/com/shepeliev/webrtckmp/LocalVideoStreamTrack.kt @@ -0,0 +1,39 @@ +package com.shepeliev.webrtckmp + +import dev.onvoid.webrtc.media.MediaDevices +import dev.onvoid.webrtc.media.video.VideoDeviceSource +import dev.onvoid.webrtc.media.video.VideoTrack + +internal class LocalVideoStreamTrack( + native: VideoTrack, + private val videoSource: VideoDeviceSource, + override val settings: MediaTrackSettings, +) : RenderedVideoStreamTrack(native), VideoStreamTrack { + + init { + videoSource.start() + } + + override suspend fun switchCamera(deviceId: String?) { + if(native.id != deviceId) { + videoSource.stop() + // if the deviceId is null, no new camera will be set, effectively "muting" video + deviceId?.let { id -> + MediaDevices.getVideoCaptureDevices().firstOrNull { it.descriptor == id } + ?.let { device -> + videoSource.setVideoCaptureDevice(device) + videoSource.start() + } + } + } + } + + override fun onSetEnabled(enabled: Boolean) { + native.isEnabled = enabled + } + + override fun onStop() { + videoSource.stop() + videoSource.dispose() + } +} diff --git a/webrtc-kmp/src/jvmMain/kotlin/com/shepeliev/webrtckmp/MediaDevices.kt b/webrtc-kmp/src/jvmMain/kotlin/com/shepeliev/webrtckmp/MediaDevices.kt new file mode 100644 index 00000000..ecb60122 --- /dev/null +++ b/webrtc-kmp/src/jvmMain/kotlin/com/shepeliev/webrtckmp/MediaDevices.kt @@ -0,0 +1,221 @@ +@file:JvmName("JVMMediaDevices") + +package com.shepeliev.webrtckmp + +import dev.onvoid.webrtc.media.Device +import dev.onvoid.webrtc.media.DeviceChangeListener +import dev.onvoid.webrtc.media.audio.AudioDevice +import dev.onvoid.webrtc.media.audio.AudioOptions +import dev.onvoid.webrtc.media.video.VideoCaptureCapability +import dev.onvoid.webrtc.media.video.VideoDesktopSource +import dev.onvoid.webrtc.media.video.VideoDevice +import dev.onvoid.webrtc.media.video.VideoDeviceSource +import java.util.UUID +import dev.onvoid.webrtc.media.MediaDevices as NativeMediaDevices + +internal actual val mediaDevices: MediaDevices = MediaDevicesImpl + +interface MediaDeviceListener { + fun deviceConnected(device: MediaDeviceInfo) + fun deviceDisconnected(device: MediaDeviceInfo) +} + +internal object MediaDevicesImpl : MediaDevices, DeviceChangeListener { + + private val deviceListeners: MutableList = mutableListOf() + + init { + NativeMediaDevices.addDeviceChangeListener(this) + } + + override suspend fun getUserMedia(streamConstraints: MediaStreamConstraintsBuilder.() -> Unit): MediaStream { + val constraints = MediaStreamConstraintsBuilder().let { + streamConstraints(it) + it.constraints + } + + var audioTrack: AudioStreamTrack? = null + if (constraints.audio != null) { + val audioDevices = NativeMediaDevices.getAudioCaptureDevices() + + if (audioDevices.isNotEmpty()) { + val device = constraints.audio.deviceId?.let { deviceId -> + audioDevices.first { device -> + device.descriptor == deviceId + } + } ?: NativeMediaDevices.getDefaultAudioCaptureDevice() + + WebRtc.setAudioInputDevice(device) + + val mediaConstraints = AudioOptions().apply { + this.autoGainControl = constraints.audio.autoGainControl?.value == true + this.echoCancellation = constraints.audio.echoCancellation?.value == true + this.noiseSuppression = constraints.audio.noiseSuppression?.value == true + } + val audioSource = WebRtc.peerConnectionFactory.createAudioSource(mediaConstraints) + val nativeTrack = WebRtc.peerConnectionFactory.createAudioTrack( + UUID.randomUUID().toString(), + audioSource + ) + audioTrack = LocalAudioStreamTrack(nativeTrack, audioSource, constraints.audio) + } + } + + var videoTrack: LocalVideoStreamTrack? = null + if (constraints.video != null) { + videoTrack = getLocalVideoStreamTrack(constraints.video) + } + + return MediaStream().apply { + if (audioTrack != null) addTrack(audioTrack) + if (videoTrack != null) addTrack(videoTrack) + } + } + + private fun getLocalVideoStreamTrack(constraints: MediaTrackConstraints): LocalVideoStreamTrack? { + val videoDevicesWithCapabilities = NativeMediaDevices.getVideoCaptureDevices().map { + Pair(it, getMatchingCapabilities(it, constraints)) + } + + if (videoDevicesWithCapabilities.isNotEmpty()) { + val matchingDevice = constraints.deviceId?.let { deviceId -> + videoDevicesWithCapabilities.first { device -> + device.first.descriptor == deviceId + } + } ?: videoDevicesWithCapabilities.firstOrNull { + it.second.isNotEmpty() + } + + if (matchingDevice != null) { + val videoSource = VideoDeviceSource().apply { + setVideoCaptureDevice(matchingDevice.first) + setVideoCaptureCapability(matchingDevice.second.first()) + } + val nativeTrack = WebRtc.peerConnectionFactory.createVideoTrack( + UUID.randomUUID().toString(), + videoSource, + ) + return LocalVideoStreamTrack( + native = nativeTrack, + videoSource = videoSource, + settings = MediaTrackSettings(), + ) + } + } + + return null + } + + internal fun getMatchingCapabilities(device: VideoDevice, constraints: MediaTrackConstraints): List { + val capabilities = NativeMediaDevices.getVideoCaptureCapabilities(device) + + val exact = capabilities.firstOrNull { capability -> + val matchHeight = constraints.height?.exact?.let { it == capability.height } ?: true + val matchWidth = constraints.width?.exact?.let { it == capability.width } ?: true + val matchFrameRate = constraints.frameRate?.exact?.let { it.toInt() == capability.frameRate } ?: true + + matchHeight && matchWidth && matchFrameRate + }?.let { + listOf(it) + } + + return exact ?: capabilities.filter { capability -> + val satisfyHeight = constraints.height?.value?.let { + it >= capability.height + } ?: true + + val satisfyWidth = constraints.width?.value?.let { + it >= capability.width + } ?: true + + val satisfyFrameRate = constraints.frameRate?.value?.let { + it >= capability.frameRate + } ?: true + + satisfyHeight && satisfyWidth && satisfyFrameRate + } + } + + override suspend fun getDisplayMedia(): MediaStream { + val source = VideoDesktopSource() + val track = WebRtc.peerConnectionFactory.createVideoTrack("desktop", source) + + return MediaStream().apply { + addTrack( + DesktopVideoStreamTrack( + native = track, + videoSource = source, + settings = MediaTrackSettings(), + ) + ) + } + } + + override suspend fun supportsDisplayMedia(): Boolean = true + + override suspend fun enumerateDevices(): List { + val audioInputDevices = NativeMediaDevices.getAudioCaptureDevices().map { + MediaDeviceInfo( + deviceId = it.descriptor, + label = it.name, + kind = MediaDeviceKind.AudioInput + ) + } + val audioOutputDevices = NativeMediaDevices.getAudioRenderDevices().map { + MediaDeviceInfo( + deviceId = it.descriptor, + label = it.name, + kind = MediaDeviceKind.AudioOutput + ) + } + val videoDevices = NativeMediaDevices.getVideoCaptureDevices().map { + MediaDeviceInfo( + deviceId = it.descriptor, + label = it.name, + kind = MediaDeviceKind.VideoInput + ) + } + + return audioInputDevices + audioOutputDevices + videoDevices + } + + fun addDeviceChangeListener(listener: MediaDeviceListener) { + deviceListeners.add(listener) + } + + fun removeDeviceChangeListener(listener: MediaDeviceListener) { + deviceListeners.remove(listener) + } + + override fun deviceConnected(device: Device) { + val deviceInfo = MediaDeviceInfo( + deviceId = device.descriptor, + label = device.name, + kind = when (device) { + is AudioDevice -> MediaDeviceKind.AudioInput + else -> MediaDeviceKind.VideoInput + } + ) + deviceListeners.forEach { + it.deviceConnected(deviceInfo) + } + } + + override fun deviceDisconnected(device: Device) { + val deviceInfo = MediaDeviceInfo( + deviceId = device.descriptor, + label = device.name, + kind = when (device) { + is AudioDevice -> if (NativeMediaDevices.getAudioCaptureDevices().contains(device)) + MediaDeviceKind.AudioInput + else + MediaDeviceKind.AudioOutput + + else -> MediaDeviceKind.VideoInput + } + ) + deviceListeners.forEach { + it.deviceDisconnected(deviceInfo) + } + } +} diff --git a/webrtc-kmp/src/jvmMain/kotlin/com/shepeliev/webrtckmp/MediaStream.kt b/webrtc-kmp/src/jvmMain/kotlin/com/shepeliev/webrtckmp/MediaStream.kt new file mode 100644 index 00000000..708cec09 --- /dev/null +++ b/webrtc-kmp/src/jvmMain/kotlin/com/shepeliev/webrtckmp/MediaStream.kt @@ -0,0 +1,52 @@ +package com.shepeliev.webrtckmp + +import dev.onvoid.webrtc.media.audio.AudioTrack +import dev.onvoid.webrtc.media.video.VideoTrack +import java.util.UUID +import dev.onvoid.webrtc.media.MediaStream as NativeMediaStream + +actual class MediaStream internal constructor( + val native: NativeMediaStream? = null, + actual val id: String = native?.id() ?: UUID.randomUUID().toString(), +) { + + actual constructor() : this(null) + + private val _tracks = mutableListOf() + actual val tracks: List = _tracks + + actual fun addTrack(track: MediaStreamTrack) { + require(track is MediaStreamTrackImpl) + + native?.let { + when (track.native) { + is AudioTrack -> it.addTrack(track.native) + is VideoTrack -> it.addTrack(track.native) + else -> error("Unknown MediaStreamTrack kind: ${track.kind}") + } + } + _tracks += track + } + + actual fun getTrackById(id: String): MediaStreamTrack? { + return tracks.firstOrNull { it.id == id } + } + + actual fun removeTrack(track: MediaStreamTrack) { + require(track is MediaStreamTrackImpl) + + native?.let { + when (track.native) { + is AudioTrack -> it.removeTrack(track.native) + is VideoTrack -> it.removeTrack(track.native) + else -> error("Unknown MediaStreamTrack kind: ${track.kind}") + } + } + _tracks -= track + } + + actual fun release() { + tracks.forEach(MediaStreamTrack::stop) + native?.dispose() + } +} diff --git a/webrtc-kmp/src/jvmMain/kotlin/com/shepeliev/webrtckmp/MediaStreamTrackImpl.kt b/webrtc-kmp/src/jvmMain/kotlin/com/shepeliev/webrtckmp/MediaStreamTrackImpl.kt new file mode 100644 index 00000000..81a3063d --- /dev/null +++ b/webrtc-kmp/src/jvmMain/kotlin/com/shepeliev/webrtckmp/MediaStreamTrackImpl.kt @@ -0,0 +1,69 @@ +@file:JvmName("JVMMediaStreamTrack") + +package com.shepeliev.webrtckmp + +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import dev.onvoid.webrtc.media.MediaStreamTrack as NativeMediaStreamTrack + +internal abstract class MediaStreamTrackImpl( + val native: NativeMediaStreamTrack +) : MediaStreamTrack { + + override val id: String + get() = native.id + + override val kind: MediaStreamTrackKind + get() = when (native.kind) { + NativeMediaStreamTrack.AUDIO_TRACK_KIND -> MediaStreamTrackKind.Audio + NativeMediaStreamTrack.VIDEO_TRACK_KIND -> MediaStreamTrackKind.Video + else -> error("Unknown track kind: ${native.kind}") + } + + override val label: String + get() = when (kind) { + MediaStreamTrackKind.Audio -> "microphone" + MediaStreamTrackKind.Video -> "camera" + } + + override var enabled: Boolean + get() = native.isEnabled + set(value) { + if (value == native.isEnabled) return + native.isEnabled = value + onSetEnabled(value) + } + + private val _state = MutableStateFlow(getInitialState()) + override val state: StateFlow = _state.asStateFlow() + + override val constraints: MediaTrackConstraints = MediaTrackConstraints() + override val settings: MediaTrackSettings = MediaTrackSettings() + + override fun stop() { + if (_state.value is MediaStreamTrackState.Ended) return + _state.update { MediaStreamTrackState.Ended(it.muted) } + onStop() + } + + protected open fun setMuted(muted: Boolean) { + if (muted) { + _state.update { it.mute() } + } else { + _state.update { it.unmute() } + } + } + + protected open fun onSetEnabled(enabled: Boolean) {} + + protected open fun onStop() {} + + private fun getInitialState(): MediaStreamTrackState { + return when (checkNotNull(native.state)) { + dev.onvoid.webrtc.media.MediaStreamTrackState.LIVE -> MediaStreamTrackState.Live(muted = false) + dev.onvoid.webrtc.media.MediaStreamTrackState.ENDED -> MediaStreamTrackState.Ended(muted = false) + } + } +} diff --git a/webrtc-kmp/src/jvmMain/kotlin/com/shepeliev/webrtckmp/PeerConnection.kt b/webrtc-kmp/src/jvmMain/kotlin/com/shepeliev/webrtckmp/PeerConnection.kt new file mode 100644 index 00000000..1a86c8ec --- /dev/null +++ b/webrtc-kmp/src/jvmMain/kotlin/com/shepeliev/webrtckmp/PeerConnection.kt @@ -0,0 +1,357 @@ +package com.shepeliev.webrtckmp + +import com.shepeliev.webrtckmp.PeerConnectionEvent.ConnectionStateChange +import com.shepeliev.webrtckmp.PeerConnectionEvent.IceConnectionStateChange +import com.shepeliev.webrtckmp.PeerConnectionEvent.IceGatheringStateChange +import com.shepeliev.webrtckmp.PeerConnectionEvent.NegotiationNeeded +import com.shepeliev.webrtckmp.PeerConnectionEvent.NewDataChannel +import com.shepeliev.webrtckmp.PeerConnectionEvent.NewIceCandidate +import com.shepeliev.webrtckmp.PeerConnectionEvent.RemoveTrack +import com.shepeliev.webrtckmp.PeerConnectionEvent.RemovedIceCandidates +import com.shepeliev.webrtckmp.PeerConnectionEvent.SignalingStateChange +import com.shepeliev.webrtckmp.PeerConnectionEvent.StandardizedIceConnectionChange +import com.shepeliev.webrtckmp.PeerConnectionEvent.Track +import dev.onvoid.webrtc.PeerConnectionObserver +import dev.onvoid.webrtc.RTCAnswerOptions +import dev.onvoid.webrtc.RTCDataChannel +import dev.onvoid.webrtc.RTCDataChannelInit +import dev.onvoid.webrtc.RTCIceCandidate +import dev.onvoid.webrtc.RTCIceConnectionState +import dev.onvoid.webrtc.RTCIceGatheringState +import dev.onvoid.webrtc.RTCOfferOptions +import dev.onvoid.webrtc.RTCPeerConnection +import dev.onvoid.webrtc.RTCPeerConnectionIceErrorEvent +import dev.onvoid.webrtc.RTCPeerConnectionState +import dev.onvoid.webrtc.RTCRtpReceiver +import dev.onvoid.webrtc.RTCRtpTransceiver +import dev.onvoid.webrtc.RTCSignalingState +import dev.onvoid.webrtc.media.audio.AudioTrack +import dev.onvoid.webrtc.media.video.VideoTrack +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlin.coroutines.resume +import kotlin.coroutines.suspendCoroutine +import dev.onvoid.webrtc.media.MediaStream as NativeMediaStream + +actual class PeerConnection actual constructor(rtcConfiguration: RtcConfiguration) { + + val native: RTCPeerConnection by lazy { + WebRtc.peerConnectionFactory.createPeerConnection( + rtcConfiguration.asNative(), + NativePeerConnectionObserver() + ) ?: error("Creating PeerConnection failed") + } + + actual val localDescription: SessionDescription? + get() = native.localDescription?.asCommon() + + actual val remoteDescription: SessionDescription? + get() = native.remoteDescription?.asCommon() + + actual val signalingState: SignalingState + get() = native.signalingState.asCommon() + + actual val iceConnectionState: IceConnectionState + get() = native.iceConnectionState.asCommon() + + actual val connectionState: PeerConnectionState + get() = native.connectionState.asCommon() + + actual val iceGatheringState: IceGatheringState + get() = native.iceGatheringState.asCommon() + + private val _peerConnectionEvent = MutableSharedFlow( + extraBufferCapacity = FLOW_BUFFER_CAPACITY, + ) + internal actual val peerConnectionEvent: Flow = + _peerConnectionEvent.asSharedFlow() + + private val localTracks = mutableMapOf() + private val remoteTracks = mutableMapOf() + + actual fun createDataChannel( + label: String, + id: Int, + ordered: Boolean, + maxPacketLifeTimeMs: Int, + maxRetransmits: Int, + protocol: String, + negotiated: Boolean , + ): DataChannel? { + val init = RTCDataChannelInit().apply { + this.id = id + this.ordered = ordered + this.maxRetransmits = maxRetransmits + this.protocol = protocol + this.negotiated = negotiated + } + return native.createDataChannel(label, init)?.let { DataChannel(it) } + } + + actual suspend fun createOffer(options: OfferAnswerOptions): SessionDescription { + return native.createOffer( + RTCOfferOptions().apply { + this.iceRestart = options.iceRestart == true + }, + ).asCommon() + } + + actual suspend fun createAnswer(options: OfferAnswerOptions): SessionDescription { + return native.createAnswer( + RTCAnswerOptions().apply { + this.voiceActivityDetection = options.voiceActivityDetection == true + }, + ).asCommon() + } + + actual suspend fun setLocalDescription(description: SessionDescription) { + return native.setLocalDescription(description.asNative()) + } + + actual suspend fun setRemoteDescription(description: SessionDescription) { + return native.setRemoteDescription(description.asNative()) + } + + actual fun setConfiguration(configuration: RtcConfiguration): Boolean { + native.configuration = configuration.asNative() + return true + } + + actual suspend fun addIceCandidate(candidate: IceCandidate): Boolean { + native.addIceCandidate(candidate.native) + return true + } + + actual fun removeIceCandidates(candidates: List): Boolean { + native.removeIceCandidates(candidates.map { it.native }.toTypedArray()) + return true + } + + actual fun getSenders(): List = native.senders.map { + RtpSender(it, localTracks[it.track?.id]) + } + + actual fun getReceivers(): List = native.receivers.map { + RtpReceiver(it, remoteTracks[it.track?.id]) + } + + actual fun getTransceivers(): List = + native.transceivers.map { + val senderTrack = localTracks[it.sender.track?.id] + val receiverTrack = remoteTracks[it.receiver.track?.id] + RtpTransceiver(it, senderTrack, receiverTrack) + } + + actual fun addTrack(track: MediaStreamTrack, vararg streams: MediaStream): RtpSender { + require(track is MediaStreamTrackImpl) + + val streamIds = streams.map { it.id } + localTracks[track.id] = track + return RtpSender(native.addTrack(track.native, streamIds), track) + } + + actual fun removeTrack(sender: RtpSender): Boolean { + localTracks.remove(sender.track?.id) + native.removeTrack(sender.native) + return true + } + + actual suspend fun getStats(): RtcStatsReport? { + return suspendCoroutine { cont -> + native.getStats { cont.resume(RtcStatsReport(it)) } + } + } + + actual fun close() { + remoteTracks.values.forEach(MediaStreamTrack::stop) + remoteTracks.clear() + native.close() + } + + internal inner class NativePeerConnectionObserver : PeerConnectionObserver { + override fun onSignalingChange(newState: RTCSignalingState) { + _peerConnectionEvent.tryEmit(SignalingStateChange(newState.asCommon())) + } + + override fun onIceConnectionChange(newState: RTCIceConnectionState) { + _peerConnectionEvent.tryEmit(IceConnectionStateChange(newState.asCommon())) + } + + override fun onStandardizedIceConnectionChange(newState: RTCIceConnectionState) { + _peerConnectionEvent.tryEmit(StandardizedIceConnectionChange(newState.asCommon())) + } + + override fun onConnectionChange(newState: RTCPeerConnectionState) { + _peerConnectionEvent.tryEmit(ConnectionStateChange(newState.asCommon())) + } + + override fun onIceConnectionReceivingChange(receiving: Boolean) {} + + override fun onIceGatheringChange(newState: RTCIceGatheringState) { + _peerConnectionEvent.tryEmit(IceGatheringStateChange(newState.asCommon())) + } + + override fun onIceCandidate(candidate: RTCIceCandidate) { + _peerConnectionEvent.tryEmit(NewIceCandidate(IceCandidate(candidate))) + } + + override fun onIceCandidatesRemoved(candidates: Array) { + _peerConnectionEvent.tryEmit(RemovedIceCandidates(candidates.map { IceCandidate(it) })) + } + + override fun onAddStream(nativeStream: NativeMediaStream) { + // this deprecated API should not longer be used + // https://developer.mozilla.org/en-US/docs/Web/API/RTCPeerConnection/onaddstream + } + + override fun onRemoveStream(nativeStream: NativeMediaStream) { + // The removestream event has been removed from the WebRTC specification in favor of + // the existing removetrack event on the remote MediaStream and the corresponding + // MediaStream.onremovetrack event handler property of the remote MediaStream. + // The RTCPeerConnection API is now track-based, so having zero tracks in the remote + // stream is equivalent to the remote stream being removed and the old removestream event. + // https://developer.mozilla.org/en-US/docs/Web/API/RTCPeerConnection/onremovestream + } + + override fun onDataChannel(dataChannel: RTCDataChannel) { + _peerConnectionEvent.tryEmit(NewDataChannel(DataChannel(dataChannel))) + } + + override fun onRenegotiationNeeded() { + _peerConnectionEvent.tryEmit(NegotiationNeeded) + } + + override fun onAddTrack( + receiver: RTCRtpReceiver, + mediaStreams: Array + ) { + val transceiver = + native.transceivers.find { it.receiver.track.id == receiver.track.id } ?: return + if (mediaStreams.isEmpty()) return + + val audioTracks = mediaStreams + .flatMap { it.audioTracks?.toList() ?: emptyList() } + .map { remoteTracks.getOrPut(it.id) { RemoteAudioStreamTrack(it) } } + + val videoTracks = mediaStreams + .flatMap { it.videoTracks?.toList() ?: emptyList() } + .map { remoteTracks.getOrPut(it.id) { RemoteVideoStreamTrack(it) } } + + if (audioTracks.isEmpty() && videoTracks.isEmpty()) { + return + } + + val streams = mediaStreams.map { nativeStream -> + MediaStream( + native = nativeStream, + ).apply { + audioTracks.forEach(::addTrack) + videoTracks.forEach(::addTrack) + } + } + + val senderTrack = localTracks[transceiver.sender.track?.id] + val receiverTrack = remoteTracks[receiver.track?.id] + + val trackEvent = TrackEvent( + receiver = RtpReceiver(receiver, receiverTrack), + streams = streams, + track = receiverTrack, + transceiver = RtpTransceiver(transceiver, senderTrack, receiverTrack) + ) + + _peerConnectionEvent.tryEmit(Track(trackEvent)) + } + + override fun onRemoveTrack(receiver: RTCRtpReceiver) { + val track = remoteTracks.remove(receiver.track?.id) + _peerConnectionEvent.tryEmit(RemoveTrack(RtpReceiver(receiver, track))) + track?.stop() + } + + override fun onTrack(transceiver: RTCRtpTransceiver) { + transceiver.receiver?.let { receiver -> + receiver.track?.let { mediaTrack -> + val track = when (mediaTrack.kind) { + "audio" -> remoteTracks.getOrPut(mediaTrack.id) { + RemoteAudioStreamTrack( + mediaTrack as AudioTrack + ) + } + + "video" -> remoteTracks.getOrPut(mediaTrack.id) { + RemoteVideoStreamTrack( + mediaTrack as VideoTrack + ) + } + + else -> error("Unknown media stream track kind: $this") + } + + val senderTrack = localTracks[transceiver.sender.track?.id] + remoteTracks[track.id] = track + + val trackEvent = TrackEvent( + receiver = RtpReceiver(receiver, track), + streams = listOf( + MediaStream().apply { + remoteTracks.values.forEach(::addTrack) + } + ), + track = track, + transceiver = RtpTransceiver(transceiver, senderTrack, track) + ) + + _peerConnectionEvent.tryEmit(Track(trackEvent)) + } + } + } + + override fun onIceCandidateError(event: RTCPeerConnectionIceErrorEvent) { + super.onIceCandidateError(event) + } + } +} + +private fun RTCSignalingState.asCommon(): SignalingState { + return when (this) { + RTCSignalingState.STABLE -> SignalingState.Stable + RTCSignalingState.HAVE_LOCAL_OFFER -> SignalingState.HaveLocalOffer + RTCSignalingState.HAVE_LOCAL_PR_ANSWER -> SignalingState.HaveLocalPranswer + RTCSignalingState.HAVE_REMOTE_OFFER -> SignalingState.HaveRemoteOffer + RTCSignalingState.HAVE_REMOTE_PR_ANSWER -> SignalingState.HaveRemotePranswer + RTCSignalingState.CLOSED -> SignalingState.Closed + } +} + +private fun RTCIceConnectionState.asCommon(): IceConnectionState { + return when (this) { + RTCIceConnectionState.NEW -> IceConnectionState.New + RTCIceConnectionState.CHECKING -> IceConnectionState.Checking + RTCIceConnectionState.CONNECTED -> IceConnectionState.Connected + RTCIceConnectionState.COMPLETED -> IceConnectionState.Completed + RTCIceConnectionState.FAILED -> IceConnectionState.Failed + RTCIceConnectionState.DISCONNECTED -> IceConnectionState.Disconnected + RTCIceConnectionState.CLOSED -> IceConnectionState.Closed + } +} + +private fun RTCPeerConnectionState.asCommon(): PeerConnectionState { + return when (this) { + RTCPeerConnectionState.NEW -> PeerConnectionState.New + RTCPeerConnectionState.CONNECTING -> PeerConnectionState.Connecting + RTCPeerConnectionState.CONNECTED -> PeerConnectionState.Connected + RTCPeerConnectionState.DISCONNECTED -> PeerConnectionState.Disconnected + RTCPeerConnectionState.FAILED -> PeerConnectionState.Failed + RTCPeerConnectionState.CLOSED -> PeerConnectionState.Closed + } +} + +private fun RTCIceGatheringState.asCommon(): IceGatheringState { + return when (this) { + RTCIceGatheringState.NEW -> IceGatheringState.New + RTCIceGatheringState.GATHERING -> IceGatheringState.Gathering + RTCIceGatheringState.COMPLETE -> IceGatheringState.Complete + } +} diff --git a/webrtc-kmp/src/jvmMain/kotlin/com/shepeliev/webrtckmp/PeerConnectionExt.kt b/webrtc-kmp/src/jvmMain/kotlin/com/shepeliev/webrtckmp/PeerConnectionExt.kt new file mode 100644 index 00000000..bfbefab3 --- /dev/null +++ b/webrtc-kmp/src/jvmMain/kotlin/com/shepeliev/webrtckmp/PeerConnectionExt.kt @@ -0,0 +1,71 @@ +package com.shepeliev.webrtckmp + +import dev.onvoid.webrtc.CreateSessionDescriptionObserver +import dev.onvoid.webrtc.RTCAnswerOptions +import dev.onvoid.webrtc.RTCOfferOptions +import dev.onvoid.webrtc.RTCPeerConnection +import dev.onvoid.webrtc.RTCSessionDescription +import dev.onvoid.webrtc.SetSessionDescriptionObserver +import kotlinx.coroutines.suspendCancellableCoroutine +import kotlin.coroutines.resume +import kotlin.coroutines.resumeWithException + +internal suspend fun RTCPeerConnection.createOffer(options: RTCOfferOptions = RTCOfferOptions()): RTCSessionDescription = suspendCancellableCoroutine { + createOffer( + options, + object : CreateSessionDescriptionObserver { + override fun onSuccess(description: RTCSessionDescription) { + it.resume(description) + } + + override fun onFailure(error: String) { + it.resumeWithException(RuntimeException(error)) + } + }, + ) +} + +internal suspend fun RTCPeerConnection.setLocalDescription(description: RTCSessionDescription) = suspendCancellableCoroutine { + setLocalDescription( + description, + object : SetSessionDescriptionObserver { + override fun onSuccess() { + it.resume(Unit) + } + + override fun onFailure(error: String) { + it.resumeWithException(RuntimeException(error)) + } + }, + ) +} + +internal suspend fun RTCPeerConnection.createAnswer(options: RTCAnswerOptions = RTCAnswerOptions()): RTCSessionDescription = suspendCancellableCoroutine { + createAnswer( + options, + object : CreateSessionDescriptionObserver { + override fun onSuccess(description: RTCSessionDescription) { + it.resume(description) + } + + override fun onFailure(error: String) { + it.resumeWithException(RuntimeException(error)) + } + }, + ) +} + +internal suspend fun RTCPeerConnection.setRemoteDescription(description: RTCSessionDescription) = suspendCancellableCoroutine { + setRemoteDescription( + description, + object : SetSessionDescriptionObserver { + override fun onSuccess() { + it.resume(Unit) + } + + override fun onFailure(error: String) { + it.resumeWithException(RuntimeException(error)) + } + }, + ) +} diff --git a/webrtc-kmp/src/jvmMain/kotlin/com/shepeliev/webrtckmp/RemoteAudioStreamTrack.kt b/webrtc-kmp/src/jvmMain/kotlin/com/shepeliev/webrtckmp/RemoteAudioStreamTrack.kt new file mode 100644 index 00000000..2217e459 --- /dev/null +++ b/webrtc-kmp/src/jvmMain/kotlin/com/shepeliev/webrtckmp/RemoteAudioStreamTrack.kt @@ -0,0 +1,7 @@ +package com.shepeliev.webrtckmp + +import dev.onvoid.webrtc.media.audio.AudioTrack + +internal class RemoteAudioStreamTrack( + native: AudioTrack +) : MediaStreamTrackImpl(native), AudioStreamTrack diff --git a/webrtc-kmp/src/jvmMain/kotlin/com/shepeliev/webrtckmp/RemoteVideoStreamTrack.kt b/webrtc-kmp/src/jvmMain/kotlin/com/shepeliev/webrtckmp/RemoteVideoStreamTrack.kt new file mode 100644 index 00000000..87ae93de --- /dev/null +++ b/webrtc-kmp/src/jvmMain/kotlin/com/shepeliev/webrtckmp/RemoteVideoStreamTrack.kt @@ -0,0 +1,29 @@ +package com.shepeliev.webrtckmp + +import dev.onvoid.webrtc.logging.Logging +import dev.onvoid.webrtc.media.MediaStreamTrack +import dev.onvoid.webrtc.media.MediaStreamTrackMuteListener +import dev.onvoid.webrtc.media.video.VideoTrack + +internal class RemoteVideoStreamTrack( + native: VideoTrack, +) : RenderedVideoStreamTrack(native), VideoStreamTrack, MediaStreamTrackMuteListener { + + override suspend fun switchCamera(deviceId: String?) { + Logging.error("switchCamera is not supported for remote tracks") + } + + override fun onSetEnabled(enabled: Boolean) { + if (enabled) { + native.addTrackMuteListener(this) + } else { + native.removeTrackMuteListener(this) + } + } + + override fun onTrackMute(track: MediaStreamTrack, muted: Boolean) { + if (track == native) { + setMuted(muted) + } + } +} diff --git a/webrtc-kmp/src/jvmMain/kotlin/com/shepeliev/webrtckmp/RenderedVideoStreamTrack.kt b/webrtc-kmp/src/jvmMain/kotlin/com/shepeliev/webrtckmp/RenderedVideoStreamTrack.kt new file mode 100644 index 00000000..4a6fd346 --- /dev/null +++ b/webrtc-kmp/src/jvmMain/kotlin/com/shepeliev/webrtckmp/RenderedVideoStreamTrack.kt @@ -0,0 +1,18 @@ +package com.shepeliev.webrtckmp + +import dev.onvoid.webrtc.media.video.VideoTrack +import dev.onvoid.webrtc.media.video.VideoTrackSink + +internal abstract class RenderedVideoStreamTrack( + native: VideoTrack +) : MediaStreamTrackImpl(native), VideoStreamTrack { + override fun addSink(sink: VideoTrackSink) { + native as VideoTrack + native.addSink(sink) + } + + override fun removeSink(sink: VideoTrackSink) { + native as VideoTrack + native.removeSink(sink) + } +} diff --git a/webrtc-kmp/src/jvmMain/kotlin/com/shepeliev/webrtckmp/RtcCertificatePem.kt b/webrtc-kmp/src/jvmMain/kotlin/com/shepeliev/webrtckmp/RtcCertificatePem.kt new file mode 100644 index 00000000..b8093fd7 --- /dev/null +++ b/webrtc-kmp/src/jvmMain/kotlin/com/shepeliev/webrtckmp/RtcCertificatePem.kt @@ -0,0 +1,126 @@ +package com.shepeliev.webrtckmp + +import dev.onvoid.webrtc.logging.Logging +import org.bouncycastle.asn1.ASN1Encodable +import org.bouncycastle.asn1.DERSequence +import org.bouncycastle.asn1.pkcs.PrivateKeyInfo +import org.bouncycastle.asn1.x509.BasicConstraints +import org.bouncycastle.asn1.x509.ExtendedKeyUsage +import org.bouncycastle.asn1.x509.Extension +import org.bouncycastle.asn1.x509.GeneralName.dNSName +import org.bouncycastle.asn1.x509.KeyPurposeId +import org.bouncycastle.asn1.x509.KeyUsage.digitalSignature +import org.bouncycastle.asn1.x509.KeyUsage.keyEncipherment +import org.bouncycastle.cert.X509CertificateHolder +import org.bouncycastle.cert.jcajce.JcaX509v3CertificateBuilder +import org.bouncycastle.jce.ECNamedCurveTable +import org.bouncycastle.jce.provider.BouncyCastleProvider +import org.bouncycastle.operator.ContentSigner +import org.bouncycastle.operator.jcajce.JcaContentSignerBuilder +import java.io.IOException +import java.math.BigInteger +import java.security.KeyPair +import java.security.KeyPairGenerator +import java.security.Security +import java.util.Base64 +import java.util.Date +import javax.security.auth.x500.X500Principal +import dev.onvoid.webrtc.RTCCertificatePEM as NativeRtcCertificatePem + +actual class RtcCertificatePem internal constructor(val native: NativeRtcCertificatePem) { + actual val privateKey: String + get() = native.privateKey + + actual val certificate: String + get() = native.certificate + + actual companion object { + + init { + Security.addProvider(BouncyCastleProvider()) + } + + actual suspend fun generateCertificate(keyType: KeyType, expires: Long): RtcCertificatePem { + val generator = when (keyType) { + KeyType.RSA -> { + KeyPairGenerator.getInstance("RSA", "BC").apply { + initialize(1024) + } + } + + KeyType.ECDSA -> { + KeyPairGenerator.getInstance("ECDSA", "BC").apply { + val ecSpec = ECNamedCurveTable.getParameterSpec("secp256r1") + initialize(ecSpec) + } + } + } + + val pair = generator.generateKeyPair() + val cert = generateSelfSignedCertificate( + keyPair = pair, + expires = expires, + algorithm = when (keyType) { + KeyType.ECDSA -> "SHA256withECDSA" + KeyType.RSA -> "SHA256withRSA" + } + ) + return RtcCertificatePem( + NativeRtcCertificatePem( + pair.getPrivateKeyPkcs1Pem(), + cert.getCertificatePem(), + cert.notAfter.time, + ) + ) + } + + private fun generateSelfSignedCertificate(keyPair: KeyPair, expires: Long, algorithm: String): X509CertificateHolder { + Security.addProvider(BouncyCastleProvider()) + val subject = X500Principal("CN=WebRTC") + val notAfter = expires + 1000L * 3600L * 24 * 365 + val encodableAltNames = arrayOf(org.bouncycastle.asn1.x509.GeneralName(dNSName, "WebRTC")) + val purposes = arrayOf(KeyPurposeId.id_kp_serverAuth, KeyPurposeId.id_kp_clientAuth) + val certBuilder = JcaX509v3CertificateBuilder( + subject, + BigInteger.ONE, + Date(expires), + Date(notAfter), + subject, + keyPair.public, + ) + + try { + certBuilder.addExtension(Extension.basicConstraints, true, BasicConstraints(false)) + certBuilder.addExtension(Extension.keyUsage, true, org.bouncycastle.asn1.x509.KeyUsage(digitalSignature + keyEncipherment)) + certBuilder.addExtension(Extension.extendedKeyUsage, false, ExtendedKeyUsage(purposes)) + certBuilder.addExtension(Extension.subjectAlternativeName, false, DERSequence(encodableAltNames)) + val signer: ContentSigner = JcaContentSignerBuilder(algorithm).build(keyPair.private) + return certBuilder.build(signer) + } catch (e: Exception) { + Logging.error(e.message) + throw AssertionError(e.message) + } + } + } +} + +@Throws(IOException::class) +private fun X509CertificateHolder.getCertificatePem(): String { + val encoder: Base64.Encoder = Base64.getEncoder() + val result = StringBuilder() + result.append("-----BEGIN CERTIFICATE-----\n") + result.append(encoder.encodeToString(encoded)) + result.append("\n-----END CERTIFICATE-----\n") + return result.toString() +} + +@Throws(IOException::class) +private fun KeyPair.getPrivateKeyPkcs1Pem(): String { + val encoder: Base64.Encoder = Base64.getEncoder() + val privateKeyInfo = PrivateKeyInfo.getInstance(private.encoded) + val result = StringBuilder() + result.append("-----BEGIN RSA PRIVATE KEY-----\n") + result.append(encoder.encodeToString(privateKeyInfo.parsePrivateKey().toASN1Primitive().encoded)) + result.append("\n-----END RSA PRIVATE KEY-----\n") + return result.toString() +} diff --git a/webrtc-kmp/src/jvmMain/kotlin/com/shepeliev/webrtckmp/RtcConfiguration.kt b/webrtc-kmp/src/jvmMain/kotlin/com/shepeliev/webrtckmp/RtcConfiguration.kt new file mode 100644 index 00000000..9744befe --- /dev/null +++ b/webrtc-kmp/src/jvmMain/kotlin/com/shepeliev/webrtckmp/RtcConfiguration.kt @@ -0,0 +1,32 @@ +package com.shepeliev.webrtckmp + +import dev.onvoid.webrtc.RTCBundlePolicy +import dev.onvoid.webrtc.RTCConfiguration +import dev.onvoid.webrtc.RTCIceTransportPolicy +import dev.onvoid.webrtc.RTCRtcpMuxPolicy + +internal fun RtcConfiguration.asNative(): RTCConfiguration = RTCConfiguration().apply { + bundlePolicy = this@asNative.bundlePolicy.asNative() + certificates = this@asNative.certificates?.map { it.native } + iceServers = this@asNative.iceServers.map { it.asNative() } + iceTransportPolicy = this@asNative.iceTransportPolicy.asNative() + rtcpMuxPolicy = this@asNative.rtcpMuxPolicy.asNative() +} + +internal fun RtcpMuxPolicy.asNative(): RTCRtcpMuxPolicy = when (this) { + RtcpMuxPolicy.Negotiate -> RTCRtcpMuxPolicy.NEGOTIATE + RtcpMuxPolicy.Require -> RTCRtcpMuxPolicy.REQUIRE +} + +internal fun BundlePolicy.asNative(): RTCBundlePolicy = when (this) { + BundlePolicy.Balanced -> RTCBundlePolicy.BALANCED + BundlePolicy.MaxBundle -> RTCBundlePolicy.MAX_BUNDLE + BundlePolicy.MaxCompat -> RTCBundlePolicy.MAX_COMPAT +} + +internal fun IceTransportPolicy.asNative(): RTCIceTransportPolicy = when(this) { + IceTransportPolicy.None -> RTCIceTransportPolicy.NONE + IceTransportPolicy.Relay -> RTCIceTransportPolicy.RELAY + IceTransportPolicy.NoHost -> RTCIceTransportPolicy.NO_HOST + IceTransportPolicy.All -> RTCIceTransportPolicy.ALL +} diff --git a/webrtc-kmp/src/jvmMain/kotlin/com/shepeliev/webrtckmp/RtcStats.kt b/webrtc-kmp/src/jvmMain/kotlin/com/shepeliev/webrtckmp/RtcStats.kt new file mode 100644 index 00000000..a5091b61 --- /dev/null +++ b/webrtc-kmp/src/jvmMain/kotlin/com/shepeliev/webrtckmp/RtcStats.kt @@ -0,0 +1,11 @@ +package com.shepeliev.webrtckmp + +import dev.onvoid.webrtc.RTCStats + +actual class RtcStats internal constructor(val native: RTCStats) { + actual val timestampUs: Long = native.timestamp + actual val type: String = native.type.name + actual val id: String = native.id + actual val members: Map = native.members + actual override fun toString(): String = native.toString() +} diff --git a/webrtc-kmp/src/jvmMain/kotlin/com/shepeliev/webrtckmp/RtcStatsReport.kt b/webrtc-kmp/src/jvmMain/kotlin/com/shepeliev/webrtckmp/RtcStatsReport.kt new file mode 100644 index 00000000..22e7908f --- /dev/null +++ b/webrtc-kmp/src/jvmMain/kotlin/com/shepeliev/webrtckmp/RtcStatsReport.kt @@ -0,0 +1,9 @@ +package com.shepeliev.webrtckmp + +import dev.onvoid.webrtc.RTCStatsReport + +actual class RtcStatsReport(val native: RTCStatsReport) { + actual val timestampUs: Long = native.stats.values.firstOrNull()?.timestamp ?: -1 + actual val stats: Map = native.stats.mapValues { (_, v) -> RtcStats(v) } + actual override fun toString(): String = native.toString() +} diff --git a/webrtc-kmp/src/jvmMain/kotlin/com/shepeliev/webrtckmp/RtpParameters.kt b/webrtc-kmp/src/jvmMain/kotlin/com/shepeliev/webrtckmp/RtpParameters.kt new file mode 100644 index 00000000..64c23bbe --- /dev/null +++ b/webrtc-kmp/src/jvmMain/kotlin/com/shepeliev/webrtckmp/RtpParameters.kt @@ -0,0 +1,92 @@ +package com.shepeliev.webrtckmp + +import dev.onvoid.webrtc.RTCRtcpParameters +import dev.onvoid.webrtc.RTCRtpCodecParameters +import dev.onvoid.webrtc.RTCRtpEncodingParameters +import dev.onvoid.webrtc.RTCRtpHeaderExtensionParameters +import dev.onvoid.webrtc.RTCRtpParameters + +actual class RtpParameters(val native: RTCRtpParameters) { + actual val codecs: List + get() = native.codecs.map { RtpCodecParameters(it) } + + actual val encodings: List + get() = emptyList() + + actual val headerExtension: List + get() = native.headerExtensions.map { HeaderExtension(it) } + + actual val rtcp: RtcpParameters + get() = RtcpParameters(native.rtcp) + + actual val transactionId: String + get() = "TODO" +} + +actual class RtpCodecParameters(val native: RTCRtpCodecParameters) { + actual val payloadType: Int + get() = native.payloadType + + actual val mimeType: String? + get() = native.mediaType.name + + actual val clockRate: Int? + get() = native.clockRate + + actual val numChannels: Int? + get() = native.channels + + actual val parameters: Map + get() = native.parameters +} + +actual class RtpEncodingParameters(val native: RTCRtpEncodingParameters) { + actual val rid: String? + get() = null + + actual val active: Boolean + get() = native.active + + actual val bitratePriority: Double + get() = 0.0 + + actual val networkPriority: Int + get() = 0 + + actual val maxBitrateBps: Int? + get() = native.maxBitrate + + actual val minBitrateBps: Int? + get() = native.minBitrate + + actual val maxFramerate: Int? + get() = native.maxFramerate.toInt() + + actual val numTemporalLayers: Int? + get() = null + + actual val scaleResolutionDownBy: Double? + get() = native.scaleResolutionDownBy + + actual val ssrc: Long? + get() = native.ssrc +} + +actual class HeaderExtension(val native: RTCRtpHeaderExtensionParameters) { + actual val uri: String + get() = native.uri + + actual val id: Int + get() = native.id + + actual val encrypted: Boolean + get() = native.encrypted +} + +actual class RtcpParameters(val native: RTCRtcpParameters) { + actual val cname: String + get() = native.cName + + actual val reducedSize: Boolean + get() = native.reducedSize +} diff --git a/webrtc-kmp/src/jvmMain/kotlin/com/shepeliev/webrtckmp/RtpReceiver.kt b/webrtc-kmp/src/jvmMain/kotlin/com/shepeliev/webrtckmp/RtpReceiver.kt new file mode 100644 index 00000000..0648671f --- /dev/null +++ b/webrtc-kmp/src/jvmMain/kotlin/com/shepeliev/webrtckmp/RtpReceiver.kt @@ -0,0 +1,11 @@ +package com.shepeliev.webrtckmp + +import dev.onvoid.webrtc.RTCRtpReceiver + +actual class RtpReceiver(val native: RTCRtpReceiver, actual val track: MediaStreamTrack?) { + actual val id: String + get() = "TODO" + + actual val parameters: RtpParameters + get() = RtpParameters(native.parameters) +} diff --git a/webrtc-kmp/src/jvmMain/kotlin/com/shepeliev/webrtckmp/RtpSender.kt b/webrtc-kmp/src/jvmMain/kotlin/com/shepeliev/webrtckmp/RtpSender.kt new file mode 100644 index 00000000..0d96ed72 --- /dev/null +++ b/webrtc-kmp/src/jvmMain/kotlin/com/shepeliev/webrtckmp/RtpSender.kt @@ -0,0 +1,34 @@ +package com.shepeliev.webrtckmp + +import dev.onvoid.webrtc.RTCRtpSendParameters +import dev.onvoid.webrtc.RTCRtpSender + +actual class RtpSender internal constructor( + val native: RTCRtpSender, + track: MediaStreamTrack? +) { + actual val id: String + get() = native.track.id + + private var _track: MediaStreamTrack? = track + actual val track: MediaStreamTrack? get() = _track + + actual var parameters: RtpParameters + get() = RtpParameters(native.parameters) + set(value) { + native.parameters = RTCRtpSendParameters().apply { + this.transactionId = value.transactionId + this.codecs = value.codecs.map { + it.native + } + } + } + + actual val dtmf: DtmfSender? + get() = null + + actual suspend fun replaceTrack(track: MediaStreamTrack?) { + native.replaceTrack((track as? MediaStreamTrackImpl)?.native) + _track = track + } +} diff --git a/webrtc-kmp/src/jvmMain/kotlin/com/shepeliev/webrtckmp/RtpTransceiver.kt b/webrtc-kmp/src/jvmMain/kotlin/com/shepeliev/webrtckmp/RtpTransceiver.kt new file mode 100644 index 00000000..09efef44 --- /dev/null +++ b/webrtc-kmp/src/jvmMain/kotlin/com/shepeliev/webrtckmp/RtpTransceiver.kt @@ -0,0 +1,54 @@ +package com.shepeliev.webrtckmp + +import dev.onvoid.webrtc.RTCRtpTransceiver +import dev.onvoid.webrtc.RTCRtpTransceiverDirection + +actual class RtpTransceiver( + val native: RTCRtpTransceiver, + private val senderTrack: MediaStreamTrack?, + private val receiverTrack: MediaStreamTrack?, +) { + + actual var direction: RtpTransceiverDirection + get() = native.direction.asCommon() + set(value) { + native.direction = value.asNative() + } + + actual val currentDirection: RtpTransceiverDirection? + get() = native.currentDirection?.asCommon() + + actual val mid: String + get() = native.mid + + actual val sender: RtpSender + get() = RtpSender(native.sender, senderTrack) + + actual val receiver: RtpReceiver + get() = RtpReceiver(native.receiver, receiverTrack) + + actual val stopped: Boolean + get() = native.stopped() + + actual fun stop() = native.stop() +} + +private fun RTCRtpTransceiverDirection.asCommon(): RtpTransceiverDirection { + return when (this) { + RTCRtpTransceiverDirection.SEND_RECV -> RtpTransceiverDirection.SendRecv + RTCRtpTransceiverDirection.SEND_ONLY -> RtpTransceiverDirection.SendOnly + RTCRtpTransceiverDirection.RECV_ONLY -> RtpTransceiverDirection.RecvOnly + RTCRtpTransceiverDirection.INACTIVE -> RtpTransceiverDirection.Inactive + RTCRtpTransceiverDirection.STOPPED -> RtpTransceiverDirection.Stopped + } +} + +internal fun RtpTransceiverDirection.asNative(): RTCRtpTransceiverDirection { + return when (this) { + RtpTransceiverDirection.SendRecv -> RTCRtpTransceiverDirection.SEND_RECV + RtpTransceiverDirection.SendOnly -> RTCRtpTransceiverDirection.SEND_ONLY + RtpTransceiverDirection.RecvOnly -> RTCRtpTransceiverDirection.RECV_ONLY + RtpTransceiverDirection.Inactive -> RTCRtpTransceiverDirection.INACTIVE + RtpTransceiverDirection.Stopped -> RTCRtpTransceiverDirection.INACTIVE + } +} diff --git a/webrtc-kmp/src/jvmMain/kotlin/com/shepeliev/webrtckmp/SessionDescriptionExt.kt b/webrtc-kmp/src/jvmMain/kotlin/com/shepeliev/webrtckmp/SessionDescriptionExt.kt new file mode 100644 index 00000000..a33ef88a --- /dev/null +++ b/webrtc-kmp/src/jvmMain/kotlin/com/shepeliev/webrtckmp/SessionDescriptionExt.kt @@ -0,0 +1,30 @@ +package com.shepeliev.webrtckmp + +import dev.onvoid.webrtc.RTCSdpType +import dev.onvoid.webrtc.RTCSessionDescription + +internal fun SessionDescription.asNative(): RTCSessionDescription { + return RTCSessionDescription(type.asNative(), sdp) +} + +private fun SessionDescriptionType.asNative(): RTCSdpType { + return when (this) { + SessionDescriptionType.Offer -> RTCSdpType.OFFER + SessionDescriptionType.Pranswer -> RTCSdpType.PR_ANSWER + SessionDescriptionType.Answer -> RTCSdpType.ANSWER + SessionDescriptionType.Rollback -> RTCSdpType.ROLLBACK + } +} + +internal fun RTCSessionDescription.asCommon(): SessionDescription { + return SessionDescription(sdpType.asCommon(), sdp) +} + +private fun RTCSdpType.asCommon(): SessionDescriptionType { + return when (this) { + RTCSdpType.OFFER -> SessionDescriptionType.Offer + RTCSdpType.PR_ANSWER -> SessionDescriptionType.Pranswer + RTCSdpType.ANSWER -> SessionDescriptionType.Answer + RTCSdpType.ROLLBACK -> SessionDescriptionType.Rollback + } +} diff --git a/webrtc-kmp/src/jvmMain/kotlin/com/shepeliev/webrtckmp/VideoStreamTrack.kt b/webrtc-kmp/src/jvmMain/kotlin/com/shepeliev/webrtckmp/VideoStreamTrack.kt new file mode 100644 index 00000000..bfec2a36 --- /dev/null +++ b/webrtc-kmp/src/jvmMain/kotlin/com/shepeliev/webrtckmp/VideoStreamTrack.kt @@ -0,0 +1,9 @@ +package com.shepeliev.webrtckmp + +import dev.onvoid.webrtc.media.video.VideoTrackSink + +actual interface VideoStreamTrack : MediaStreamTrack { + actual suspend fun switchCamera(deviceId: String?) + fun addSink(sink: VideoTrackSink) + fun removeSink(sink: VideoTrackSink) +} diff --git a/webrtc-kmp/src/jvmMain/kotlin/com/shepeliev/webrtckmp/WebRtc.kt b/webrtc-kmp/src/jvmMain/kotlin/com/shepeliev/webrtckmp/WebRtc.kt new file mode 100644 index 00000000..7128367a --- /dev/null +++ b/webrtc-kmp/src/jvmMain/kotlin/com/shepeliev/webrtckmp/WebRtc.kt @@ -0,0 +1,130 @@ +@file:JvmName("WebRtcKmpJVM") + +package com.shepeliev.webrtckmp + +import dev.onvoid.webrtc.PeerConnectionFactory +import dev.onvoid.webrtc.logging.Logging +import dev.onvoid.webrtc.media.MediaDevices +import dev.onvoid.webrtc.media.audio.AudioDevice +import dev.onvoid.webrtc.media.audio.AudioDeviceModule +import dev.onvoid.webrtc.media.audio.AudioProcessing +import java.util.Optional + +typealias AudioDeviceModuleBuilder = () -> AudioDeviceModule? + +object WebRtc { + + private var _peerConnectionFactory: PeerConnectionFactory? = null + internal val peerConnectionFactory: PeerConnectionFactory + get() { + if (_peerConnectionFactory == null) initialize() + return checkNotNull(_peerConnectionFactory) + } + + private var _audioDeviceModule: AudioDeviceModule? = null + internal val audioDeviceModule: AudioDeviceModule + get() { + if (_audioDeviceModule == null) initialize() + return checkNotNull(_audioDeviceModule) + } + + private val builder by lazy { + WebRtcBuilder() + } + + fun configureBuilder(block: WebRtcBuilder.() -> Unit = {}) { + block(builder) + } + + private fun initialize() { + initLogging() + initializePeerConnectionFactory() + } + + private fun initializePeerConnectionFactory() { + with(builder) { + _audioDeviceModule = audioModuleBuilder() + + _peerConnectionFactory = PeerConnectionFactory( + _audioDeviceModule, + audioProcessing, + ) + } + } + + private fun initLogging() { + with(builder) { + loggingSeverity?.let { + Logging.addLogSink(it) { _, message -> + println(message) + } + } + } + } + + fun addDeviceChangeListener(listener: MediaDeviceListener) { + MediaDevicesImpl.addDeviceChangeListener(listener) + } + + fun removeDeviceChangeListener(listener: MediaDeviceListener) { + MediaDevicesImpl.removeDeviceChangeListener(listener) + } + + fun setAudioOutputDevice(device: AudioDevice) { + audioDeviceModule.stopPlayout() + audioDeviceModule.setPlayoutDevice(device) + audioDeviceModule.initPlayout() + } + + fun setAudioOutputDevice(device: MediaDeviceInfo) { + MediaDevices.getAudioRenderDevices().firstOrNull { + it.descriptor == device.deviceId + }?.let { + setAudioOutputDevice(it) + } + } + + fun setAudioInputDevice(device: AudioDevice) { + audioDeviceModule.stopRecording() + audioDeviceModule.setRecordingDevice(device) + audioDeviceModule.initRecording() + } + + fun setAudioInputDevice(device: MediaDeviceInfo) { + MediaDevices.getAudioCaptureDevices().firstOrNull { + it.descriptor == device.deviceId + }?.let { + setAudioInputDevice(it) + } + } + + fun getDefaultAudioOutput(): MediaDeviceInfo? { + return MediaDevices.getDefaultAudioRenderDevice()?.let { + MediaDeviceInfo( + deviceId = it.descriptor, + label = it.name, + kind = MediaDeviceKind.AudioOutput, + ) + } + } + + fun disposePeerConnectionFactory() { + peerConnectionFactory.dispose() + } +} + +internal val defaultAudioDeviceModuleBuilder: AudioDeviceModuleBuilder = { + AudioDeviceModule().apply { + MediaDevices.getDefaultAudioRenderDevice()?.let { + setPlayoutDevice(it) + initPlayout() + startPlayout() + } + } +} + +class WebRtcBuilder( + var loggingSeverity: Logging.Severity? = null, + var audioModuleBuilder: AudioDeviceModuleBuilder = defaultAudioDeviceModuleBuilder, + var audioProcessing: AudioProcessing? = null, +) diff --git a/webrtc-kmp/src/jvmTest/kotlin/com/shepeliev/webrtckmp/TestUtils.kt b/webrtc-kmp/src/jvmTest/kotlin/com/shepeliev/webrtckmp/TestUtils.kt new file mode 100644 index 00000000..00089967 --- /dev/null +++ b/webrtc-kmp/src/jvmTest/kotlin/com/shepeliev/webrtckmp/TestUtils.kt @@ -0,0 +1,7 @@ +package com.shepeliev.webrtckmp + +actual fun setupMocks() { + WebRtc.configureBuilder { + audioModuleBuilder = { null } + } +} diff --git a/webrtc-kmp/src/wasmJsTest/kotlin/com/shepeliev/webrtckmp/TestUtils.wasmJs.kt b/webrtc-kmp/src/wasmJsTest/kotlin/com/shepeliev/webrtckmp/TestUtils.wasmJs.kt new file mode 100644 index 00000000..b712b925 --- /dev/null +++ b/webrtc-kmp/src/wasmJsTest/kotlin/com/shepeliev/webrtckmp/TestUtils.wasmJs.kt @@ -0,0 +1,4 @@ +package com.shepeliev.webrtckmp + +actual fun setupMocks() { +} \ No newline at end of file