@poolse/react-native-calls 0.2.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/android/src/main/java/dev/poolse/callcore/android/CallCore.kt +25 -8
- package/android/src/main/java/dev/poolse/callcore/android/CallVideo.kt +36 -0
- package/android/src/main/java/dev/poolse/callcore/android/SnapshotJson.kt +15 -0
- package/android/src/main/java/expo/modules/poolsecalls/PoolseCallVideoView.kt +64 -0
- package/android/src/main/java/expo/modules/poolsecalls/PoolseCallsModule.kt +10 -0
- package/android/src/main/livekit/java/dev/poolse/callcore/livekit/LiveKitMediaPort.kt +142 -14
- package/android/src/main/livekit/java/dev/poolse/callcore/livekit/LiveKitVideoSurface.kt +107 -0
- package/dist/index.cjs +34 -0
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +44 -2
- package/dist/index.d.ts +44 -2
- package/dist/index.js +33 -2
- package/dist/index.js.map +1 -1
- package/expo-module.config.json +6 -1
- package/ios/Core/CallCoreRuntime.swift +393 -0
- package/ios/Core/CallHttpClient.swift +210 -0
- package/ios/Core/CallMachine.swift +304 -0
- package/ios/Core/PORTS_READY.md +93 -0
- package/ios/Core/Ports.swift +193 -0
- package/ios/Core/SnapshotEncoding.swift +75 -0
- package/ios/Module/PoolseCallVideoView.swift +62 -0
- package/ios/Module/PoolseCallsAppDelegateSubscriber.swift +16 -0
- package/ios/Module/PoolseCallsModule.swift +111 -0
- package/ios/Platform/CallCore.swift +464 -0
- package/ios/Platform/CallKitController.swift +263 -0
- package/ios/Platform/LiveKitMediaPort.swift +445 -0
- package/ios/Platform/LiveKitVideoSurface.swift +67 -0
- package/ios/Platform/Ringback.swift +61 -0
- package/ios/Platform/VoipPush.swift +172 -0
- package/ios/PoolseCalls.podspec +64 -0
- package/package.json +7 -1
- package/src/expo-modules-core.d.ts +5 -0
- package/src/index.ts +85 -2
- package/src/react-shims.d.ts +22 -0
|
@@ -67,6 +67,7 @@ class CallCore private constructor(context: Context) {
|
|
|
67
67
|
private var conversationName: String? = null
|
|
68
68
|
private var mutedSelf = false
|
|
69
69
|
private var cameraOn = false
|
|
70
|
+
private var cameraFacing: String? = null
|
|
70
71
|
private var audioRoute: String? = null
|
|
71
72
|
|
|
72
73
|
/** Last snapshot emitted; read by the bridge's getCallState from any thread. */
|
|
@@ -146,14 +147,22 @@ class CallCore private constructor(context: Context) {
|
|
|
146
147
|
runtime.reemit()
|
|
147
148
|
}
|
|
148
149
|
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
150
|
+
/**
|
|
151
|
+
* Not optimistic: `cameraOn` in the snapshot flips only when the
|
|
152
|
+
* media adapter reports the local camera track actually published
|
|
153
|
+
* (it may be refused — e.g. no CAMERA permission).
|
|
154
|
+
*/
|
|
155
|
+
fun setCamera(on: Boolean) = runtime.setCamera(on)
|
|
154
156
|
|
|
155
157
|
fun switchCamera() = runtime.switchCamera()
|
|
156
158
|
|
|
159
|
+
/**
|
|
160
|
+
* A video slot for `<CallVideoView>`, or null when the media engine
|
|
161
|
+
* renders no video (signalling-only build). Main thread.
|
|
162
|
+
*/
|
|
163
|
+
fun createVideoSurface(context: Context): CallVideoSurface? =
|
|
164
|
+
(media as? CallVideoSource)?.createVideoSurface(context)
|
|
165
|
+
|
|
157
166
|
fun setSpeaker(on: Boolean) {
|
|
158
167
|
// Optimistic; the media adapter reports the route it actually got.
|
|
159
168
|
runtime.post { audioRoute = if (on) "speaker" else "earpiece" }
|
|
@@ -188,7 +197,6 @@ class CallCore private constructor(context: Context) {
|
|
|
188
197
|
// The server corrected the media (a join defaults to audio until
|
|
189
198
|
// its bundle says otherwise): re-derive what depends on it.
|
|
190
199
|
if (extrasMedia != null) {
|
|
191
|
-
cameraOn = state.media == "video"
|
|
192
200
|
audioRoute = null
|
|
193
201
|
CallForegroundService.updateCall(state.callId ?: "", state.media)
|
|
194
202
|
}
|
|
@@ -210,6 +218,7 @@ class CallCore private constructor(context: Context) {
|
|
|
210
218
|
audioRoute = audioRoute ?: if (state.media == "video") "speaker" else "earpiece",
|
|
211
219
|
mutedSelf = mutedSelf,
|
|
212
220
|
cameraOn = cameraOn,
|
|
221
|
+
cameraFacing = cameraFacing,
|
|
213
222
|
)
|
|
214
223
|
lastSnapshot = snap
|
|
215
224
|
reconcileAndroid(state)
|
|
@@ -222,8 +231,9 @@ class CallCore private constructor(context: Context) {
|
|
|
222
231
|
roster = emptyList()
|
|
223
232
|
conversationName = null
|
|
224
233
|
mutedSelf = false
|
|
225
|
-
//
|
|
226
|
-
cameraOn =
|
|
234
|
+
// Reported by the media adapter once the camera is really published.
|
|
235
|
+
cameraOn = false
|
|
236
|
+
cameraFacing = null
|
|
227
237
|
audioRoute = null
|
|
228
238
|
}
|
|
229
239
|
|
|
@@ -286,6 +296,10 @@ class CallCore private constructor(context: Context) {
|
|
|
286
296
|
runtime.post { audioRoute = route }
|
|
287
297
|
runtime.reemit()
|
|
288
298
|
}
|
|
299
|
+
override fun onLocalCamera(on: Boolean, facing: String?) {
|
|
300
|
+
runtime.post { cameraOn = on; cameraFacing = facing }
|
|
301
|
+
runtime.reemit()
|
|
302
|
+
}
|
|
289
303
|
}
|
|
290
304
|
|
|
291
305
|
private fun bringAppToFront(callId: String, from: Context?): Boolean {
|
|
@@ -450,6 +464,8 @@ interface MediaEvents {
|
|
|
450
464
|
fun onParticipants(participants: List<MediaParticipant>)
|
|
451
465
|
/** `earpiece` | `speaker` | `bluetooth` | `headset`. */
|
|
452
466
|
fun onAudioRoute(route: String)
|
|
467
|
+
/** The local camera track is (not) published + enabled; [facing] `front` | `back`. */
|
|
468
|
+
fun onLocalCamera(on: Boolean, facing: String?) {}
|
|
453
469
|
}
|
|
454
470
|
|
|
455
471
|
data class MediaParticipant(
|
|
@@ -457,6 +473,7 @@ data class MediaParticipant(
|
|
|
457
473
|
val identity: String,
|
|
458
474
|
val speaking: Boolean,
|
|
459
475
|
val muted: Boolean,
|
|
476
|
+
/** True only with a published, subscribed, unmuted camera track — a frame to draw. */
|
|
460
477
|
val cameraOn: Boolean = false,
|
|
461
478
|
/** The host app's own id for the user, when the SFU carries it; else the identity. */
|
|
462
479
|
val externalId: String = identity,
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
package dev.poolse.callcore.android
|
|
2
|
+
|
|
3
|
+
import android.content.Context
|
|
4
|
+
import android.view.View
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Video rendering, kept SFU-agnostic: a [dev.poolse.callcore.MediaPort]
|
|
8
|
+
* that can render video ALSO implements this; the bridge's view asks
|
|
9
|
+
* [CallCore.createVideoSurface] and never sees the SFU's types. A port
|
|
10
|
+
* without video (the signalling-only fallback) simply doesn't implement
|
|
11
|
+
* it, and the view stays empty.
|
|
12
|
+
*
|
|
13
|
+
* Main thread only — surfaces live in the view hierarchy, and the media
|
|
14
|
+
* adapter keeps its room on main.
|
|
15
|
+
*/
|
|
16
|
+
interface CallVideoSource {
|
|
17
|
+
fun createVideoSurface(context: Context): CallVideoSurface
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** One on-screen video slot, bound to a participant's camera. */
|
|
21
|
+
interface CallVideoSurface {
|
|
22
|
+
/** The Android view to host; it fills its parent. */
|
|
23
|
+
val view: View
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* What to show. [participantId] is the remote's userId (SFU
|
|
27
|
+
* identity); [local] shows the self view instead. [mirror] null =
|
|
28
|
+
* the default (mirrored for the local front camera only). [fit] is
|
|
29
|
+
* `cover` or `contain`. Rebinds immediately and again whenever the
|
|
30
|
+
* underlying track appears, disappears, mutes or the room is replaced.
|
|
31
|
+
*/
|
|
32
|
+
fun configure(participantId: String?, local: Boolean, mirror: Boolean?, fit: String)
|
|
33
|
+
|
|
34
|
+
/** Detach from any track and free the renderer. Idempotent. */
|
|
35
|
+
fun release()
|
|
36
|
+
}
|
|
@@ -20,8 +20,23 @@ internal fun MachineState.toSnapshot(
|
|
|
20
20
|
audioRoute: String = "earpiece",
|
|
21
21
|
mutedSelf: Boolean = false,
|
|
22
22
|
cameraOn: Boolean = false,
|
|
23
|
+
cameraFacing: String? = null,
|
|
23
24
|
): Map<String, Any?>? {
|
|
24
25
|
if (phase == Phase.IDLE) return null
|
|
26
|
+
return buildMap {
|
|
27
|
+
putAll(snapshotCore(conversationName, participants, audioRoute, mutedSelf, cameraOn))
|
|
28
|
+
// Optional in the contract: present only while a camera is published.
|
|
29
|
+
if (cameraFacing != null) put("cameraFacing", cameraFacing)
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
private fun MachineState.snapshotCore(
|
|
34
|
+
conversationName: String?,
|
|
35
|
+
participants: List<Map<String, Any>>,
|
|
36
|
+
audioRoute: String,
|
|
37
|
+
mutedSelf: Boolean,
|
|
38
|
+
cameraOn: Boolean,
|
|
39
|
+
): Map<String, Any?> {
|
|
25
40
|
return mapOf(
|
|
26
41
|
"cv" to 1,
|
|
27
42
|
"callId" to callId,
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
package expo.modules.poolsecalls
|
|
2
|
+
|
|
3
|
+
import android.content.Context
|
|
4
|
+
import android.view.ViewGroup
|
|
5
|
+
import dev.poolse.callcore.android.CallCore
|
|
6
|
+
import dev.poolse.callcore.android.CallVideoSurface
|
|
7
|
+
import expo.modules.kotlin.AppContext
|
|
8
|
+
import expo.modules.kotlin.views.ExpoView
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* `<CallVideoView>` on Android: a thin host for the core's
|
|
12
|
+
* [CallVideoSurface]. All binding logic (which track, rebinds on
|
|
13
|
+
* subscribe/mute/rejoin) lives in the media adapter; this view only
|
|
14
|
+
* forwards props and ties the surface's lifetime to the window.
|
|
15
|
+
*/
|
|
16
|
+
class PoolseCallVideoView(context: Context, appContext: AppContext) : ExpoView(context, appContext) {
|
|
17
|
+
|
|
18
|
+
// The renderer is a native child React does not lay out: let Android
|
|
19
|
+
// measure it (and re-measure when the video's size changes).
|
|
20
|
+
override val shouldUseAndroidLayout = true
|
|
21
|
+
|
|
22
|
+
private var surface: CallVideoSurface? = null
|
|
23
|
+
|
|
24
|
+
var participantId: String? = null
|
|
25
|
+
set(v) { field = v; apply() }
|
|
26
|
+
var local: Boolean = false
|
|
27
|
+
set(v) { field = v; apply() }
|
|
28
|
+
var mirror: Boolean? = null
|
|
29
|
+
set(v) { field = v; apply() }
|
|
30
|
+
var fit: String = "cover"
|
|
31
|
+
set(v) { field = v; apply() }
|
|
32
|
+
|
|
33
|
+
override fun onAttachedToWindow() {
|
|
34
|
+
super.onAttachedToWindow()
|
|
35
|
+
attachSurface()
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
override fun onDetachedFromWindow() {
|
|
39
|
+
detachSurface()
|
|
40
|
+
super.onDetachedFromWindow()
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
private fun attachSurface() {
|
|
44
|
+
if (surface != null) return
|
|
45
|
+
// No core yet (never configured) or no video-capable SFU: stay empty.
|
|
46
|
+
val s = CallCore.peek()?.createVideoSurface(context) ?: return
|
|
47
|
+
surface = s
|
|
48
|
+
addView(s.view, LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT))
|
|
49
|
+
apply()
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
internal fun detachSurface() {
|
|
53
|
+
val s = surface ?: return
|
|
54
|
+
surface = null
|
|
55
|
+
s.release()
|
|
56
|
+
removeView(s.view)
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
private fun apply() {
|
|
60
|
+
// Mounted before the core existed (configure still pending): try again.
|
|
61
|
+
val s = surface ?: run { if (isAttachedToWindow) attachSurface(); return }
|
|
62
|
+
s.configure(participantId, local, mirror, if (fit == "contain") "contain" else "cover")
|
|
63
|
+
}
|
|
64
|
+
}
|
|
@@ -32,6 +32,16 @@ class PoolseCallsModule : Module() {
|
|
|
32
32
|
|
|
33
33
|
Events("onCallState")
|
|
34
34
|
|
|
35
|
+
// <CallVideoView>: renders a participant's (or our own) camera.
|
|
36
|
+
View(PoolseCallVideoView::class) {
|
|
37
|
+
Name("PoolseCallVideo")
|
|
38
|
+
Prop("participantId") { view: PoolseCallVideoView, id: String? -> view.participantId = id }
|
|
39
|
+
Prop("local") { view: PoolseCallVideoView, local: Boolean? -> view.local = local ?: false }
|
|
40
|
+
Prop("mirror") { view: PoolseCallVideoView, mirror: Boolean? -> view.mirror = mirror }
|
|
41
|
+
Prop("fit") { view: PoolseCallVideoView, fit: String? -> view.fit = fit ?: "cover" }
|
|
42
|
+
OnViewDestroys { view: PoolseCallVideoView -> view.detachSurface() }
|
|
43
|
+
}
|
|
44
|
+
|
|
35
45
|
// Snapshots → the single JS event. Null (idle) becomes an explicit
|
|
36
46
|
// cleared payload so the JS side always has a definite state. The
|
|
37
47
|
// listener is process-level, so subscribing before configure works.
|
|
@@ -1,12 +1,18 @@
|
|
|
1
1
|
package dev.poolse.callcore.livekit
|
|
2
2
|
|
|
3
|
+
import android.Manifest
|
|
3
4
|
import android.content.Context
|
|
5
|
+
import android.content.pm.PackageManager
|
|
4
6
|
import android.util.Log
|
|
7
|
+
import androidx.core.content.ContextCompat
|
|
5
8
|
import com.twilio.audioswitch.AudioDevice
|
|
6
9
|
import dev.poolse.callcore.CallSessionBundle
|
|
7
10
|
import dev.poolse.callcore.MediaPort
|
|
11
|
+
import dev.poolse.callcore.android.CallVideoSource
|
|
12
|
+
import dev.poolse.callcore.android.CallVideoSurface
|
|
8
13
|
import dev.poolse.callcore.android.MediaEvents
|
|
9
14
|
import dev.poolse.callcore.android.MediaParticipant
|
|
15
|
+
import io.livekit.android.ConnectOptions
|
|
10
16
|
import io.livekit.android.LiveKit
|
|
11
17
|
import io.livekit.android.audio.AudioSwitchHandler
|
|
12
18
|
import io.livekit.android.events.DisconnectReason
|
|
@@ -14,11 +20,17 @@ import io.livekit.android.events.RoomEvent
|
|
|
14
20
|
import io.livekit.android.events.collect
|
|
15
21
|
import io.livekit.android.room.Room
|
|
16
22
|
import io.livekit.android.room.participant.Participant
|
|
23
|
+
import io.livekit.android.room.track.CameraPosition
|
|
24
|
+
import io.livekit.android.room.track.LocalVideoTrack
|
|
25
|
+
import io.livekit.android.room.track.RemoteTrackPublication
|
|
26
|
+
import io.livekit.android.room.track.Track
|
|
27
|
+
import io.livekit.android.room.track.VideoTrack
|
|
17
28
|
import kotlinx.coroutines.CoroutineScope
|
|
18
29
|
import kotlinx.coroutines.Dispatchers
|
|
19
30
|
import kotlinx.coroutines.Job
|
|
20
31
|
import kotlinx.coroutines.SupervisorJob
|
|
21
32
|
import kotlinx.coroutines.cancel
|
|
33
|
+
import kotlinx.coroutines.delay
|
|
22
34
|
import kotlinx.coroutines.launch
|
|
23
35
|
import org.json.JSONObject
|
|
24
36
|
|
|
@@ -42,7 +54,7 @@ import org.json.JSONObject
|
|
|
42
54
|
class LiveKitMediaPort(
|
|
43
55
|
private val context: Context,
|
|
44
56
|
private val events: MediaEvents,
|
|
45
|
-
) : MediaPort {
|
|
57
|
+
) : MediaPort, CallVideoSource {
|
|
46
58
|
|
|
47
59
|
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate)
|
|
48
60
|
@Volatile private var room: Room? = null
|
|
@@ -62,6 +74,9 @@ class LiveKitMediaPort(
|
|
|
62
74
|
private var cameraWanted: Boolean? = null
|
|
63
75
|
private var joinedCallId: String? = null
|
|
64
76
|
|
|
77
|
+
/** Live `<CallVideoView>` slots; rebound whenever what they show may have changed. */
|
|
78
|
+
private val surfaces = linkedSetOf<LiveKitVideoSurface>()
|
|
79
|
+
|
|
65
80
|
override fun join(bundle: CallSessionBundle) {
|
|
66
81
|
// Rejoin of the same call keeps the user's choices; so do choices
|
|
67
82
|
// made while this call was still connecting (joinedCallId null). A
|
|
@@ -73,26 +88,55 @@ class LiveKitMediaPort(
|
|
|
73
88
|
val wantsVideo = bundle.media == "video"
|
|
74
89
|
val newRoom = LiveKit.create(appContext = context.applicationContext)
|
|
75
90
|
configureAudio(newRoom, wantsVideo)
|
|
91
|
+
// A 1:1/small call: never let the SFU pause a subscribed camera for
|
|
92
|
+
// "not visible" — a track whose view mounts late would stay black.
|
|
93
|
+
// (Renderers are still registered with visibility via addRenderer.)
|
|
94
|
+
newRoom.adaptiveStream = false
|
|
95
|
+
newRoom.dynacast = false
|
|
76
96
|
room = newRoom
|
|
97
|
+
refreshVideo() // surfaces re-initialise against the new room's EGL
|
|
77
98
|
eventJob = scope.launch { observe(newRoom) }
|
|
78
99
|
scope.launch {
|
|
79
|
-
runCatching {
|
|
80
|
-
newRoom.connect(bundle.livekitUrl, bundle.livekitToken)
|
|
81
|
-
if (room !== newRoom) return@runCatching
|
|
82
|
-
newRoom.localParticipant.setMicrophoneEnabled(!mutedWanted)
|
|
83
|
-
if (cameraWanted ?: wantsVideo) newRoom.localParticipant.setCameraEnabled(true)
|
|
100
|
+
val connected = runCatching {
|
|
101
|
+
newRoom.connect(bundle.livekitUrl, bundle.livekitToken, ConnectOptions(autoSubscribe = true))
|
|
84
102
|
}.onFailure {
|
|
85
103
|
Log.w(TAG, "connect failed", it)
|
|
86
104
|
if (room === newRoom) {
|
|
87
105
|
// Don't leave a half-open room (and its audio mode) behind.
|
|
88
106
|
eventJob?.cancel()
|
|
89
107
|
eventJob = null
|
|
90
|
-
teardown(newRoom)
|
|
91
108
|
room = null
|
|
109
|
+
refreshVideo()
|
|
110
|
+
teardown(newRoom)
|
|
92
111
|
events.onFailed()
|
|
93
112
|
}
|
|
94
|
-
}
|
|
113
|
+
}.isSuccess
|
|
114
|
+
if (!connected || room !== newRoom) return@launch
|
|
115
|
+
// Publishing failures must not fail the call: audio-only is a call.
|
|
116
|
+
runCatching { newRoom.localParticipant.setMicrophoneEnabled(!mutedWanted) }
|
|
117
|
+
.onFailure { Log.w(TAG, "microphone publish failed", it) }
|
|
118
|
+
if (cameraWanted ?: wantsVideo) enableCamera(newRoom, true)
|
|
119
|
+
reportLocalCamera(newRoom)
|
|
120
|
+
refreshVideo()
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
private fun cameraGranted() =
|
|
125
|
+
ContextCompat.checkSelfPermission(context, Manifest.permission.CAMERA) ==
|
|
126
|
+
PackageManager.PERMISSION_GRANTED
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Publish / unpublish the camera. Without the CAMERA grant a video
|
|
130
|
+
* call joins audio-only instead of failing; a later setCamera(true)
|
|
131
|
+
* after the user grants it publishes then.
|
|
132
|
+
*/
|
|
133
|
+
private suspend fun enableCamera(r: Room, on: Boolean) {
|
|
134
|
+
if (on && !cameraGranted()) {
|
|
135
|
+
Log.i(TAG, "CAMERA not granted: staying audio-only")
|
|
136
|
+
return
|
|
95
137
|
}
|
|
138
|
+
runCatching { r.localParticipant.setCameraEnabled(on) }
|
|
139
|
+
.onFailure { Log.w(TAG, "camera ${if (on) "publish" else "unpublish"} failed", it) }
|
|
96
140
|
}
|
|
97
141
|
|
|
98
142
|
/** The call is over for this device: drop the room AND the call's choices. */
|
|
@@ -114,7 +158,10 @@ class LiveKitMediaPort(
|
|
|
114
158
|
eventJob = null
|
|
115
159
|
val r = room ?: return
|
|
116
160
|
room = null
|
|
161
|
+
// Unbind every view from this room's tracks BEFORE its EGL is released.
|
|
162
|
+
refreshVideo()
|
|
117
163
|
teardown(r)
|
|
164
|
+
events.onLocalCamera(false, null)
|
|
118
165
|
}
|
|
119
166
|
|
|
120
167
|
private fun teardown(r: Room) {
|
|
@@ -134,12 +181,78 @@ class LiveKitMediaPort(
|
|
|
134
181
|
override fun setCamera(on: Boolean) {
|
|
135
182
|
cameraWanted = on
|
|
136
183
|
val r = room ?: return
|
|
137
|
-
scope.launch {
|
|
184
|
+
scope.launch {
|
|
185
|
+
enableCamera(r, on)
|
|
186
|
+
if (room !== r) return@launch
|
|
187
|
+
reportLocalCamera(r)
|
|
188
|
+
refreshVideo()
|
|
189
|
+
}
|
|
138
190
|
}
|
|
139
191
|
|
|
192
|
+
/** Front ↔ back on the published camera track. */
|
|
140
193
|
override fun switchCamera() {
|
|
141
|
-
|
|
142
|
-
|
|
194
|
+
val r = room ?: return
|
|
195
|
+
val track = localCameraTrack(r) ?: return
|
|
196
|
+
val next = if (track.options.position == CameraPosition.BACK) CameraPosition.FRONT else CameraPosition.BACK
|
|
197
|
+
runCatching { track.switchCamera(null, next) }.onFailure { Log.w(TAG, "switchCamera failed", it) }
|
|
198
|
+
// The capturer switches asynchronously and updates the options when
|
|
199
|
+
// done: report (and re-mirror the self view) once it has.
|
|
200
|
+
scope.launch {
|
|
201
|
+
delay(SWITCH_SETTLE_MS)
|
|
202
|
+
if (room !== r) return@launch
|
|
203
|
+
reportLocalCamera(r)
|
|
204
|
+
refreshVideo()
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// ── video ───────────────────────────────────────────────────────────
|
|
209
|
+
|
|
210
|
+
override fun createVideoSurface(context: Context): CallVideoSurface =
|
|
211
|
+
LiveKitVideoSurface(context, this).also { surfaces += it }
|
|
212
|
+
|
|
213
|
+
internal fun unregister(surface: LiveKitVideoSurface) { surfaces -= surface }
|
|
214
|
+
|
|
215
|
+
internal fun currentRoom(): Room? = room
|
|
216
|
+
|
|
217
|
+
private fun refreshVideo() {
|
|
218
|
+
for (s in surfaces.toList()) s.rebind()
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
private fun localCameraTrack(r: Room): LocalVideoTrack? =
|
|
222
|
+
r.localParticipant.getTrackPublication(Track.Source.CAMERA)
|
|
223
|
+
?.takeIf { !it.muted }?.track as? LocalVideoTrack
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* The camera track a view should show: the local one, or the remote
|
|
227
|
+
* participant's — only once it is published, SUBSCRIBED and unmuted.
|
|
228
|
+
*/
|
|
229
|
+
internal fun cameraTrack(participantId: String?, local: Boolean): VideoTrack? {
|
|
230
|
+
val r = room ?: return null
|
|
231
|
+
if (local) return localCameraTrack(r)
|
|
232
|
+
val p = r.remoteParticipants.values.firstOrNull { it.identity?.value == participantId } ?: return null
|
|
233
|
+
return remoteCamera(p)
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
private fun remoteCamera(p: Participant): VideoTrack? {
|
|
237
|
+
val pub = p.getTrackPublication(Track.Source.CAMERA) as? RemoteTrackPublication ?: return null
|
|
238
|
+
if (!pub.subscribed || pub.muted) return null
|
|
239
|
+
return pub.track as? VideoTrack
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/** `front` | `back` of the published local camera, or null when none. */
|
|
243
|
+
internal fun cameraFacing(): String? {
|
|
244
|
+
val r = room ?: return null
|
|
245
|
+
return when (localCameraTrack(r)?.options?.position) {
|
|
246
|
+
CameraPosition.FRONT -> "front"
|
|
247
|
+
CameraPosition.BACK -> "back"
|
|
248
|
+
null -> null
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
private fun reportLocalCamera(r: Room) {
|
|
253
|
+
if (room !== r) return
|
|
254
|
+
val on = localCameraTrack(r) != null
|
|
255
|
+
events.onLocalCamera(on, if (on) cameraFacing() else null)
|
|
143
256
|
}
|
|
144
257
|
|
|
145
258
|
override fun setSpeaker(on: Boolean) {
|
|
@@ -219,6 +332,7 @@ class LiveKitMediaPort(
|
|
|
219
332
|
// answered before our media came up): they will not "connect".
|
|
220
333
|
if (room.remoteParticipants.isNotEmpty()) events.onRemoteJoined()
|
|
221
334
|
emit(room)
|
|
335
|
+
refreshVideo()
|
|
222
336
|
}
|
|
223
337
|
// LiveKit is resuming the SAME session (ICE restart / signal
|
|
224
338
|
// resume). Only reflect it: tearing the room down here fought
|
|
@@ -230,13 +344,23 @@ class LiveKitMediaPort(
|
|
|
230
344
|
is RoomEvent.Disconnected -> {
|
|
231
345
|
if (event.reason != DisconnectReason.CLIENT_INITIATED) events.onLost()
|
|
232
346
|
}
|
|
233
|
-
is RoomEvent.ParticipantConnected -> { events.onRemoteJoined(); emit(room) }
|
|
347
|
+
is RoomEvent.ParticipantConnected -> { events.onRemoteJoined(); emit(room); refreshVideo() }
|
|
348
|
+
// Anything that can make a camera appear, vanish or freeze:
|
|
349
|
+
// update the roster's cameraOn, the local camera state, and
|
|
350
|
+
// rebind every video view.
|
|
234
351
|
is RoomEvent.ParticipantDisconnected,
|
|
235
|
-
is RoomEvent.
|
|
352
|
+
is RoomEvent.TrackSubscribed,
|
|
353
|
+
is RoomEvent.TrackUnsubscribed,
|
|
236
354
|
is RoomEvent.TrackMuted,
|
|
237
355
|
is RoomEvent.TrackUnmuted,
|
|
238
356
|
is RoomEvent.TrackPublished,
|
|
239
357
|
is RoomEvent.TrackUnpublished,
|
|
358
|
+
is RoomEvent.LocalTrackSubscribed -> {
|
|
359
|
+
emit(room)
|
|
360
|
+
reportLocalCamera(room)
|
|
361
|
+
refreshVideo()
|
|
362
|
+
}
|
|
363
|
+
is RoomEvent.ActiveSpeakersChanged,
|
|
240
364
|
is RoomEvent.ParticipantMetadataChanged,
|
|
241
365
|
is RoomEvent.ParticipantAttributesChanged -> emit(room)
|
|
242
366
|
else -> Unit
|
|
@@ -254,7 +378,9 @@ class LiveKitMediaPort(
|
|
|
254
378
|
identity = identity,
|
|
255
379
|
speaking = it.isSpeaking,
|
|
256
380
|
muted = !it.isMicrophoneEnabled(),
|
|
257
|
-
|
|
381
|
+
// Video, not avatar, only when there is a frame to draw:
|
|
382
|
+
// published, subscribed and unmuted.
|
|
383
|
+
cameraOn = remoteCamera(it) != null,
|
|
258
384
|
externalId = externalIdOf(it) ?: identity,
|
|
259
385
|
)
|
|
260
386
|
},
|
|
@@ -270,11 +396,13 @@ class LiveKitMediaPort(
|
|
|
270
396
|
|
|
271
397
|
fun dispose() {
|
|
272
398
|
leave()
|
|
399
|
+
for (s in surfaces.toList()) s.release()
|
|
273
400
|
scope.cancel()
|
|
274
401
|
}
|
|
275
402
|
|
|
276
403
|
companion object {
|
|
277
404
|
private const val TAG = "PoolseLiveKit"
|
|
405
|
+
private const val SWITCH_SETTLE_MS = 600L
|
|
278
406
|
|
|
279
407
|
/** Pass as `mediaFactory` in CallCoreConfig. */
|
|
280
408
|
val factory: (Context, MediaEvents) -> MediaPort = { ctx, ev -> LiveKitMediaPort(ctx, ev) }
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
package dev.poolse.callcore.livekit
|
|
2
|
+
|
|
3
|
+
import android.content.Context
|
|
4
|
+
import android.util.Log
|
|
5
|
+
import android.view.View
|
|
6
|
+
import dev.poolse.callcore.android.CallVideoSurface
|
|
7
|
+
import io.livekit.android.renderer.TextureViewRenderer
|
|
8
|
+
import io.livekit.android.room.Room
|
|
9
|
+
import io.livekit.android.room.track.VideoTrack
|
|
10
|
+
import livekit.org.webrtc.RendererCommon
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* A [CallVideoSurface] over LiveKit's [TextureViewRenderer]. TextureView,
|
|
14
|
+
* not SurfaceView: a local picture-in-picture overlapping a full-screen
|
|
15
|
+
* remote composes like any view, with no SurfaceView z-order fights.
|
|
16
|
+
*
|
|
17
|
+
* The renderer is initialised against the CURRENT room's EGL context
|
|
18
|
+
* ([Room.initVideoRenderer]); a rejoin builds a new room, so [rebind]
|
|
19
|
+
* re-initialises it. Tracks are attached with [VideoTrack.addRenderer]
|
|
20
|
+
* — for a remote track that registers the view's visibility with the
|
|
21
|
+
* SDK, so adaptive stream (if ever enabled) resumes the track as soon as
|
|
22
|
+
* the view is on screen instead of keeping it paused.
|
|
23
|
+
*
|
|
24
|
+
* Main thread only, like the port that owns it.
|
|
25
|
+
*/
|
|
26
|
+
internal class LiveKitVideoSurface(
|
|
27
|
+
context: Context,
|
|
28
|
+
private val port: LiveKitMediaPort,
|
|
29
|
+
) : CallVideoSurface {
|
|
30
|
+
|
|
31
|
+
private val renderer = TextureViewRenderer(context)
|
|
32
|
+
override val view: View get() = renderer
|
|
33
|
+
|
|
34
|
+
private var initRoom: Room? = null
|
|
35
|
+
private var track: VideoTrack? = null
|
|
36
|
+
private var released = false
|
|
37
|
+
|
|
38
|
+
private var participantId: String? = null
|
|
39
|
+
private var local = false
|
|
40
|
+
private var mirror: Boolean? = null
|
|
41
|
+
private var fit = "cover"
|
|
42
|
+
|
|
43
|
+
override fun configure(participantId: String?, local: Boolean, mirror: Boolean?, fit: String) {
|
|
44
|
+
this.participantId = participantId
|
|
45
|
+
this.local = local
|
|
46
|
+
this.mirror = mirror
|
|
47
|
+
this.fit = fit
|
|
48
|
+
rebind()
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Re-resolve the track (and the room) this slot should show. */
|
|
52
|
+
fun rebind() {
|
|
53
|
+
if (released) return
|
|
54
|
+
val room = port.currentRoom()
|
|
55
|
+
if (room !== initRoom) {
|
|
56
|
+
// Detach from the old room's track BEFORE its EGL goes away.
|
|
57
|
+
detachTrack()
|
|
58
|
+
if (initRoom != null) runCatching { renderer.release() }
|
|
59
|
+
initRoom = null
|
|
60
|
+
if (room != null) {
|
|
61
|
+
runCatching { room.initVideoRenderer(renderer); initRoom = room }
|
|
62
|
+
.onFailure { Log.w(TAG, "initVideoRenderer failed", it) }
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
val next = if (initRoom != null) port.cameraTrack(participantId, local) else null
|
|
67
|
+
if (next !== track) {
|
|
68
|
+
detachTrack()
|
|
69
|
+
if (next != null) {
|
|
70
|
+
runCatching { next.addRenderer(renderer); track = next }
|
|
71
|
+
.onFailure { Log.w(TAG, "addRenderer failed", it) }
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
// No camera: hide the renderer so its last frozen frame never shows;
|
|
75
|
+
// the UI draws the avatar from the snapshot's cameraOn.
|
|
76
|
+
renderer.visibility = if (track != null) View.VISIBLE else View.INVISIBLE
|
|
77
|
+
if (initRoom != null) {
|
|
78
|
+
runCatching {
|
|
79
|
+
renderer.setMirror(mirror ?: (local && port.cameraFacing() != "back"))
|
|
80
|
+
renderer.setScalingType(
|
|
81
|
+
if (fit == "contain") RendererCommon.ScalingType.SCALE_ASPECT_FIT
|
|
82
|
+
else RendererCommon.ScalingType.SCALE_ASPECT_FILL,
|
|
83
|
+
)
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
private fun detachTrack() {
|
|
89
|
+
val t = track ?: return
|
|
90
|
+
track = null
|
|
91
|
+
runCatching { t.removeRenderer(renderer) }
|
|
92
|
+
runCatching { renderer.clearImage() }
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
override fun release() {
|
|
96
|
+
if (released) return
|
|
97
|
+
released = true
|
|
98
|
+
detachTrack()
|
|
99
|
+
if (initRoom != null) runCatching { renderer.release() }
|
|
100
|
+
initRoom = null
|
|
101
|
+
port.unregister(this)
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
private companion object {
|
|
105
|
+
const val TAG = "PoolseCallVideo"
|
|
106
|
+
}
|
|
107
|
+
}
|
package/dist/index.cjs
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
|
+
var react = require('react');
|
|
4
|
+
var reactNative = require('react-native');
|
|
3
5
|
var expoModulesCore = require('expo-modules-core');
|
|
4
6
|
|
|
5
7
|
// src/index.ts
|
|
@@ -33,18 +35,50 @@ var onRingEnded = (callId, reason) => native?.onRingEnded(callId, reason);
|
|
|
33
35
|
var onCallFinished = (callId, reason) => native?.onCallFinished(callId, reason ?? null);
|
|
34
36
|
var canUseFullScreenIntent = () => native?.canUseFullScreenIntent() ?? true;
|
|
35
37
|
var openFullScreenIntentSettings = () => native?.openFullScreenIntentSettings();
|
|
38
|
+
function getVoipToken() {
|
|
39
|
+
return native?.getVoipToken?.() ?? null;
|
|
40
|
+
}
|
|
41
|
+
function onVoipToken(cb) {
|
|
42
|
+
if (!native || !native.getVoipToken) return () => void 0;
|
|
43
|
+
const sub = native.addListener("onVoipToken", cb);
|
|
44
|
+
return () => sub.remove();
|
|
45
|
+
}
|
|
46
|
+
var nativeVideo;
|
|
47
|
+
function nativeVideoView() {
|
|
48
|
+
if (nativeVideo !== void 0) return nativeVideo;
|
|
49
|
+
nativeVideo = null;
|
|
50
|
+
if (native) {
|
|
51
|
+
try {
|
|
52
|
+
nativeVideo = expoModulesCore.requireNativeViewManager("PoolseCalls", "PoolseCallVideo");
|
|
53
|
+
} catch {
|
|
54
|
+
nativeVideo = null;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
return nativeVideo;
|
|
58
|
+
}
|
|
59
|
+
function CallVideoView(props) {
|
|
60
|
+
const Native = nativeVideoView();
|
|
61
|
+
if (!Native) {
|
|
62
|
+
const { participantId: _p, local: _l, mirror: _m, fit: _f, ...rest } = props;
|
|
63
|
+
return react.createElement(reactNative.View, rest);
|
|
64
|
+
}
|
|
65
|
+
return react.createElement(Native, props);
|
|
66
|
+
}
|
|
36
67
|
|
|
68
|
+
exports.CallVideoView = CallVideoView;
|
|
37
69
|
exports.answer = answer;
|
|
38
70
|
exports.canUseFullScreenIntent = canUseFullScreenIntent;
|
|
39
71
|
exports.configure = configure;
|
|
40
72
|
exports.decline = decline;
|
|
41
73
|
exports.getCallState = getCallState;
|
|
74
|
+
exports.getVoipToken = getVoipToken;
|
|
42
75
|
exports.hangUp = hangUp;
|
|
43
76
|
exports.join = join;
|
|
44
77
|
exports.nativeCallsAvailable = nativeCallsAvailable;
|
|
45
78
|
exports.onCallFinished = onCallFinished;
|
|
46
79
|
exports.onCallState = onCallState;
|
|
47
80
|
exports.onRingEnded = onRingEnded;
|
|
81
|
+
exports.onVoipToken = onVoipToken;
|
|
48
82
|
exports.openFullScreenIntentSettings = openFullScreenIntentSettings;
|
|
49
83
|
exports.place = place;
|
|
50
84
|
exports.setCamera = setCamera;
|