@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.
Files changed (34) hide show
  1. package/android/src/main/java/dev/poolse/callcore/android/CallCore.kt +25 -8
  2. package/android/src/main/java/dev/poolse/callcore/android/CallVideo.kt +36 -0
  3. package/android/src/main/java/dev/poolse/callcore/android/SnapshotJson.kt +15 -0
  4. package/android/src/main/java/expo/modules/poolsecalls/PoolseCallVideoView.kt +64 -0
  5. package/android/src/main/java/expo/modules/poolsecalls/PoolseCallsModule.kt +10 -0
  6. package/android/src/main/livekit/java/dev/poolse/callcore/livekit/LiveKitMediaPort.kt +142 -14
  7. package/android/src/main/livekit/java/dev/poolse/callcore/livekit/LiveKitVideoSurface.kt +107 -0
  8. package/dist/index.cjs +34 -0
  9. package/dist/index.cjs.map +1 -1
  10. package/dist/index.d.cts +44 -2
  11. package/dist/index.d.ts +44 -2
  12. package/dist/index.js +33 -2
  13. package/dist/index.js.map +1 -1
  14. package/expo-module.config.json +6 -1
  15. package/ios/Core/CallCoreRuntime.swift +393 -0
  16. package/ios/Core/CallHttpClient.swift +210 -0
  17. package/ios/Core/CallMachine.swift +304 -0
  18. package/ios/Core/PORTS_READY.md +93 -0
  19. package/ios/Core/Ports.swift +193 -0
  20. package/ios/Core/SnapshotEncoding.swift +75 -0
  21. package/ios/Module/PoolseCallVideoView.swift +62 -0
  22. package/ios/Module/PoolseCallsAppDelegateSubscriber.swift +16 -0
  23. package/ios/Module/PoolseCallsModule.swift +111 -0
  24. package/ios/Platform/CallCore.swift +464 -0
  25. package/ios/Platform/CallKitController.swift +263 -0
  26. package/ios/Platform/LiveKitMediaPort.swift +445 -0
  27. package/ios/Platform/LiveKitVideoSurface.swift +67 -0
  28. package/ios/Platform/Ringback.swift +61 -0
  29. package/ios/Platform/VoipPush.swift +172 -0
  30. package/ios/PoolseCalls.podspec +64 -0
  31. package/package.json +7 -1
  32. package/src/expo-modules-core.d.ts +5 -0
  33. package/src/index.ts +85 -2
  34. 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
- fun setCamera(on: Boolean) {
150
- runtime.post { cameraOn = on }
151
- runtime.setCamera(on)
152
- runtime.reemit()
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
- // Video calls publish the camera on join (LiveKitMediaPort).
226
- cameraOn = media == "video"
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 { runCatching { r.localParticipant.setCameraEnabled(on) } }
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
- // The host's VideoTrackView performs the actual flip; exposing the
142
- // control here keeps the port surface complete.
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.ActiveSpeakersChanged,
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
- cameraOn = it.isCameraEnabled(),
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;