@poolse/react-native-calls 0.4.5 → 0.5.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/README.md +5 -0
- package/android/src/main/java/dev/poolse/callcore/CallCoreRuntime.kt +1 -0
- package/android/src/main/java/dev/poolse/callcore/Ports.kt +7 -0
- package/android/src/main/java/dev/poolse/callcore/android/CallCore.kt +28 -0
- package/android/src/main/java/dev/poolse/callcore/android/SnapshotJson.kt +4 -0
- package/android/src/main/java/expo/modules/poolsecalls/PoolseCallsModule.kt +1 -0
- package/android/src/main/livekit/java/dev/poolse/callcore/livekit/LiveKitMediaPort.kt +91 -27
- package/dist/index.cjs +5 -0
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +33 -2
- package/dist/index.d.ts +33 -2
- package/dist/index.js +5 -1
- package/dist/index.js.map +1 -1
- package/ios/Core/CallCoreRuntime.swift +1 -0
- package/ios/Core/Ports.swift +7 -0
- package/ios/Core/SnapshotEncoding.swift +22 -0
- package/ios/Module/PoolseCallsModule.swift +1 -0
- package/ios/Platform/CallCore.swift +14 -0
- package/ios/Platform/LiveKitMediaPort.swift +153 -70
- package/package.json +1 -1
- package/src/index.ts +39 -1
package/README.md
CHANGED
|
@@ -17,6 +17,11 @@ await calls.configure('https://api.poolse.dev', { smallIcon: 'ic_call', ringback
|
|
|
17
17
|
const stop = calls.onCallState((s) => render(s)); // s: Snapshot | null
|
|
18
18
|
await calls.place(conversationId, { media: 'audio', sessionToken });
|
|
19
19
|
calls.answer(); calls.decline(); calls.hangUp();
|
|
20
|
+
|
|
21
|
+
// Audio output: s.audioOutputs lists the phone, the speaker and every
|
|
22
|
+
// connected headset/Bluetooth device by name; s.audioOutputId is the live one.
|
|
23
|
+
calls.setAudioOutput(s.audioOutputs[0].id);
|
|
24
|
+
calls.setSpeaker(true); // the one-tap toggle, still there
|
|
20
25
|
```
|
|
21
26
|
|
|
22
27
|
## Layout
|
|
@@ -194,6 +194,7 @@ class CallCoreRuntime(
|
|
|
194
194
|
fun setCamera(on: Boolean) = post { media.setCamera(on) }
|
|
195
195
|
fun switchCamera() = post { media.switchCamera() }
|
|
196
196
|
fun setSpeaker(on: Boolean) = post { media.setSpeaker(on) }
|
|
197
|
+
fun setAudioOutput(id: String) = post { media.setAudioOutput(id) }
|
|
197
198
|
|
|
198
199
|
/** Re-emit the current state (the bridge's roster/route changed). */
|
|
199
200
|
fun reemit() = post { if (state.phase != Phase.IDLE) sink.emit(state) }
|
|
@@ -67,6 +67,13 @@ interface MediaPort {
|
|
|
67
67
|
* route — earpiece, or a connected headset/Bluetooth device (`false`).
|
|
68
68
|
*/
|
|
69
69
|
fun setSpeaker(on: Boolean)
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Play through one specific output — an id from the snapshot's
|
|
73
|
+
* `audioOutputs`. Engines without device selection fall back to the
|
|
74
|
+
* speaker toggle.
|
|
75
|
+
*/
|
|
76
|
+
fun setAudioOutput(id: String) = setSpeaker(id == "speaker")
|
|
70
77
|
}
|
|
71
78
|
|
|
72
79
|
/** The ring UX: CallStyle notification + full-screen activity + tones. */
|
|
@@ -69,6 +69,8 @@ class CallCore private constructor(context: Context) {
|
|
|
69
69
|
private var cameraOn = false
|
|
70
70
|
private var cameraFacing: String? = null
|
|
71
71
|
private var audioRoute: String? = null
|
|
72
|
+
private var audioOutputs: List<AudioOutput> = emptyList()
|
|
73
|
+
private var audioOutputId: String? = null
|
|
72
74
|
|
|
73
75
|
/** Last snapshot emitted; read by the bridge's getCallState from any thread. */
|
|
74
76
|
@Volatile private var lastSnapshot: Map<String, Any?>? = null
|
|
@@ -170,6 +172,12 @@ class CallCore private constructor(context: Context) {
|
|
|
170
172
|
runtime.reemit()
|
|
171
173
|
}
|
|
172
174
|
|
|
175
|
+
/**
|
|
176
|
+
* Play through one output from the snapshot's `audioOutputs`. Not
|
|
177
|
+
* optimistic: the adapter reports the route once the OS has moved it.
|
|
178
|
+
*/
|
|
179
|
+
fun setAudioOutput(id: String) = runtime.setAudioOutput(id)
|
|
180
|
+
|
|
173
181
|
fun allRingsResolved(outcome: String) = runtime.allRingsResolved(outcome)
|
|
174
182
|
|
|
175
183
|
fun state(): MachineState = runtime.currentState()
|
|
@@ -216,6 +224,8 @@ class CallCore private constructor(context: Context) {
|
|
|
216
224
|
)
|
|
217
225
|
},
|
|
218
226
|
audioRoute = audioRoute ?: if (state.media == "video") "speaker" else "earpiece",
|
|
227
|
+
audioOutputs = audioOutputs.map { mapOf("id" to it.id, "kind" to it.kind, "name" to it.name) },
|
|
228
|
+
audioOutputId = audioOutputId,
|
|
219
229
|
mutedSelf = mutedSelf,
|
|
220
230
|
cameraOn = cameraOn,
|
|
221
231
|
cameraFacing = cameraFacing,
|
|
@@ -235,6 +245,8 @@ class CallCore private constructor(context: Context) {
|
|
|
235
245
|
cameraOn = false
|
|
236
246
|
cameraFacing = null
|
|
237
247
|
audioRoute = null
|
|
248
|
+
audioOutputs = emptyList()
|
|
249
|
+
audioOutputId = null
|
|
238
250
|
}
|
|
239
251
|
|
|
240
252
|
/**
|
|
@@ -300,6 +312,10 @@ class CallCore private constructor(context: Context) {
|
|
|
300
312
|
runtime.post { cameraOn = on; cameraFacing = facing }
|
|
301
313
|
runtime.reemit()
|
|
302
314
|
}
|
|
315
|
+
override fun onAudioOutputs(outputs: List<AudioOutput>, selectedId: String?) {
|
|
316
|
+
runtime.post { audioOutputs = outputs; audioOutputId = selectedId }
|
|
317
|
+
runtime.reemit()
|
|
318
|
+
}
|
|
303
319
|
}
|
|
304
320
|
|
|
305
321
|
private fun bringAppToFront(callId: String, from: Context?): Boolean {
|
|
@@ -466,8 +482,20 @@ interface MediaEvents {
|
|
|
466
482
|
fun onAudioRoute(route: String)
|
|
467
483
|
/** The local camera track is (not) published + enabled; [facing] `front` | `back`. */
|
|
468
484
|
fun onLocalCamera(on: Boolean, facing: String?) {}
|
|
485
|
+
/** Every output call audio can play through right now, and the one it does. */
|
|
486
|
+
fun onAudioOutputs(outputs: List<AudioOutput>, selectedId: String?) {}
|
|
469
487
|
}
|
|
470
488
|
|
|
489
|
+
/** One place call audio can play — a snapshot `audioOutputs[]` entry. */
|
|
490
|
+
data class AudioOutput(
|
|
491
|
+
/** Passed back to `setAudioOutput`; stable while the device stays connected. */
|
|
492
|
+
val id: String,
|
|
493
|
+
/** `earpiece` | `speaker` | `bluetooth` | `headset`. */
|
|
494
|
+
val kind: String,
|
|
495
|
+
/** What the device calls itself ("Galaxy Buds2"), or a plain default for built-ins. */
|
|
496
|
+
val name: String,
|
|
497
|
+
)
|
|
498
|
+
|
|
471
499
|
data class MediaParticipant(
|
|
472
500
|
/** The SFU identity — the poolse user id. */
|
|
473
501
|
val identity: String,
|
|
@@ -18,6 +18,8 @@ internal fun MachineState.toSnapshot(
|
|
|
18
18
|
conversationName: String? = null,
|
|
19
19
|
participants: List<Map<String, Any>> = emptyList(),
|
|
20
20
|
audioRoute: String = "earpiece",
|
|
21
|
+
audioOutputs: List<Map<String, Any>> = emptyList(),
|
|
22
|
+
audioOutputId: String? = null,
|
|
21
23
|
mutedSelf: Boolean = false,
|
|
22
24
|
cameraOn: Boolean = false,
|
|
23
25
|
cameraFacing: String? = null,
|
|
@@ -25,6 +27,8 @@ internal fun MachineState.toSnapshot(
|
|
|
25
27
|
if (phase == Phase.IDLE) return null
|
|
26
28
|
return buildMap {
|
|
27
29
|
putAll(snapshotCore(conversationName, participants, audioRoute, mutedSelf, cameraOn))
|
|
30
|
+
put("audioOutputs", audioOutputs)
|
|
31
|
+
put("audioOutputId", audioOutputId)
|
|
28
32
|
// Optional in the contract: present only while a camera is published.
|
|
29
33
|
if (cameraFacing != null) put("cameraFacing", cameraFacing)
|
|
30
34
|
}
|
|
@@ -85,6 +85,7 @@ class PoolseCallsModule : Module() {
|
|
|
85
85
|
Function("setCamera") { on: Boolean -> core()?.setCamera(on) }
|
|
86
86
|
Function("switchCamera") { core()?.switchCamera() }
|
|
87
87
|
Function("setSpeaker") { on: Boolean -> core()?.setSpeaker(on) }
|
|
88
|
+
Function("setAudioOutput") { id: String -> core()?.setAudioOutput(id) }
|
|
88
89
|
|
|
89
90
|
// Socket forwarding (open app): idempotent with the FCM path.
|
|
90
91
|
Function("onRingEnded") { callId: String, reason: String -> core()?.onRingEnded(callId, reason) }
|
|
@@ -8,6 +8,7 @@ import androidx.core.content.ContextCompat
|
|
|
8
8
|
import com.twilio.audioswitch.AudioDevice
|
|
9
9
|
import dev.poolse.callcore.CallSessionBundle
|
|
10
10
|
import dev.poolse.callcore.MediaPort
|
|
11
|
+
import dev.poolse.callcore.android.AudioOutput
|
|
11
12
|
import dev.poolse.callcore.android.CallVideoSource
|
|
12
13
|
import dev.poolse.callcore.android.CallVideoSurface
|
|
13
14
|
import dev.poolse.callcore.android.MediaEvents
|
|
@@ -61,8 +62,15 @@ class LiveKitMediaPort(
|
|
|
61
62
|
private var eventJob: Job? = null
|
|
62
63
|
private var media: String = "audio"
|
|
63
64
|
|
|
64
|
-
/**
|
|
65
|
-
|
|
65
|
+
/**
|
|
66
|
+
* The user's output choice for the CURRENT call: an output id
|
|
67
|
+
* (`speaker`, `earpiece`, `wired`, `bluetooth`) or [PRIVATE] — the
|
|
68
|
+
* best private route. null = automatic (see [resolve]).
|
|
69
|
+
*/
|
|
70
|
+
private var outputWanted: String? = null
|
|
71
|
+
|
|
72
|
+
/** The current room's last device list; a newcomer in it takes the call. */
|
|
73
|
+
private var knownDevices: List<AudioDevice>? = null
|
|
66
74
|
|
|
67
75
|
/**
|
|
68
76
|
* The user's mute / camera choices for the CURRENT call. They survive
|
|
@@ -149,7 +157,7 @@ class LiveKitMediaPort(
|
|
|
149
157
|
private fun resetWanted() {
|
|
150
158
|
mutedWanted = false
|
|
151
159
|
cameraWanted = null
|
|
152
|
-
|
|
160
|
+
outputWanted = null
|
|
153
161
|
}
|
|
154
162
|
|
|
155
163
|
/** Drop the current room only (a rejoin replaces it). */
|
|
@@ -256,7 +264,13 @@ class LiveKitMediaPort(
|
|
|
256
264
|
}
|
|
257
265
|
|
|
258
266
|
override fun setSpeaker(on: Boolean) {
|
|
259
|
-
|
|
267
|
+
outputWanted = if (on) SPEAKER else PRIVATE
|
|
268
|
+
val handler = room?.audioHandler as? AudioSwitchHandler ?: return
|
|
269
|
+
applyRoute(handler)
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
override fun setAudioOutput(id: String) {
|
|
273
|
+
outputWanted = id
|
|
260
274
|
val handler = room?.audioHandler as? AudioSwitchHandler ?: return
|
|
261
275
|
applyRoute(handler)
|
|
262
276
|
}
|
|
@@ -264,12 +278,13 @@ class LiveKitMediaPort(
|
|
|
264
278
|
// ── audio routing ───────────────────────────────────────────────────
|
|
265
279
|
|
|
266
280
|
/**
|
|
267
|
-
*
|
|
268
|
-
* headset or Bluetooth device first
|
|
269
|
-
*
|
|
281
|
+
* Seed AudioSwitch's own order (it starts during connect, before any
|
|
282
|
+
* choice is applied): a connected headset or Bluetooth device first,
|
|
283
|
+
* then the earpiece for voice or the speaker for video.
|
|
270
284
|
*/
|
|
271
285
|
private fun configureAudio(r: Room, video: Boolean) {
|
|
272
286
|
val handler = r.audioHandler as? AudioSwitchHandler ?: return
|
|
287
|
+
knownDevices = null
|
|
273
288
|
handler.preferredDeviceList = if (video) {
|
|
274
289
|
listOf(
|
|
275
290
|
AudioDevice.BluetoothHeadset::class.java,
|
|
@@ -285,31 +300,78 @@ class LiveKitMediaPort(
|
|
|
285
300
|
AudioDevice.Speakerphone::class.java,
|
|
286
301
|
)
|
|
287
302
|
}
|
|
288
|
-
handler.audioDeviceChangeListener = { devices, selected ->
|
|
289
|
-
|
|
290
|
-
//
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
303
|
+
handler.audioDeviceChangeListener = listener@{ devices, selected ->
|
|
304
|
+
if (room?.audioHandler !== handler) return@listener
|
|
305
|
+
// Putting on a headset or connecting Bluetooth mid-call moves the
|
|
306
|
+
// call to it, whatever was picked before — as the dialer does. The
|
|
307
|
+
// first list of a room is not "new": a choice made while
|
|
308
|
+
// connecting must survive it.
|
|
309
|
+
val known = knownDevices
|
|
310
|
+
if (known != null && devices.any { it.isExternal() && it !in known }) outputWanted = null
|
|
311
|
+
knownDevices = devices
|
|
312
|
+
val target = resolve(devices)
|
|
313
|
+
if (target != null && target != selected) handler.selectDevice(target)
|
|
314
|
+
report(handler, devices, handler.selectedAudioDevice ?: selected)
|
|
298
315
|
}
|
|
299
316
|
}
|
|
300
317
|
|
|
301
318
|
private fun applyRoute(handler: AudioSwitchHandler) {
|
|
302
|
-
val
|
|
303
|
-
|
|
304
|
-
|
|
319
|
+
val devices = handler.availableAudioDevices
|
|
320
|
+
resolve(devices)?.let { if (it != handler.selectedAudioDevice) handler.selectDevice(it) }
|
|
321
|
+
report(handler, devices, handler.selectedAudioDevice)
|
|
305
322
|
}
|
|
306
323
|
|
|
307
|
-
/**
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
324
|
+
/**
|
|
325
|
+
* Where the call should play. A chosen device while it is connected
|
|
326
|
+
* (it is forgotten once it goes); otherwise a connected headset or
|
|
327
|
+
* Bluetooth device, then the speaker for video or the earpiece for
|
|
328
|
+
* voice — never the speaker over earbuds the user put on.
|
|
329
|
+
*/
|
|
330
|
+
private fun resolve(devices: List<AudioDevice>): AudioDevice? {
|
|
331
|
+
val wanted = outputWanted
|
|
332
|
+
if (wanted != null && wanted != PRIVATE) {
|
|
333
|
+
devices.firstOrNull { idOf(it) == wanted }?.let { return it }
|
|
334
|
+
outputWanted = null
|
|
335
|
+
}
|
|
336
|
+
val external = devices.firstOrNull { it is AudioDevice.BluetoothHeadset }
|
|
337
|
+
?: devices.firstOrNull { it is AudioDevice.WiredHeadset }
|
|
338
|
+
val earpiece = devices.firstOrNull { it is AudioDevice.Earpiece }
|
|
339
|
+
val speaker = devices.firstOrNull { it is AudioDevice.Speakerphone }
|
|
340
|
+
return when {
|
|
341
|
+
external != null -> external
|
|
342
|
+
outputWanted == PRIVATE -> earpiece ?: speaker
|
|
343
|
+
media == "video" -> speaker ?: earpiece
|
|
344
|
+
else -> earpiece ?: speaker
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
private fun report(handler: AudioSwitchHandler, devices: List<AudioDevice>, selected: AudioDevice?) {
|
|
349
|
+
if (room?.audioHandler !== handler) return
|
|
350
|
+
events.onAudioRoute(routeOf(selected))
|
|
351
|
+
events.onAudioOutputs(
|
|
352
|
+
devices.map { AudioOutput(id = idOf(it), kind = routeOf(it), name = nameOf(it)) },
|
|
353
|
+
selected?.let(::idOf),
|
|
354
|
+
)
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
private fun AudioDevice.isExternal() =
|
|
358
|
+
this is AudioDevice.BluetoothHeadset || this is AudioDevice.WiredHeadset
|
|
359
|
+
|
|
360
|
+
private fun idOf(d: AudioDevice): String = when (d) {
|
|
361
|
+
is AudioDevice.Speakerphone -> SPEAKER
|
|
362
|
+
is AudioDevice.Earpiece -> "earpiece"
|
|
363
|
+
is AudioDevice.WiredHeadset -> "wired"
|
|
364
|
+
// AudioSwitch carries one Bluetooth device: the active headset.
|
|
365
|
+
is AudioDevice.BluetoothHeadset -> "bluetooth"
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
/** The Bluetooth device's own name; plain defaults for the rest. */
|
|
369
|
+
private fun nameOf(d: AudioDevice): String = when (d) {
|
|
370
|
+
is AudioDevice.Speakerphone -> "Speaker"
|
|
371
|
+
is AudioDevice.Earpiece -> "Phone"
|
|
372
|
+
is AudioDevice.WiredHeadset -> "Headphones"
|
|
373
|
+
is AudioDevice.BluetoothHeadset -> d.name.ifBlank { "Bluetooth" }
|
|
374
|
+
}
|
|
313
375
|
|
|
314
376
|
private fun routeOf(d: AudioDevice?): String = when (d) {
|
|
315
377
|
is AudioDevice.Speakerphone -> "speaker"
|
|
@@ -326,7 +388,7 @@ class LiveKitMediaPort(
|
|
|
326
388
|
if (this.room !== room) return@collect
|
|
327
389
|
when (event) {
|
|
328
390
|
is RoomEvent.Connected -> {
|
|
329
|
-
(room.audioHandler as? AudioSwitchHandler)?.let
|
|
391
|
+
(room.audioHandler as? AudioSwitchHandler)?.let(::applyRoute)
|
|
330
392
|
events.onConnected()
|
|
331
393
|
// Somebody may already be in (a late join, or a callee who
|
|
332
394
|
// answered before our media came up): they will not "connect".
|
|
@@ -403,6 +465,8 @@ class LiveKitMediaPort(
|
|
|
403
465
|
companion object {
|
|
404
466
|
private const val TAG = "PoolseLiveKit"
|
|
405
467
|
private const val SWITCH_SETTLE_MS = 600L
|
|
468
|
+
private const val SPEAKER = "speaker"
|
|
469
|
+
private const val PRIVATE = "private"
|
|
406
470
|
|
|
407
471
|
/** Pass as `mediaFactory` in CallCoreConfig. */
|
|
408
472
|
val factory: (Context, MediaEvents) -> MediaPort = { ctx, ev -> LiveKitMediaPort(ctx, ev) }
|
package/dist/index.cjs
CHANGED
|
@@ -31,6 +31,10 @@ var setMuted = (muted) => native?.setMuted(muted);
|
|
|
31
31
|
var setCamera = (on) => native?.setCamera(on);
|
|
32
32
|
var switchCamera = () => native?.switchCamera();
|
|
33
33
|
var setSpeaker = (on) => native?.setSpeaker(on);
|
|
34
|
+
function setAudioOutput(id) {
|
|
35
|
+
if (native?.setAudioOutput) native.setAudioOutput(id);
|
|
36
|
+
else native?.setSpeaker(id === "speaker");
|
|
37
|
+
}
|
|
34
38
|
var onRingEnded = (callId, reason) => native?.onRingEnded(callId, reason);
|
|
35
39
|
var onCallFinished = (callId, reason) => native?.onCallFinished(callId, reason ?? null);
|
|
36
40
|
var canUseFullScreenIntent = () => native?.canUseFullScreenIntent() ?? true;
|
|
@@ -81,6 +85,7 @@ exports.onRingEnded = onRingEnded;
|
|
|
81
85
|
exports.onVoipToken = onVoipToken;
|
|
82
86
|
exports.openFullScreenIntentSettings = openFullScreenIntentSettings;
|
|
83
87
|
exports.place = place;
|
|
88
|
+
exports.setAudioOutput = setAudioOutput;
|
|
84
89
|
exports.setCamera = setCamera;
|
|
85
90
|
exports.setMuted = setMuted;
|
|
86
91
|
exports.setSpeaker = setSpeaker;
|
package/dist/index.cjs.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/index.ts"],"names":["requireOptionalNativeModule","requireNativeViewManager","createElement","View"],"mappings":"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","file":"index.cjs","sourcesContent":["// @poolse/react-native-calls — the React Native face of the native\n// call core (CONTRACT.md §8). A thin, typed surface over the native\n// module: commands in, one snapshot event out. All call logic lives in\n// the native cores; nothing here decides anything about a call.\n//\n// On a platform or build without the native module (Expo Go, web,\n// unlinked), every command is a safe no-op and the state stays idle —\n// so a host can render against this unconditionally.\nimport { createElement, type ComponentType, type ReactElement } from 'react';\nimport { View, type ViewProps } from 'react-native';\nimport { requireNativeViewManager, requireOptionalNativeModule } from 'expo-modules-core';\n\n/**\n * A call's complete state. `null` means idle — no call. After a call\n * ends the snapshot stays `phase: 'ended'` (with `reason`) for ~2.5 s,\n * then becomes `null`.\n */\nexport interface Snapshot {\n cv: 1;\n callId: string;\n conversationId: string;\n conversationName: string | null;\n direction: 'incoming' | 'outgoing' | 'joined';\n media: 'audio' | 'video';\n phase: 'ringing_in' | 'ringing_out' | 'connecting' | 'active' | 'reconnecting' | 'ended';\n reason?:\n | 'left'\n | 'finished'\n | 'declined_all'\n | 'busy_all'\n | 'no_answer'\n | 'cancelled'\n | 'connection_lost'\n | 'failed';\n /** REMOTE participants only; `userId` is the LiveKit identity. */\n participants: Array<{\n userId: string;\n externalId: string;\n speaking: boolean;\n muted: boolean;\n /** True only with a published, subscribed, unmuted camera track (show video, not avatar). */\n cameraOn: boolean;\n }>;\n audioRoute: 'earpiece' | 'speaker' | 'bluetooth' | 'headset';\n mutedSelf: boolean;\n /** True only while the local camera track is actually published and enabled. */\n cameraOn: boolean;\n /** Facing of the published local camera; absent when the camera is off. */\n cameraFacing?: 'front' | 'back';\n}\n\nexport interface ConfigureOptions {\n /** Drawable resource NAME for the call notification's small icon. */\n smallIcon?: string;\n /** Ringback tone: raw resource NAME (Android); main-bundle sound name, mp3/caf/wav (iOS). */\n ringback?: string;\n /**\n * iOS: the APNs environment of this build's `aps-environment`\n * entitlement. Echoed back with the VoIP token so the server pushes to\n * the right gateway. Ignored on Android.\n */\n apnsEnvironment?: 'production' | 'sandbox';\n}\n\n/** The iOS PushKit (VoIP) token and the APNs environment it belongs to. */\nexport interface VoipToken {\n /** Hex-encoded PushKit token. */\n token: string;\n environment: 'production' | 'sandbox' | string;\n}\n\nexport interface PlaceOptions {\n media: 'audio' | 'video';\n /** The user's session token — authorizes placing/joining (CONTRACT.md §3.2). */\n sessionToken: string;\n}\n\ntype NativeSnapshot = { snapshot: Snapshot | null };\n\ninterface PoolseCallsNative {\n configure(apiBaseUrl: string, options: ConfigureOptions | null): Promise<void>;\n getCallState(): NativeSnapshot;\n place(conversationId: string, media: string, sessionToken: string): Promise<void>;\n join(callId: string, conversationId: string | null, sessionToken: string): Promise<void>;\n answer(): void;\n decline(): void;\n hangUp(): void;\n setMuted(muted: boolean): void;\n setCamera(on: boolean): void;\n switchCamera(): void;\n setSpeaker(on: boolean): void;\n onRingEnded(callId: string, reason: string): void;\n onCallFinished(callId: string, reason: string | null): void;\n canUseFullScreenIntent(): boolean;\n openFullScreenIntentSettings(): void;\n getVoipToken?(): VoipToken | null;\n addListener(event: 'onCallState', cb: (payload: NativeSnapshot) => void): { remove(): void };\n addListener(event: 'onVoipToken', cb: (payload: VoipToken) => void): { remove(): void };\n}\n\nconst native = requireOptionalNativeModule<PoolseCallsNative>('PoolseCalls');\n\n/** True when the native call core is present in this build. */\nexport const nativeCallsAvailable = native != null;\n\n/** Configure once, before any call can arrive. No secrets — just the API origin. */\nexport async function configure(apiBaseUrl: string, options?: ConfigureOptions): Promise<void> {\n await native?.configure(apiBaseUrl, options ?? null);\n}\n\n/** The current snapshot — safe to read at any moment, including cold answer and before configure. */\nexport function getCallState(): Snapshot | null {\n return native?.getCallState().snapshot ?? null;\n}\n\nexport type Unsubscribe = () => void;\n\n/** Subscribe to call state. Fires a full snapshot on every change. */\nexport function onCallState(cb: (snapshot: Snapshot | null) => void): Unsubscribe {\n if (!native) return () => undefined;\n const sub = native.addListener('onCallState', (payload) => cb(payload.snapshot));\n return () => sub.remove();\n}\n\nexport function place(conversationId: string, options: PlaceOptions): Promise<void> {\n return native?.place(conversationId, options.media, options.sessionToken) ?? Promise.resolve();\n}\n\nexport function join(\n callId: string,\n conversationId: string | null,\n sessionToken: string,\n): Promise<void> {\n return native?.join(callId, conversationId, sessionToken) ?? Promise.resolve();\n}\n\nexport const answer = (): void => native?.answer();\nexport const decline = (): void => native?.decline();\nexport const hangUp = (): void => native?.hangUp();\nexport const setMuted = (muted: boolean): void => native?.setMuted(muted);\nexport const setCamera = (on: boolean): void => native?.setCamera(on);\nexport const switchCamera = (): void => native?.switchCamera();\n/** Route call audio to the loudspeaker (`true`) or back to earpiece / headset (`false`). */\nexport const setSpeaker = (on: boolean): void => native?.setSpeaker(on);\n\n/**\n * Socket forwarding for an open app: a `call:ring_ended` seen on the\n * socket. Idempotent with the push path — the core dedupes.\n */\nexport const onRingEnded = (callId: string, reason: string): void =>\n native?.onRingEnded(callId, reason);\n\n/**\n * Socket forwarding: `call:finished`. Pass the payload's `reason`\n * (`finished` | `declined_all` | `no_answer` | `cancelled`) so an\n * unanswered caller sees \"Declined\"/\"No answer\", not \"Call ended\".\n * Idempotent with the `finished` push.\n */\nexport const onCallFinished = (callId: string, reason?: string): void =>\n native?.onCallFinished(callId, reason ?? null);\n\n/**\n * Android 14+: whether the app may show the full-screen ring over the\n * lock screen. `true` on older Android and where the module is absent.\n */\nexport const canUseFullScreenIntent = (): boolean => native?.canUseFullScreenIntent() ?? true;\n\n/** Open the system screen where the user grants full-screen intents (falls back to notification settings). */\nexport const openFullScreenIntentSettings = (): void => native?.openFullScreenIntentSettings();\n\n// ── VoIP push (iOS) ───────────────────────────────────────────────────\n\n/**\n * iOS: the current PushKit VoIP token, registered natively at launch.\n * `null` until iOS issues one, and always on Android (FCM carries rings).\n */\nexport function getVoipToken(): VoipToken | null {\n return native?.getVoipToken?.() ?? null;\n}\n\n/** iOS: fires whenever PushKit issues (or rotates) the VoIP token. No-op on Android. */\nexport function onVoipToken(cb: (token: VoipToken) => void): Unsubscribe {\n if (!native || !native.getVoipToken) return () => undefined;\n const sub = native.addListener('onVoipToken', cb);\n return () => sub.remove();\n}\n\n// ── video ─────────────────────────────────────────────────────────────\n\nexport interface CallVideoViewProps extends ViewProps {\n /** Remote participant's userId (LiveKit identity). Omit with local=true for self view. */\n participantId?: string;\n local?: boolean;\n /** default: true for local front camera, false otherwise */\n mirror?: boolean;\n /** default 'cover' */\n fit?: 'cover' | 'contain';\n}\n\ntype NativeVideoProps = CallVideoViewProps;\n\nlet nativeVideo: ComponentType<NativeVideoProps> | null | undefined;\n\n/** Resolved lazily (first render) and only when the native module exists. */\nfunction nativeVideoView(): ComponentType<NativeVideoProps> | null {\n if (nativeVideo !== undefined) return nativeVideo;\n nativeVideo = null;\n if (native) {\n try {\n nativeVideo = requireNativeViewManager<NativeVideoProps>('PoolseCalls', 'PoolseCallVideo');\n } catch {\n nativeVideo = null; // an older native build without the view\n }\n }\n return nativeVideo;\n}\n\n/**\n * A participant's camera (or, with `local`, your own), rendered natively\n * by the call core. Shows nothing (stays transparent) while that camera\n * is off — draw the avatar from the snapshot's `cameraOn` underneath.\n * Without the native module it renders an empty View.\n */\nexport function CallVideoView(props: CallVideoViewProps): ReactElement | null {\n const Native = nativeVideoView();\n if (!Native) {\n const { participantId: _p, local: _l, mirror: _m, fit: _f, ...rest } = props;\n return createElement(View, rest);\n }\n return createElement(Native, props);\n}\n"]}
|
|
1
|
+
{"version":3,"sources":["../src/index.ts"],"names":["requireOptionalNativeModule","requireNativeViewManager","createElement","View"],"mappings":";;;;;;;AA8HA,IAAM,MAAA,GAASA,4CAA+C,aAAa,CAAA;AAGpE,IAAM,uBAAuB,MAAA,IAAU;AAG9C,eAAsB,SAAA,CAAU,YAAoB,OAAA,EAA2C;AAC7F,EAAA,MAAM,MAAA,EAAQ,SAAA,CAAU,UAAA,EAAY,OAAA,IAAW,IAAI,CAAA;AACrD;AAGO,SAAS,YAAA,GAAgC;AAC9C,EAAA,OAAO,MAAA,EAAQ,YAAA,EAAa,CAAE,QAAA,IAAY,IAAA;AAC5C;AAKO,SAAS,YAAY,EAAA,EAAsD;AAChF,EAAA,IAAI,CAAC,MAAA,EAAQ,OAAO,MAAM,MAAA;AAC1B,EAAA,MAAM,GAAA,GAAM,OAAO,WAAA,CAAY,aAAA,EAAe,CAAC,OAAA,KAAY,EAAA,CAAG,OAAA,CAAQ,QAAQ,CAAC,CAAA;AAC/E,EAAA,OAAO,MAAM,IAAI,MAAA,EAAO;AAC1B;AAEO,SAAS,KAAA,CAAM,gBAAwB,OAAA,EAAsC;AAClF,EAAA,OAAO,MAAA,EAAQ,MAAM,cAAA,EAAgB,OAAA,CAAQ,OAAO,OAAA,CAAQ,YAAY,CAAA,IAAK,OAAA,CAAQ,OAAA,EAAQ;AAC/F;AAEO,SAAS,IAAA,CACd,MAAA,EACA,cAAA,EACA,YAAA,EACe;AACf,EAAA,OAAO,QAAQ,IAAA,CAAK,MAAA,EAAQ,gBAAgB,YAAY,CAAA,IAAK,QAAQ,OAAA,EAAQ;AAC/E;AAEO,IAAM,MAAA,GAAS,MAAY,MAAA,EAAQ,MAAA;AACnC,IAAM,OAAA,GAAU,MAAY,MAAA,EAAQ,OAAA;AACpC,IAAM,MAAA,GAAS,MAAY,MAAA,EAAQ,MAAA;AACnC,IAAM,QAAA,GAAW,CAAC,KAAA,KAAyB,MAAA,EAAQ,SAAS,KAAK;AACjE,IAAM,SAAA,GAAY,CAAC,EAAA,KAAsB,MAAA,EAAQ,UAAU,EAAE;AAC7D,IAAM,YAAA,GAAe,MAAY,MAAA,EAAQ,YAAA;AAEzC,IAAM,UAAA,GAAa,CAAC,EAAA,KAAsB,MAAA,EAAQ,WAAW,EAAE;AAS/D,SAAS,eAAe,EAAA,EAAkB;AAC/C,EAAA,IAAI,MAAA,EAAQ,cAAA,EAAgB,MAAA,CAAO,cAAA,CAAe,EAAE,CAAA;AAAA,OAC/C,MAAA,EAAQ,UAAA,CAAW,EAAA,KAAO,SAAS,CAAA;AAC1C;AAMO,IAAM,cAAc,CAAC,MAAA,EAAgB,WAC1C,MAAA,EAAQ,WAAA,CAAY,QAAQ,MAAM;AAQ7B,IAAM,cAAA,GAAiB,CAAC,MAAA,EAAgB,MAAA,KAC7C,QAAQ,cAAA,CAAe,MAAA,EAAQ,UAAU,IAAI;AAMxC,IAAM,sBAAA,GAAyB,MAAe,MAAA,EAAQ,sBAAA,EAAuB,IAAK;AAGlF,IAAM,4BAAA,GAA+B,MAAY,MAAA,EAAQ,4BAAA;AAQzD,SAAS,YAAA,GAAiC;AAC/C,EAAA,OAAO,MAAA,EAAQ,gBAAe,IAAK,IAAA;AACrC;AAGO,SAAS,YAAY,EAAA,EAA6C;AACvE,EAAA,IAAI,CAAC,MAAA,IAAU,CAAC,MAAA,CAAO,YAAA,SAAqB,MAAM,MAAA;AAClD,EAAA,MAAM,GAAA,GAAM,MAAA,CAAO,WAAA,CAAY,aAAA,EAAe,EAAE,CAAA;AAChD,EAAA,OAAO,MAAM,IAAI,MAAA,EAAO;AAC1B;AAgBA,IAAI,WAAA;AAGJ,SAAS,eAAA,GAA0D;AACjE,EAAA,IAAI,WAAA,KAAgB,QAAW,OAAO,WAAA;AACtC,EAAA,WAAA,GAAc,IAAA;AACd,EAAA,IAAI,MAAA,EAAQ;AACV,IAAA,IAAI;AACF,MAAA,WAAA,GAAcC,wCAAA,CAA2C,eAAe,iBAAiB,CAAA;AAAA,IAC3F,CAAA,CAAA,MAAQ;AACN,MAAA,WAAA,GAAc,IAAA;AAAA,IAChB;AAAA,EACF;AACA,EAAA,OAAO,WAAA;AACT;AAQO,SAAS,cAAc,KAAA,EAAgD;AAC5E,EAAA,MAAM,SAAS,eAAA,EAAgB;AAC/B,EAAA,IAAI,CAAC,MAAA,EAAQ;AACX,IAAA,MAAM,EAAE,aAAA,EAAe,EAAA,EAAI,KAAA,EAAO,EAAA,EAAI,MAAA,EAAQ,EAAA,EAAI,GAAA,EAAK,EAAA,EAAI,GAAG,IAAA,EAAK,GAAI,KAAA;AACvE,IAAA,OAAOC,mBAAA,CAAcC,kBAAM,IAAI,CAAA;AAAA,EACjC;AACA,EAAA,OAAOD,mBAAA,CAAc,QAAQ,KAAK,CAAA;AACpC","file":"index.cjs","sourcesContent":["// @poolse/react-native-calls — the React Native face of the native\n// call core (CONTRACT.md §8). A thin, typed surface over the native\n// module: commands in, one snapshot event out. All call logic lives in\n// the native cores; nothing here decides anything about a call.\n//\n// On a platform or build without the native module (Expo Go, web,\n// unlinked), every command is a safe no-op and the state stays idle —\n// so a host can render against this unconditionally.\nimport { createElement, type ComponentType, type ReactElement } from 'react';\nimport { View, type ViewProps } from 'react-native';\nimport { requireNativeViewManager, requireOptionalNativeModule } from 'expo-modules-core';\n\n/**\n * A call's complete state. `null` means idle — no call. After a call\n * ends the snapshot stays `phase: 'ended'` (with `reason`) for ~2.5 s,\n * then becomes `null`.\n */\nexport interface Snapshot {\n cv: 1;\n callId: string;\n conversationId: string;\n conversationName: string | null;\n direction: 'incoming' | 'outgoing' | 'joined';\n media: 'audio' | 'video';\n phase: 'ringing_in' | 'ringing_out' | 'connecting' | 'active' | 'reconnecting' | 'ended';\n reason?:\n | 'left'\n | 'finished'\n | 'declined_all'\n | 'busy_all'\n | 'no_answer'\n | 'cancelled'\n | 'connection_lost'\n | 'failed';\n /** REMOTE participants only; `userId` is the LiveKit identity. */\n participants: Array<{\n userId: string;\n externalId: string;\n speaking: boolean;\n muted: boolean;\n /** True only with a published, subscribed, unmuted camera track (show video, not avatar). */\n cameraOn: boolean;\n }>;\n /** The kind of output playing now — the short answer to \"where is the audio\". */\n audioRoute: AudioOutputKind;\n /**\n * Every output the call can play through right now: always the phone\n * (earpiece, or wired headphones in its place) and the speaker, plus\n * each connected Bluetooth / USB device by its own name. Empty until\n * the call's audio is up. Pass an `id` to `setAudioOutput`.\n */\n audioOutputs: AudioOutput[];\n /** The `audioOutputs` entry playing now; null before audio is up. */\n audioOutputId: string | null;\n mutedSelf: boolean;\n /** True only while the local camera track is actually published and enabled. */\n cameraOn: boolean;\n /** Facing of the published local camera; absent when the camera is off. */\n cameraFacing?: 'front' | 'back';\n}\n\nexport type AudioOutputKind = 'earpiece' | 'speaker' | 'bluetooth' | 'headset';\n\n/** One place call audio can play. */\nexport interface AudioOutput {\n /** Opaque; stable while the device stays connected. */\n id: string;\n kind: AudioOutputKind;\n /**\n * The device's own name for Bluetooth/USB (\"AirPods Pro\"); a plain\n * English default for built-ins (\"iPhone\"/\"Phone\", \"Speaker\",\n * \"Headphones\") — relabel those by `kind` to localise.\n */\n name: string;\n}\n\nexport interface ConfigureOptions {\n /** Drawable resource NAME for the call notification's small icon. */\n smallIcon?: string;\n /** Ringback tone: raw resource NAME (Android); main-bundle sound name, mp3/caf/wav (iOS). */\n ringback?: string;\n /**\n * iOS: the APNs environment of this build's `aps-environment`\n * entitlement. Echoed back with the VoIP token so the server pushes to\n * the right gateway. Ignored on Android.\n */\n apnsEnvironment?: 'production' | 'sandbox';\n}\n\n/** The iOS PushKit (VoIP) token and the APNs environment it belongs to. */\nexport interface VoipToken {\n /** Hex-encoded PushKit token. */\n token: string;\n environment: 'production' | 'sandbox' | string;\n}\n\nexport interface PlaceOptions {\n media: 'audio' | 'video';\n /** The user's session token — authorizes placing/joining (CONTRACT.md §3.2). */\n sessionToken: string;\n}\n\ntype NativeSnapshot = { snapshot: Snapshot | null };\n\ninterface PoolseCallsNative {\n configure(apiBaseUrl: string, options: ConfigureOptions | null): Promise<void>;\n getCallState(): NativeSnapshot;\n place(conversationId: string, media: string, sessionToken: string): Promise<void>;\n join(callId: string, conversationId: string | null, sessionToken: string): Promise<void>;\n answer(): void;\n decline(): void;\n hangUp(): void;\n setMuted(muted: boolean): void;\n setCamera(on: boolean): void;\n switchCamera(): void;\n setSpeaker(on: boolean): void;\n setAudioOutput?(id: string): void;\n onRingEnded(callId: string, reason: string): void;\n onCallFinished(callId: string, reason: string | null): void;\n canUseFullScreenIntent(): boolean;\n openFullScreenIntentSettings(): void;\n getVoipToken?(): VoipToken | null;\n addListener(event: 'onCallState', cb: (payload: NativeSnapshot) => void): { remove(): void };\n addListener(event: 'onVoipToken', cb: (payload: VoipToken) => void): { remove(): void };\n}\n\nconst native = requireOptionalNativeModule<PoolseCallsNative>('PoolseCalls');\n\n/** True when the native call core is present in this build. */\nexport const nativeCallsAvailable = native != null;\n\n/** Configure once, before any call can arrive. No secrets — just the API origin. */\nexport async function configure(apiBaseUrl: string, options?: ConfigureOptions): Promise<void> {\n await native?.configure(apiBaseUrl, options ?? null);\n}\n\n/** The current snapshot — safe to read at any moment, including cold answer and before configure. */\nexport function getCallState(): Snapshot | null {\n return native?.getCallState().snapshot ?? null;\n}\n\nexport type Unsubscribe = () => void;\n\n/** Subscribe to call state. Fires a full snapshot on every change. */\nexport function onCallState(cb: (snapshot: Snapshot | null) => void): Unsubscribe {\n if (!native) return () => undefined;\n const sub = native.addListener('onCallState', (payload) => cb(payload.snapshot));\n return () => sub.remove();\n}\n\nexport function place(conversationId: string, options: PlaceOptions): Promise<void> {\n return native?.place(conversationId, options.media, options.sessionToken) ?? Promise.resolve();\n}\n\nexport function join(\n callId: string,\n conversationId: string | null,\n sessionToken: string,\n): Promise<void> {\n return native?.join(callId, conversationId, sessionToken) ?? Promise.resolve();\n}\n\nexport const answer = (): void => native?.answer();\nexport const decline = (): void => native?.decline();\nexport const hangUp = (): void => native?.hangUp();\nexport const setMuted = (muted: boolean): void => native?.setMuted(muted);\nexport const setCamera = (on: boolean): void => native?.setCamera(on);\nexport const switchCamera = (): void => native?.switchCamera();\n/** Route call audio to the loudspeaker (`true`) or back to earpiece / headset (`false`). */\nexport const setSpeaker = (on: boolean): void => native?.setSpeaker(on);\n\n/**\n * Play call audio through one of `snapshot.audioOutputs`. The choice\n * holds for the rest of the call, until that device disconnects or a new\n * headset / Bluetooth device connects (which takes over, as in the\n * phone's own dialer). Without a choice a connected headset wins, then\n * the earpiece for voice calls or the speaker for video.\n */\nexport function setAudioOutput(id: string): void {\n if (native?.setAudioOutput) native.setAudioOutput(id);\n else native?.setSpeaker(id === 'speaker'); // an older native build\n}\n\n/**\n * Socket forwarding for an open app: a `call:ring_ended` seen on the\n * socket. Idempotent with the push path — the core dedupes.\n */\nexport const onRingEnded = (callId: string, reason: string): void =>\n native?.onRingEnded(callId, reason);\n\n/**\n * Socket forwarding: `call:finished`. Pass the payload's `reason`\n * (`finished` | `declined_all` | `no_answer` | `cancelled`) so an\n * unanswered caller sees \"Declined\"/\"No answer\", not \"Call ended\".\n * Idempotent with the `finished` push.\n */\nexport const onCallFinished = (callId: string, reason?: string): void =>\n native?.onCallFinished(callId, reason ?? null);\n\n/**\n * Android 14+: whether the app may show the full-screen ring over the\n * lock screen. `true` on older Android and where the module is absent.\n */\nexport const canUseFullScreenIntent = (): boolean => native?.canUseFullScreenIntent() ?? true;\n\n/** Open the system screen where the user grants full-screen intents (falls back to notification settings). */\nexport const openFullScreenIntentSettings = (): void => native?.openFullScreenIntentSettings();\n\n// ── VoIP push (iOS) ───────────────────────────────────────────────────\n\n/**\n * iOS: the current PushKit VoIP token, registered natively at launch.\n * `null` until iOS issues one, and always on Android (FCM carries rings).\n */\nexport function getVoipToken(): VoipToken | null {\n return native?.getVoipToken?.() ?? null;\n}\n\n/** iOS: fires whenever PushKit issues (or rotates) the VoIP token. No-op on Android. */\nexport function onVoipToken(cb: (token: VoipToken) => void): Unsubscribe {\n if (!native || !native.getVoipToken) return () => undefined;\n const sub = native.addListener('onVoipToken', cb);\n return () => sub.remove();\n}\n\n// ── video ─────────────────────────────────────────────────────────────\n\nexport interface CallVideoViewProps extends ViewProps {\n /** Remote participant's userId (LiveKit identity). Omit with local=true for self view. */\n participantId?: string;\n local?: boolean;\n /** default: true for local front camera, false otherwise */\n mirror?: boolean;\n /** default 'cover' */\n fit?: 'cover' | 'contain';\n}\n\ntype NativeVideoProps = CallVideoViewProps;\n\nlet nativeVideo: ComponentType<NativeVideoProps> | null | undefined;\n\n/** Resolved lazily (first render) and only when the native module exists. */\nfunction nativeVideoView(): ComponentType<NativeVideoProps> | null {\n if (nativeVideo !== undefined) return nativeVideo;\n nativeVideo = null;\n if (native) {\n try {\n nativeVideo = requireNativeViewManager<NativeVideoProps>('PoolseCalls', 'PoolseCallVideo');\n } catch {\n nativeVideo = null; // an older native build without the view\n }\n }\n return nativeVideo;\n}\n\n/**\n * A participant's camera (or, with `local`, your own), rendered natively\n * by the call core. Shows nothing (stays transparent) while that camera\n * is off — draw the avatar from the snapshot's `cameraOn` underneath.\n * Without the native module it renders an empty View.\n */\nexport function CallVideoView(props: CallVideoViewProps): ReactElement | null {\n const Native = nativeVideoView();\n if (!Native) {\n const { participantId: _p, local: _l, mirror: _m, fit: _f, ...rest } = props;\n return createElement(View, rest);\n }\n return createElement(Native, props);\n}\n"]}
|
package/dist/index.d.cts
CHANGED
|
@@ -24,13 +24,36 @@ interface Snapshot {
|
|
|
24
24
|
/** True only with a published, subscribed, unmuted camera track (show video, not avatar). */
|
|
25
25
|
cameraOn: boolean;
|
|
26
26
|
}>;
|
|
27
|
-
|
|
27
|
+
/** The kind of output playing now — the short answer to "where is the audio". */
|
|
28
|
+
audioRoute: AudioOutputKind;
|
|
29
|
+
/**
|
|
30
|
+
* Every output the call can play through right now: always the phone
|
|
31
|
+
* (earpiece, or wired headphones in its place) and the speaker, plus
|
|
32
|
+
* each connected Bluetooth / USB device by its own name. Empty until
|
|
33
|
+
* the call's audio is up. Pass an `id` to `setAudioOutput`.
|
|
34
|
+
*/
|
|
35
|
+
audioOutputs: AudioOutput[];
|
|
36
|
+
/** The `audioOutputs` entry playing now; null before audio is up. */
|
|
37
|
+
audioOutputId: string | null;
|
|
28
38
|
mutedSelf: boolean;
|
|
29
39
|
/** True only while the local camera track is actually published and enabled. */
|
|
30
40
|
cameraOn: boolean;
|
|
31
41
|
/** Facing of the published local camera; absent when the camera is off. */
|
|
32
42
|
cameraFacing?: 'front' | 'back';
|
|
33
43
|
}
|
|
44
|
+
type AudioOutputKind = 'earpiece' | 'speaker' | 'bluetooth' | 'headset';
|
|
45
|
+
/** One place call audio can play. */
|
|
46
|
+
interface AudioOutput {
|
|
47
|
+
/** Opaque; stable while the device stays connected. */
|
|
48
|
+
id: string;
|
|
49
|
+
kind: AudioOutputKind;
|
|
50
|
+
/**
|
|
51
|
+
* The device's own name for Bluetooth/USB ("AirPods Pro"); a plain
|
|
52
|
+
* English default for built-ins ("iPhone"/"Phone", "Speaker",
|
|
53
|
+
* "Headphones") — relabel those by `kind` to localise.
|
|
54
|
+
*/
|
|
55
|
+
name: string;
|
|
56
|
+
}
|
|
34
57
|
interface ConfigureOptions {
|
|
35
58
|
/** Drawable resource NAME for the call notification's small icon. */
|
|
36
59
|
smallIcon?: string;
|
|
@@ -73,6 +96,14 @@ declare const setCamera: (on: boolean) => void;
|
|
|
73
96
|
declare const switchCamera: () => void;
|
|
74
97
|
/** Route call audio to the loudspeaker (`true`) or back to earpiece / headset (`false`). */
|
|
75
98
|
declare const setSpeaker: (on: boolean) => void;
|
|
99
|
+
/**
|
|
100
|
+
* Play call audio through one of `snapshot.audioOutputs`. The choice
|
|
101
|
+
* holds for the rest of the call, until that device disconnects or a new
|
|
102
|
+
* headset / Bluetooth device connects (which takes over, as in the
|
|
103
|
+
* phone's own dialer). Without a choice a connected headset wins, then
|
|
104
|
+
* the earpiece for voice calls or the speaker for video.
|
|
105
|
+
*/
|
|
106
|
+
declare function setAudioOutput(id: string): void;
|
|
76
107
|
/**
|
|
77
108
|
* Socket forwarding for an open app: a `call:ring_ended` seen on the
|
|
78
109
|
* socket. Idempotent with the push path — the core dedupes.
|
|
@@ -116,4 +147,4 @@ interface CallVideoViewProps extends ViewProps {
|
|
|
116
147
|
*/
|
|
117
148
|
declare function CallVideoView(props: CallVideoViewProps): ReactElement | null;
|
|
118
149
|
|
|
119
|
-
export { CallVideoView, type CallVideoViewProps, type ConfigureOptions, type PlaceOptions, type Snapshot, type Unsubscribe, type VoipToken, answer, canUseFullScreenIntent, configure, decline, getCallState, getVoipToken, hangUp, join, nativeCallsAvailable, onCallFinished, onCallState, onRingEnded, onVoipToken, openFullScreenIntentSettings, place, setCamera, setMuted, setSpeaker, switchCamera };
|
|
150
|
+
export { type AudioOutput, type AudioOutputKind, CallVideoView, type CallVideoViewProps, type ConfigureOptions, type PlaceOptions, type Snapshot, type Unsubscribe, type VoipToken, answer, canUseFullScreenIntent, configure, decline, getCallState, getVoipToken, hangUp, join, nativeCallsAvailable, onCallFinished, onCallState, onRingEnded, onVoipToken, openFullScreenIntentSettings, place, setAudioOutput, setCamera, setMuted, setSpeaker, switchCamera };
|
package/dist/index.d.ts
CHANGED
|
@@ -24,13 +24,36 @@ interface Snapshot {
|
|
|
24
24
|
/** True only with a published, subscribed, unmuted camera track (show video, not avatar). */
|
|
25
25
|
cameraOn: boolean;
|
|
26
26
|
}>;
|
|
27
|
-
|
|
27
|
+
/** The kind of output playing now — the short answer to "where is the audio". */
|
|
28
|
+
audioRoute: AudioOutputKind;
|
|
29
|
+
/**
|
|
30
|
+
* Every output the call can play through right now: always the phone
|
|
31
|
+
* (earpiece, or wired headphones in its place) and the speaker, plus
|
|
32
|
+
* each connected Bluetooth / USB device by its own name. Empty until
|
|
33
|
+
* the call's audio is up. Pass an `id` to `setAudioOutput`.
|
|
34
|
+
*/
|
|
35
|
+
audioOutputs: AudioOutput[];
|
|
36
|
+
/** The `audioOutputs` entry playing now; null before audio is up. */
|
|
37
|
+
audioOutputId: string | null;
|
|
28
38
|
mutedSelf: boolean;
|
|
29
39
|
/** True only while the local camera track is actually published and enabled. */
|
|
30
40
|
cameraOn: boolean;
|
|
31
41
|
/** Facing of the published local camera; absent when the camera is off. */
|
|
32
42
|
cameraFacing?: 'front' | 'back';
|
|
33
43
|
}
|
|
44
|
+
type AudioOutputKind = 'earpiece' | 'speaker' | 'bluetooth' | 'headset';
|
|
45
|
+
/** One place call audio can play. */
|
|
46
|
+
interface AudioOutput {
|
|
47
|
+
/** Opaque; stable while the device stays connected. */
|
|
48
|
+
id: string;
|
|
49
|
+
kind: AudioOutputKind;
|
|
50
|
+
/**
|
|
51
|
+
* The device's own name for Bluetooth/USB ("AirPods Pro"); a plain
|
|
52
|
+
* English default for built-ins ("iPhone"/"Phone", "Speaker",
|
|
53
|
+
* "Headphones") — relabel those by `kind` to localise.
|
|
54
|
+
*/
|
|
55
|
+
name: string;
|
|
56
|
+
}
|
|
34
57
|
interface ConfigureOptions {
|
|
35
58
|
/** Drawable resource NAME for the call notification's small icon. */
|
|
36
59
|
smallIcon?: string;
|
|
@@ -73,6 +96,14 @@ declare const setCamera: (on: boolean) => void;
|
|
|
73
96
|
declare const switchCamera: () => void;
|
|
74
97
|
/** Route call audio to the loudspeaker (`true`) or back to earpiece / headset (`false`). */
|
|
75
98
|
declare const setSpeaker: (on: boolean) => void;
|
|
99
|
+
/**
|
|
100
|
+
* Play call audio through one of `snapshot.audioOutputs`. The choice
|
|
101
|
+
* holds for the rest of the call, until that device disconnects or a new
|
|
102
|
+
* headset / Bluetooth device connects (which takes over, as in the
|
|
103
|
+
* phone's own dialer). Without a choice a connected headset wins, then
|
|
104
|
+
* the earpiece for voice calls or the speaker for video.
|
|
105
|
+
*/
|
|
106
|
+
declare function setAudioOutput(id: string): void;
|
|
76
107
|
/**
|
|
77
108
|
* Socket forwarding for an open app: a `call:ring_ended` seen on the
|
|
78
109
|
* socket. Idempotent with the push path — the core dedupes.
|
|
@@ -116,4 +147,4 @@ interface CallVideoViewProps extends ViewProps {
|
|
|
116
147
|
*/
|
|
117
148
|
declare function CallVideoView(props: CallVideoViewProps): ReactElement | null;
|
|
118
149
|
|
|
119
|
-
export { CallVideoView, type CallVideoViewProps, type ConfigureOptions, type PlaceOptions, type Snapshot, type Unsubscribe, type VoipToken, answer, canUseFullScreenIntent, configure, decline, getCallState, getVoipToken, hangUp, join, nativeCallsAvailable, onCallFinished, onCallState, onRingEnded, onVoipToken, openFullScreenIntentSettings, place, setCamera, setMuted, setSpeaker, switchCamera };
|
|
150
|
+
export { type AudioOutput, type AudioOutputKind, CallVideoView, type CallVideoViewProps, type ConfigureOptions, type PlaceOptions, type Snapshot, type Unsubscribe, type VoipToken, answer, canUseFullScreenIntent, configure, decline, getCallState, getVoipToken, hangUp, join, nativeCallsAvailable, onCallFinished, onCallState, onRingEnded, onVoipToken, openFullScreenIntentSettings, place, setAudioOutput, setCamera, setMuted, setSpeaker, switchCamera };
|
package/dist/index.js
CHANGED
|
@@ -29,6 +29,10 @@ var setMuted = (muted) => native?.setMuted(muted);
|
|
|
29
29
|
var setCamera = (on) => native?.setCamera(on);
|
|
30
30
|
var switchCamera = () => native?.switchCamera();
|
|
31
31
|
var setSpeaker = (on) => native?.setSpeaker(on);
|
|
32
|
+
function setAudioOutput(id) {
|
|
33
|
+
if (native?.setAudioOutput) native.setAudioOutput(id);
|
|
34
|
+
else native?.setSpeaker(id === "speaker");
|
|
35
|
+
}
|
|
32
36
|
var onRingEnded = (callId, reason) => native?.onRingEnded(callId, reason);
|
|
33
37
|
var onCallFinished = (callId, reason) => native?.onCallFinished(callId, reason ?? null);
|
|
34
38
|
var canUseFullScreenIntent = () => native?.canUseFullScreenIntent() ?? true;
|
|
@@ -63,6 +67,6 @@ function CallVideoView(props) {
|
|
|
63
67
|
return createElement(Native, props);
|
|
64
68
|
}
|
|
65
69
|
|
|
66
|
-
export { CallVideoView, answer, canUseFullScreenIntent, configure, decline, getCallState, getVoipToken, hangUp, join, nativeCallsAvailable, onCallFinished, onCallState, onRingEnded, onVoipToken, openFullScreenIntentSettings, place, setCamera, setMuted, setSpeaker, switchCamera };
|
|
70
|
+
export { CallVideoView, answer, canUseFullScreenIntent, configure, decline, getCallState, getVoipToken, hangUp, join, nativeCallsAvailable, onCallFinished, onCallState, onRingEnded, onVoipToken, openFullScreenIntentSettings, place, setAudioOutput, setCamera, setMuted, setSpeaker, switchCamera };
|
|
67
71
|
//# sourceMappingURL=index.js.map
|
|
68
72
|
//# sourceMappingURL=index.js.map
|
package/dist/index.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/index.ts"],"names":[],"mappings":"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","file":"index.js","sourcesContent":["// @poolse/react-native-calls — the React Native face of the native\n// call core (CONTRACT.md §8). A thin, typed surface over the native\n// module: commands in, one snapshot event out. All call logic lives in\n// the native cores; nothing here decides anything about a call.\n//\n// On a platform or build without the native module (Expo Go, web,\n// unlinked), every command is a safe no-op and the state stays idle —\n// so a host can render against this unconditionally.\nimport { createElement, type ComponentType, type ReactElement } from 'react';\nimport { View, type ViewProps } from 'react-native';\nimport { requireNativeViewManager, requireOptionalNativeModule } from 'expo-modules-core';\n\n/**\n * A call's complete state. `null` means idle — no call. After a call\n * ends the snapshot stays `phase: 'ended'` (with `reason`) for ~2.5 s,\n * then becomes `null`.\n */\nexport interface Snapshot {\n cv: 1;\n callId: string;\n conversationId: string;\n conversationName: string | null;\n direction: 'incoming' | 'outgoing' | 'joined';\n media: 'audio' | 'video';\n phase: 'ringing_in' | 'ringing_out' | 'connecting' | 'active' | 'reconnecting' | 'ended';\n reason?:\n | 'left'\n | 'finished'\n | 'declined_all'\n | 'busy_all'\n | 'no_answer'\n | 'cancelled'\n | 'connection_lost'\n | 'failed';\n /** REMOTE participants only; `userId` is the LiveKit identity. */\n participants: Array<{\n userId: string;\n externalId: string;\n speaking: boolean;\n muted: boolean;\n /** True only with a published, subscribed, unmuted camera track (show video, not avatar). */\n cameraOn: boolean;\n }>;\n audioRoute: 'earpiece' | 'speaker' | 'bluetooth' | 'headset';\n mutedSelf: boolean;\n /** True only while the local camera track is actually published and enabled. */\n cameraOn: boolean;\n /** Facing of the published local camera; absent when the camera is off. */\n cameraFacing?: 'front' | 'back';\n}\n\nexport interface ConfigureOptions {\n /** Drawable resource NAME for the call notification's small icon. */\n smallIcon?: string;\n /** Ringback tone: raw resource NAME (Android); main-bundle sound name, mp3/caf/wav (iOS). */\n ringback?: string;\n /**\n * iOS: the APNs environment of this build's `aps-environment`\n * entitlement. Echoed back with the VoIP token so the server pushes to\n * the right gateway. Ignored on Android.\n */\n apnsEnvironment?: 'production' | 'sandbox';\n}\n\n/** The iOS PushKit (VoIP) token and the APNs environment it belongs to. */\nexport interface VoipToken {\n /** Hex-encoded PushKit token. */\n token: string;\n environment: 'production' | 'sandbox' | string;\n}\n\nexport interface PlaceOptions {\n media: 'audio' | 'video';\n /** The user's session token — authorizes placing/joining (CONTRACT.md §3.2). */\n sessionToken: string;\n}\n\ntype NativeSnapshot = { snapshot: Snapshot | null };\n\ninterface PoolseCallsNative {\n configure(apiBaseUrl: string, options: ConfigureOptions | null): Promise<void>;\n getCallState(): NativeSnapshot;\n place(conversationId: string, media: string, sessionToken: string): Promise<void>;\n join(callId: string, conversationId: string | null, sessionToken: string): Promise<void>;\n answer(): void;\n decline(): void;\n hangUp(): void;\n setMuted(muted: boolean): void;\n setCamera(on: boolean): void;\n switchCamera(): void;\n setSpeaker(on: boolean): void;\n onRingEnded(callId: string, reason: string): void;\n onCallFinished(callId: string, reason: string | null): void;\n canUseFullScreenIntent(): boolean;\n openFullScreenIntentSettings(): void;\n getVoipToken?(): VoipToken | null;\n addListener(event: 'onCallState', cb: (payload: NativeSnapshot) => void): { remove(): void };\n addListener(event: 'onVoipToken', cb: (payload: VoipToken) => void): { remove(): void };\n}\n\nconst native = requireOptionalNativeModule<PoolseCallsNative>('PoolseCalls');\n\n/** True when the native call core is present in this build. */\nexport const nativeCallsAvailable = native != null;\n\n/** Configure once, before any call can arrive. No secrets — just the API origin. */\nexport async function configure(apiBaseUrl: string, options?: ConfigureOptions): Promise<void> {\n await native?.configure(apiBaseUrl, options ?? null);\n}\n\n/** The current snapshot — safe to read at any moment, including cold answer and before configure. */\nexport function getCallState(): Snapshot | null {\n return native?.getCallState().snapshot ?? null;\n}\n\nexport type Unsubscribe = () => void;\n\n/** Subscribe to call state. Fires a full snapshot on every change. */\nexport function onCallState(cb: (snapshot: Snapshot | null) => void): Unsubscribe {\n if (!native) return () => undefined;\n const sub = native.addListener('onCallState', (payload) => cb(payload.snapshot));\n return () => sub.remove();\n}\n\nexport function place(conversationId: string, options: PlaceOptions): Promise<void> {\n return native?.place(conversationId, options.media, options.sessionToken) ?? Promise.resolve();\n}\n\nexport function join(\n callId: string,\n conversationId: string | null,\n sessionToken: string,\n): Promise<void> {\n return native?.join(callId, conversationId, sessionToken) ?? Promise.resolve();\n}\n\nexport const answer = (): void => native?.answer();\nexport const decline = (): void => native?.decline();\nexport const hangUp = (): void => native?.hangUp();\nexport const setMuted = (muted: boolean): void => native?.setMuted(muted);\nexport const setCamera = (on: boolean): void => native?.setCamera(on);\nexport const switchCamera = (): void => native?.switchCamera();\n/** Route call audio to the loudspeaker (`true`) or back to earpiece / headset (`false`). */\nexport const setSpeaker = (on: boolean): void => native?.setSpeaker(on);\n\n/**\n * Socket forwarding for an open app: a `call:ring_ended` seen on the\n * socket. Idempotent with the push path — the core dedupes.\n */\nexport const onRingEnded = (callId: string, reason: string): void =>\n native?.onRingEnded(callId, reason);\n\n/**\n * Socket forwarding: `call:finished`. Pass the payload's `reason`\n * (`finished` | `declined_all` | `no_answer` | `cancelled`) so an\n * unanswered caller sees \"Declined\"/\"No answer\", not \"Call ended\".\n * Idempotent with the `finished` push.\n */\nexport const onCallFinished = (callId: string, reason?: string): void =>\n native?.onCallFinished(callId, reason ?? null);\n\n/**\n * Android 14+: whether the app may show the full-screen ring over the\n * lock screen. `true` on older Android and where the module is absent.\n */\nexport const canUseFullScreenIntent = (): boolean => native?.canUseFullScreenIntent() ?? true;\n\n/** Open the system screen where the user grants full-screen intents (falls back to notification settings). */\nexport const openFullScreenIntentSettings = (): void => native?.openFullScreenIntentSettings();\n\n// ── VoIP push (iOS) ───────────────────────────────────────────────────\n\n/**\n * iOS: the current PushKit VoIP token, registered natively at launch.\n * `null` until iOS issues one, and always on Android (FCM carries rings).\n */\nexport function getVoipToken(): VoipToken | null {\n return native?.getVoipToken?.() ?? null;\n}\n\n/** iOS: fires whenever PushKit issues (or rotates) the VoIP token. No-op on Android. */\nexport function onVoipToken(cb: (token: VoipToken) => void): Unsubscribe {\n if (!native || !native.getVoipToken) return () => undefined;\n const sub = native.addListener('onVoipToken', cb);\n return () => sub.remove();\n}\n\n// ── video ─────────────────────────────────────────────────────────────\n\nexport interface CallVideoViewProps extends ViewProps {\n /** Remote participant's userId (LiveKit identity). Omit with local=true for self view. */\n participantId?: string;\n local?: boolean;\n /** default: true for local front camera, false otherwise */\n mirror?: boolean;\n /** default 'cover' */\n fit?: 'cover' | 'contain';\n}\n\ntype NativeVideoProps = CallVideoViewProps;\n\nlet nativeVideo: ComponentType<NativeVideoProps> | null | undefined;\n\n/** Resolved lazily (first render) and only when the native module exists. */\nfunction nativeVideoView(): ComponentType<NativeVideoProps> | null {\n if (nativeVideo !== undefined) return nativeVideo;\n nativeVideo = null;\n if (native) {\n try {\n nativeVideo = requireNativeViewManager<NativeVideoProps>('PoolseCalls', 'PoolseCallVideo');\n } catch {\n nativeVideo = null; // an older native build without the view\n }\n }\n return nativeVideo;\n}\n\n/**\n * A participant's camera (or, with `local`, your own), rendered natively\n * by the call core. Shows nothing (stays transparent) while that camera\n * is off — draw the avatar from the snapshot's `cameraOn` underneath.\n * Without the native module it renders an empty View.\n */\nexport function CallVideoView(props: CallVideoViewProps): ReactElement | null {\n const Native = nativeVideoView();\n if (!Native) {\n const { participantId: _p, local: _l, mirror: _m, fit: _f, ...rest } = props;\n return createElement(View, rest);\n }\n return createElement(Native, props);\n}\n"]}
|
|
1
|
+
{"version":3,"sources":["../src/index.ts"],"names":[],"mappings":"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","file":"index.js","sourcesContent":["// @poolse/react-native-calls — the React Native face of the native\n// call core (CONTRACT.md §8). A thin, typed surface over the native\n// module: commands in, one snapshot event out. All call logic lives in\n// the native cores; nothing here decides anything about a call.\n//\n// On a platform or build without the native module (Expo Go, web,\n// unlinked), every command is a safe no-op and the state stays idle —\n// so a host can render against this unconditionally.\nimport { createElement, type ComponentType, type ReactElement } from 'react';\nimport { View, type ViewProps } from 'react-native';\nimport { requireNativeViewManager, requireOptionalNativeModule } from 'expo-modules-core';\n\n/**\n * A call's complete state. `null` means idle — no call. After a call\n * ends the snapshot stays `phase: 'ended'` (with `reason`) for ~2.5 s,\n * then becomes `null`.\n */\nexport interface Snapshot {\n cv: 1;\n callId: string;\n conversationId: string;\n conversationName: string | null;\n direction: 'incoming' | 'outgoing' | 'joined';\n media: 'audio' | 'video';\n phase: 'ringing_in' | 'ringing_out' | 'connecting' | 'active' | 'reconnecting' | 'ended';\n reason?:\n | 'left'\n | 'finished'\n | 'declined_all'\n | 'busy_all'\n | 'no_answer'\n | 'cancelled'\n | 'connection_lost'\n | 'failed';\n /** REMOTE participants only; `userId` is the LiveKit identity. */\n participants: Array<{\n userId: string;\n externalId: string;\n speaking: boolean;\n muted: boolean;\n /** True only with a published, subscribed, unmuted camera track (show video, not avatar). */\n cameraOn: boolean;\n }>;\n /** The kind of output playing now — the short answer to \"where is the audio\". */\n audioRoute: AudioOutputKind;\n /**\n * Every output the call can play through right now: always the phone\n * (earpiece, or wired headphones in its place) and the speaker, plus\n * each connected Bluetooth / USB device by its own name. Empty until\n * the call's audio is up. Pass an `id` to `setAudioOutput`.\n */\n audioOutputs: AudioOutput[];\n /** The `audioOutputs` entry playing now; null before audio is up. */\n audioOutputId: string | null;\n mutedSelf: boolean;\n /** True only while the local camera track is actually published and enabled. */\n cameraOn: boolean;\n /** Facing of the published local camera; absent when the camera is off. */\n cameraFacing?: 'front' | 'back';\n}\n\nexport type AudioOutputKind = 'earpiece' | 'speaker' | 'bluetooth' | 'headset';\n\n/** One place call audio can play. */\nexport interface AudioOutput {\n /** Opaque; stable while the device stays connected. */\n id: string;\n kind: AudioOutputKind;\n /**\n * The device's own name for Bluetooth/USB (\"AirPods Pro\"); a plain\n * English default for built-ins (\"iPhone\"/\"Phone\", \"Speaker\",\n * \"Headphones\") — relabel those by `kind` to localise.\n */\n name: string;\n}\n\nexport interface ConfigureOptions {\n /** Drawable resource NAME for the call notification's small icon. */\n smallIcon?: string;\n /** Ringback tone: raw resource NAME (Android); main-bundle sound name, mp3/caf/wav (iOS). */\n ringback?: string;\n /**\n * iOS: the APNs environment of this build's `aps-environment`\n * entitlement. Echoed back with the VoIP token so the server pushes to\n * the right gateway. Ignored on Android.\n */\n apnsEnvironment?: 'production' | 'sandbox';\n}\n\n/** The iOS PushKit (VoIP) token and the APNs environment it belongs to. */\nexport interface VoipToken {\n /** Hex-encoded PushKit token. */\n token: string;\n environment: 'production' | 'sandbox' | string;\n}\n\nexport interface PlaceOptions {\n media: 'audio' | 'video';\n /** The user's session token — authorizes placing/joining (CONTRACT.md §3.2). */\n sessionToken: string;\n}\n\ntype NativeSnapshot = { snapshot: Snapshot | null };\n\ninterface PoolseCallsNative {\n configure(apiBaseUrl: string, options: ConfigureOptions | null): Promise<void>;\n getCallState(): NativeSnapshot;\n place(conversationId: string, media: string, sessionToken: string): Promise<void>;\n join(callId: string, conversationId: string | null, sessionToken: string): Promise<void>;\n answer(): void;\n decline(): void;\n hangUp(): void;\n setMuted(muted: boolean): void;\n setCamera(on: boolean): void;\n switchCamera(): void;\n setSpeaker(on: boolean): void;\n setAudioOutput?(id: string): void;\n onRingEnded(callId: string, reason: string): void;\n onCallFinished(callId: string, reason: string | null): void;\n canUseFullScreenIntent(): boolean;\n openFullScreenIntentSettings(): void;\n getVoipToken?(): VoipToken | null;\n addListener(event: 'onCallState', cb: (payload: NativeSnapshot) => void): { remove(): void };\n addListener(event: 'onVoipToken', cb: (payload: VoipToken) => void): { remove(): void };\n}\n\nconst native = requireOptionalNativeModule<PoolseCallsNative>('PoolseCalls');\n\n/** True when the native call core is present in this build. */\nexport const nativeCallsAvailable = native != null;\n\n/** Configure once, before any call can arrive. No secrets — just the API origin. */\nexport async function configure(apiBaseUrl: string, options?: ConfigureOptions): Promise<void> {\n await native?.configure(apiBaseUrl, options ?? null);\n}\n\n/** The current snapshot — safe to read at any moment, including cold answer and before configure. */\nexport function getCallState(): Snapshot | null {\n return native?.getCallState().snapshot ?? null;\n}\n\nexport type Unsubscribe = () => void;\n\n/** Subscribe to call state. Fires a full snapshot on every change. */\nexport function onCallState(cb: (snapshot: Snapshot | null) => void): Unsubscribe {\n if (!native) return () => undefined;\n const sub = native.addListener('onCallState', (payload) => cb(payload.snapshot));\n return () => sub.remove();\n}\n\nexport function place(conversationId: string, options: PlaceOptions): Promise<void> {\n return native?.place(conversationId, options.media, options.sessionToken) ?? Promise.resolve();\n}\n\nexport function join(\n callId: string,\n conversationId: string | null,\n sessionToken: string,\n): Promise<void> {\n return native?.join(callId, conversationId, sessionToken) ?? Promise.resolve();\n}\n\nexport const answer = (): void => native?.answer();\nexport const decline = (): void => native?.decline();\nexport const hangUp = (): void => native?.hangUp();\nexport const setMuted = (muted: boolean): void => native?.setMuted(muted);\nexport const setCamera = (on: boolean): void => native?.setCamera(on);\nexport const switchCamera = (): void => native?.switchCamera();\n/** Route call audio to the loudspeaker (`true`) or back to earpiece / headset (`false`). */\nexport const setSpeaker = (on: boolean): void => native?.setSpeaker(on);\n\n/**\n * Play call audio through one of `snapshot.audioOutputs`. The choice\n * holds for the rest of the call, until that device disconnects or a new\n * headset / Bluetooth device connects (which takes over, as in the\n * phone's own dialer). Without a choice a connected headset wins, then\n * the earpiece for voice calls or the speaker for video.\n */\nexport function setAudioOutput(id: string): void {\n if (native?.setAudioOutput) native.setAudioOutput(id);\n else native?.setSpeaker(id === 'speaker'); // an older native build\n}\n\n/**\n * Socket forwarding for an open app: a `call:ring_ended` seen on the\n * socket. Idempotent with the push path — the core dedupes.\n */\nexport const onRingEnded = (callId: string, reason: string): void =>\n native?.onRingEnded(callId, reason);\n\n/**\n * Socket forwarding: `call:finished`. Pass the payload's `reason`\n * (`finished` | `declined_all` | `no_answer` | `cancelled`) so an\n * unanswered caller sees \"Declined\"/\"No answer\", not \"Call ended\".\n * Idempotent with the `finished` push.\n */\nexport const onCallFinished = (callId: string, reason?: string): void =>\n native?.onCallFinished(callId, reason ?? null);\n\n/**\n * Android 14+: whether the app may show the full-screen ring over the\n * lock screen. `true` on older Android and where the module is absent.\n */\nexport const canUseFullScreenIntent = (): boolean => native?.canUseFullScreenIntent() ?? true;\n\n/** Open the system screen where the user grants full-screen intents (falls back to notification settings). */\nexport const openFullScreenIntentSettings = (): void => native?.openFullScreenIntentSettings();\n\n// ── VoIP push (iOS) ───────────────────────────────────────────────────\n\n/**\n * iOS: the current PushKit VoIP token, registered natively at launch.\n * `null` until iOS issues one, and always on Android (FCM carries rings).\n */\nexport function getVoipToken(): VoipToken | null {\n return native?.getVoipToken?.() ?? null;\n}\n\n/** iOS: fires whenever PushKit issues (or rotates) the VoIP token. No-op on Android. */\nexport function onVoipToken(cb: (token: VoipToken) => void): Unsubscribe {\n if (!native || !native.getVoipToken) return () => undefined;\n const sub = native.addListener('onVoipToken', cb);\n return () => sub.remove();\n}\n\n// ── video ─────────────────────────────────────────────────────────────\n\nexport interface CallVideoViewProps extends ViewProps {\n /** Remote participant's userId (LiveKit identity). Omit with local=true for self view. */\n participantId?: string;\n local?: boolean;\n /** default: true for local front camera, false otherwise */\n mirror?: boolean;\n /** default 'cover' */\n fit?: 'cover' | 'contain';\n}\n\ntype NativeVideoProps = CallVideoViewProps;\n\nlet nativeVideo: ComponentType<NativeVideoProps> | null | undefined;\n\n/** Resolved lazily (first render) and only when the native module exists. */\nfunction nativeVideoView(): ComponentType<NativeVideoProps> | null {\n if (nativeVideo !== undefined) return nativeVideo;\n nativeVideo = null;\n if (native) {\n try {\n nativeVideo = requireNativeViewManager<NativeVideoProps>('PoolseCalls', 'PoolseCallVideo');\n } catch {\n nativeVideo = null; // an older native build without the view\n }\n }\n return nativeVideo;\n}\n\n/**\n * A participant's camera (or, with `local`, your own), rendered natively\n * by the call core. Shows nothing (stays transparent) while that camera\n * is off — draw the avatar from the snapshot's `cameraOn` underneath.\n * Without the native module it renders an empty View.\n */\nexport function CallVideoView(props: CallVideoViewProps): ReactElement | null {\n const Native = nativeVideoView();\n if (!Native) {\n const { participantId: _p, local: _l, mirror: _m, fit: _f, ...rest } = props;\n return createElement(View, rest);\n }\n return createElement(Native, props);\n}\n"]}
|
|
@@ -173,6 +173,7 @@ public final class CallCoreRuntime {
|
|
|
173
173
|
public func setCamera(_ on: Bool) { post { [self] in media.setCamera(on) } }
|
|
174
174
|
public func switchCamera() { post { [self] in media.switchCamera() } }
|
|
175
175
|
public func setSpeaker(_ on: Bool) { post { [self] in media.setSpeaker(on) } }
|
|
176
|
+
public func setAudioOutput(_ id: String) { post { [self] in media.setAudioOutput(id) } }
|
|
176
177
|
|
|
177
178
|
/// Re-emit the current state (the bridge's roster/route changed).
|
|
178
179
|
public func reemit() { post { [self] in if state.phase != .idle { sink.emit(state: state) } } }
|
package/ios/Core/Ports.swift
CHANGED
|
@@ -71,6 +71,13 @@ public protocol MediaPort: AnyObject {
|
|
|
71
71
|
func switchCamera()
|
|
72
72
|
/// Loudspeaker (`true`) or back to the private route (`false`).
|
|
73
73
|
func setSpeaker(_ on: Bool)
|
|
74
|
+
/// Play through one specific output — an id from `Snapshot.audioOutputs`.
|
|
75
|
+
func setAudioOutput(_ id: String)
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
public extension MediaPort {
|
|
79
|
+
/// Engines without device selection fall back to the speaker toggle.
|
|
80
|
+
func setAudioOutput(_ id: String) { setSpeaker(id == "speaker") }
|
|
74
81
|
}
|
|
75
82
|
|
|
76
83
|
// MARK: - Ring surface
|
|
@@ -23,6 +23,24 @@ public struct SnapshotParticipant: Equatable {
|
|
|
23
23
|
}
|
|
24
24
|
}
|
|
25
25
|
|
|
26
|
+
/// One place call audio can play, as a JS `Snapshot.audioOutputs[]` entry.
|
|
27
|
+
public struct AudioOutput: Equatable {
|
|
28
|
+
/// Stable for the device's lifetime in this call; passed back to `setAudioOutput`.
|
|
29
|
+
public var id: String
|
|
30
|
+
/// `earpiece` | `speaker` | `bluetooth` | `headset`.
|
|
31
|
+
public var kind: String
|
|
32
|
+
/// What the device calls itself ("AirPods Pro"), or a plain default for built-ins.
|
|
33
|
+
public var name: String
|
|
34
|
+
|
|
35
|
+
public init(id: String, kind: String, name: String) {
|
|
36
|
+
self.id = id
|
|
37
|
+
self.kind = kind
|
|
38
|
+
self.name = name
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
public var dictionary: [String: Any] { ["id": id, "kind": kind, "name": name] }
|
|
42
|
+
}
|
|
43
|
+
|
|
26
44
|
public extension Phase {
|
|
27
45
|
var wire: String {
|
|
28
46
|
switch self {
|
|
@@ -50,6 +68,8 @@ public extension MachineState {
|
|
|
50
68
|
func toSnapshot(conversationName: String? = nil,
|
|
51
69
|
participants: [SnapshotParticipant] = [],
|
|
52
70
|
audioRoute: String = "earpiece",
|
|
71
|
+
audioOutputs: [AudioOutput] = [],
|
|
72
|
+
audioOutputId: String? = nil,
|
|
53
73
|
mutedSelf: Bool = false,
|
|
54
74
|
cameraOn: Bool = false,
|
|
55
75
|
cameraFacing: String? = nil) -> [String: Any]? {
|
|
@@ -64,6 +84,8 @@ public extension MachineState {
|
|
|
64
84
|
"phase": phase.wire,
|
|
65
85
|
"participants": participants.map { $0.dictionary },
|
|
66
86
|
"audioRoute": audioRoute,
|
|
87
|
+
"audioOutputs": audioOutputs.map { $0.dictionary },
|
|
88
|
+
"audioOutputId": audioOutputId.map { $0 as Any } ?? NSNull(),
|
|
67
89
|
"mutedSelf": mutedSelf,
|
|
68
90
|
"cameraOn": cameraOn,
|
|
69
91
|
]
|
|
@@ -70,6 +70,7 @@ public class PoolseCallsModule: Module {
|
|
|
70
70
|
Function("setCamera") { (on: Bool) in onCore { $0.setCamera(on) } }
|
|
71
71
|
Function("switchCamera") { onCore { $0.switchCamera() } }
|
|
72
72
|
Function("setSpeaker") { (on: Bool) in onCore { $0.setSpeaker(on) } }
|
|
73
|
+
Function("setAudioOutput") { (id: String) in onCore { $0.setAudioOutput(id) } }
|
|
73
74
|
|
|
74
75
|
// Socket forwarding (open app): idempotent with the push path.
|
|
75
76
|
Function("onRingEnded") { (callId: String, reason: String) in
|
|
@@ -143,6 +143,8 @@ final class CallCore {
|
|
|
143
143
|
private var cameraOn = false
|
|
144
144
|
private var cameraFacing: String?
|
|
145
145
|
private var audioRoute: String?
|
|
146
|
+
private var audioOutputs: [AudioOutput] = []
|
|
147
|
+
private var audioOutputId: String?
|
|
146
148
|
|
|
147
149
|
/// ring_ended reasons seen per call — the CallKit end reason.
|
|
148
150
|
private var ringEndReasons: [String: String] = [:]
|
|
@@ -270,6 +272,9 @@ final class CallCore {
|
|
|
270
272
|
}
|
|
271
273
|
}
|
|
272
274
|
|
|
275
|
+
/// Not optimistic: the adapter reports the route once iOS has moved it.
|
|
276
|
+
func setAudioOutput(_ id: String) { runtime.setAudioOutput(id) }
|
|
277
|
+
|
|
273
278
|
/// A video slot for the native view. Main thread.
|
|
274
279
|
func createVideoSurface() -> LiveKitVideoSurface { mediaPort.createVideoSurface() }
|
|
275
280
|
|
|
@@ -307,6 +312,8 @@ final class CallCore {
|
|
|
307
312
|
conversationName: conversationName,
|
|
308
313
|
participants: roster,
|
|
309
314
|
audioRoute: audioRoute ?? (state.media == "video" ? "speaker" : "earpiece"),
|
|
315
|
+
audioOutputs: audioOutputs,
|
|
316
|
+
audioOutputId: audioOutputId,
|
|
310
317
|
mutedSelf: mutedSelf,
|
|
311
318
|
cameraOn: cameraOn,
|
|
312
319
|
cameraFacing: cameraFacing
|
|
@@ -326,6 +333,8 @@ final class CallCore {
|
|
|
326
333
|
cameraOn = false
|
|
327
334
|
cameraFacing = nil
|
|
328
335
|
audioRoute = nil
|
|
336
|
+
audioOutputs = []
|
|
337
|
+
audioOutputId = nil
|
|
329
338
|
}
|
|
330
339
|
|
|
331
340
|
/// CallKit follows the FINAL state of each dispatch.
|
|
@@ -422,6 +431,10 @@ final class CallCore {
|
|
|
422
431
|
runtime.post { [weak self] in self?.audioRoute = r }
|
|
423
432
|
runtime.reemit()
|
|
424
433
|
}
|
|
434
|
+
fileprivate func outputs(_ list: [AudioOutput], selected: String?) {
|
|
435
|
+
runtime.post { [weak self] in self?.audioOutputs = list; self?.audioOutputId = selected }
|
|
436
|
+
runtime.reemit()
|
|
437
|
+
}
|
|
425
438
|
fileprivate func localCamera(on: Bool, facing: String?) {
|
|
426
439
|
runtime.post { [weak self] in self?.cameraOn = on; self?.cameraFacing = facing }
|
|
427
440
|
runtime.reemit()
|
|
@@ -497,6 +510,7 @@ private final class EventsAdapter: MediaEvents {
|
|
|
497
510
|
func onParticipants(_ participants: [SnapshotParticipant]) { core?.participants(participants) }
|
|
498
511
|
func onAudioRoute(_ route: String) { core?.route(route) }
|
|
499
512
|
func onLocalCamera(on: Bool, facing: String?) { core?.localCamera(on: on, facing: facing) }
|
|
513
|
+
func onAudioOutputs(_ outputs: [AudioOutput], selectedId: String?) { core?.outputs(outputs, selected: selectedId) }
|
|
500
514
|
}
|
|
501
515
|
|
|
502
516
|
/// Host-facing entry points.
|
|
@@ -23,6 +23,8 @@ protocol MediaEvents: AnyObject {
|
|
|
23
23
|
func onParticipants(_ participants: [SnapshotParticipant])
|
|
24
24
|
/// `earpiece` | `speaker` | `bluetooth` | `headset`.
|
|
25
25
|
func onAudioRoute(_ route: String)
|
|
26
|
+
/// Every output call audio can play through right now, and the one it does.
|
|
27
|
+
func onAudioOutputs(_ outputs: [AudioOutput], selectedId: String?)
|
|
26
28
|
/// The local camera track is (not) published + enabled; facing `front` | `back`.
|
|
27
29
|
func onLocalCamera(on: Bool, facing: String?)
|
|
28
30
|
}
|
|
@@ -66,8 +68,19 @@ final class LiveKitMediaPort: NSObject, MediaPort {
|
|
|
66
68
|
private var media = "audio"
|
|
67
69
|
private var connectedRoom: ObjectIdentifier?
|
|
68
70
|
|
|
69
|
-
///
|
|
70
|
-
|
|
71
|
+
/// Where the user asked the audio to go, for the CURRENT call.
|
|
72
|
+
enum OutputChoice: Equatable {
|
|
73
|
+
case speaker
|
|
74
|
+
/// The best private route: Bluetooth, then wired, then the earpiece.
|
|
75
|
+
case privateRoute
|
|
76
|
+
/// The phone itself: earpiece (or wired headphones, which replace it).
|
|
77
|
+
case builtIn
|
|
78
|
+
/// One external input/output pair, by AVAudioSessionPortDescription uid.
|
|
79
|
+
case port(String)
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/// The user's output choice; nil = automatic (see `resolvedChoice`).
|
|
83
|
+
private var outputWanted: OutputChoice?
|
|
71
84
|
/// The user's choices for the CURRENT call; survive a rejoin.
|
|
72
85
|
private var mutedWanted = false
|
|
73
86
|
private var cameraWanted: Bool?
|
|
@@ -76,8 +89,6 @@ final class LiveKitMediaPort: NSObject, MediaPort {
|
|
|
76
89
|
private let surfaces = NSHashTable<LiveKitVideoSurface>.weakObjects()
|
|
77
90
|
private var audioPrepared = false
|
|
78
91
|
private var audioActive = false
|
|
79
|
-
/// The room whose audio engine has been kicked (once per room).
|
|
80
|
-
private var audioKicked: ObjectIdentifier?
|
|
81
92
|
/// Asks CallKit to (re)try activating the audio session — set by
|
|
82
93
|
/// CallCore. Used when a room is live but activation never arrived.
|
|
83
94
|
var requestAudioActivation: (() -> Void)?
|
|
@@ -121,7 +132,6 @@ final class LiveKitMediaPort: NSObject, MediaPort {
|
|
|
121
132
|
audioActive = true
|
|
122
133
|
if media != self.media && room == nil { self.media = media }
|
|
123
134
|
reassertAudio(session)
|
|
124
|
-
kickAudioIfReady()
|
|
125
135
|
}
|
|
126
136
|
|
|
127
137
|
/// Connected with a live mic but CallKit never activated the session
|
|
@@ -147,48 +157,6 @@ final class LiveKitMediaPort: NSObject, MediaPort {
|
|
|
147
157
|
}
|
|
148
158
|
}
|
|
149
159
|
|
|
150
|
-
/// Once per call, the first moment the session is active AND the mic is
|
|
151
|
-
/// published (in whichever order they happen): make LiveKit's audio
|
|
152
|
-
/// engine rebuild around the live mic.
|
|
153
|
-
///
|
|
154
|
-
/// On a call answered with the app closed, the process is brand new and
|
|
155
|
-
/// LiveKit's engine never started for a mic published right after
|
|
156
|
-
/// CallKit made audio available — no capture, no playback, though the
|
|
157
|
-
/// session was correct (video, which needs no audio engine, worked). A
|
|
158
|
-
/// user-side mute → unmute cured it, because toggling the mic makes the
|
|
159
|
-
/// engine re-evaluate and start. This does the same, automatically: an
|
|
160
|
-
/// availability cycle first, then a mic off/on. A deliberate mute is
|
|
161
|
-
/// left alone. The remote hears at most a sub-second blip at connect.
|
|
162
|
-
private func kickAudioIfReady() {
|
|
163
|
-
guard audioActive, let r = room, connectedRoom == ObjectIdentifier(r),
|
|
164
|
-
audioKicked != ObjectIdentifier(r), !mutedWanted else { return }
|
|
165
|
-
guard r.localParticipant.isMicrophoneEnabled() else { return }
|
|
166
|
-
audioKicked = ObjectIdentifier(r)
|
|
167
|
-
pcTrace("kick: cycling engine availability + mic")
|
|
168
|
-
do {
|
|
169
|
-
try AudioManager.shared.setEngineAvailability(.none)
|
|
170
|
-
try AudioManager.shared.setEngineAvailability(.default)
|
|
171
|
-
} catch {
|
|
172
|
-
NSLog("[PoolseCalls] engine availability cycle failed: %@", "\(error)")
|
|
173
|
-
}
|
|
174
|
-
Task { [weak self] in
|
|
175
|
-
try? await Task.sleep(nanoseconds: 250_000_000)
|
|
176
|
-
let still = await MainActor.run { () -> Bool in
|
|
177
|
-
guard let self = self else { return false }
|
|
178
|
-
return self.room === r && self.audioActive && !self.mutedWanted
|
|
179
|
-
}
|
|
180
|
-
guard still else { return }
|
|
181
|
-
do {
|
|
182
|
-
try await r.localParticipant.setMicrophone(enabled: false)
|
|
183
|
-
try await r.localParticipant.setMicrophone(enabled: true)
|
|
184
|
-
} catch {
|
|
185
|
-
NSLog("[PoolseCalls] mic cycle failed: %@", "\(error)")
|
|
186
|
-
}
|
|
187
|
-
pcTrace("kick done enabled=\(r.localParticipant.isMicrophoneEnabled()) \(pcSessionDescription())")
|
|
188
|
-
await MainActor.run { self?.reassertAudio() }
|
|
189
|
-
}
|
|
190
|
-
}
|
|
191
|
-
|
|
192
160
|
/// Put the call's audio back the way the call needs it: play-and-record
|
|
193
161
|
/// in the voice/video chat mode, LiveKit's engine available, the chosen
|
|
194
162
|
/// route. Idempotent — only touches what differs — so it is safe to run
|
|
@@ -199,7 +167,7 @@ final class LiveKitMediaPort: NSObject, MediaPort {
|
|
|
199
167
|
func reassertAudio(_ session: AVAudioSession = .sharedInstance()) {
|
|
200
168
|
guard audioActive else { return }
|
|
201
169
|
pcTrace("reassertAudio before: \(pcSessionDescription(session))")
|
|
202
|
-
let mode: AVAudioSession.Mode = media == "video" &&
|
|
170
|
+
let mode: AVAudioSession.Mode = media == "video" && resolvedChoice(session) == .speaker ? .videoChat : .voiceChat
|
|
203
171
|
if session.category != .playAndRecord || session.mode != mode {
|
|
204
172
|
do {
|
|
205
173
|
try session.setCategory(.playAndRecord, mode: mode,
|
|
@@ -289,7 +257,6 @@ final class LiveKitMediaPort: NSObject, MediaPort {
|
|
|
289
257
|
// then make sure the engine is actually carrying the mic.
|
|
290
258
|
await MainActor.run {
|
|
291
259
|
self.reassertAudio()
|
|
292
|
-
self.kickAudioIfReady()
|
|
293
260
|
self.ensureAudioActivated(r, attempt: 0)
|
|
294
261
|
}
|
|
295
262
|
let camera = await MainActor.run { self.cameraWanted ?? wantsVideo }
|
|
@@ -305,7 +272,7 @@ final class LiveKitMediaPort: NSObject, MediaPort {
|
|
|
305
272
|
private func markConnected(_ r: Room) {
|
|
306
273
|
guard room === r, connectedRoom != ObjectIdentifier(r) else { return }
|
|
307
274
|
connectedRoom = ObjectIdentifier(r)
|
|
308
|
-
if
|
|
275
|
+
if outputWanted != nil { applyRoute() }
|
|
309
276
|
events?.onConnected()
|
|
310
277
|
if !r.remoteParticipants.isEmpty { events?.onRemoteJoined() }
|
|
311
278
|
emit(r)
|
|
@@ -339,7 +306,7 @@ final class LiveKitMediaPort: NSObject, MediaPort {
|
|
|
339
306
|
private func resetWanted() {
|
|
340
307
|
mutedWanted = false
|
|
341
308
|
cameraWanted = nil
|
|
342
|
-
|
|
309
|
+
outputWanted = nil
|
|
343
310
|
}
|
|
344
311
|
|
|
345
312
|
private func detach() {
|
|
@@ -387,25 +354,72 @@ final class LiveKitMediaPort: NSObject, MediaPort {
|
|
|
387
354
|
}
|
|
388
355
|
|
|
389
356
|
func setSpeaker(_ on: Bool) {
|
|
390
|
-
|
|
357
|
+
outputWanted = on ? .speaker : .privateRoute
|
|
358
|
+
applyRoute()
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
/// An id from the snapshot's `audioOutputs`: `speaker`, `earpiece`,
|
|
362
|
+
/// `wired`, or `port:<uid>`. An id that is no longer connected is ignored.
|
|
363
|
+
func setAudioOutput(_ id: String) {
|
|
364
|
+
switch id {
|
|
365
|
+
case "speaker": outputWanted = .speaker
|
|
366
|
+
case "earpiece", "wired": outputWanted = .builtIn
|
|
367
|
+
default:
|
|
368
|
+
guard id.hasPrefix("port:") else { return }
|
|
369
|
+
let uid = String(id.dropFirst("port:".count))
|
|
370
|
+
// An output with no matching input (an A2DP-only speaker) cannot be
|
|
371
|
+
// chosen through the session; the system's own route wins there.
|
|
372
|
+
outputWanted = selectablePort(uid) != nil ? .port(uid) : .privateRoute
|
|
373
|
+
}
|
|
391
374
|
applyRoute()
|
|
392
375
|
}
|
|
393
376
|
|
|
394
377
|
// MARK: - audio routing
|
|
395
378
|
|
|
396
|
-
|
|
397
|
-
|
|
379
|
+
private static let externalInputs: Set<AVAudioSession.Port> =
|
|
380
|
+
[.bluetoothHFP, .bluetoothLE, .carAudio, .usbAudio, .headsetMic]
|
|
381
|
+
private static let externalOutputs: Set<AVAudioSession.Port> =
|
|
382
|
+
[.bluetoothHFP, .bluetoothA2DP, .bluetoothLE, .carAudio, .usbAudio, .headphones, .airPlay, .lineOut, .HDMI]
|
|
383
|
+
|
|
384
|
+
private func selectablePort(_ uid: String, _ s: AVAudioSession = .sharedInstance()) -> AVAudioSessionPortDescription? {
|
|
385
|
+
s.availableInputs?.first { $0.uid == uid && Self.externalInputs.contains($0.portType) }
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
/// A headset, Bluetooth device or car is connected — something the
|
|
389
|
+
/// user put on or plugged in, which should carry the call by default.
|
|
390
|
+
private func externalConnected(_ s: AVAudioSession) -> Bool {
|
|
391
|
+
(s.availableInputs ?? []).contains { Self.externalInputs.contains($0.portType) && $0.portType != .usbAudio }
|
|
392
|
+
|| s.currentRoute.outputs.contains { Self.externalOutputs.contains($0.portType) }
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
/// What the call should do now. Automatic: a voice call is held to the
|
|
396
|
+
/// ear and a video call uses the loudspeaker — unless a headset or
|
|
397
|
+
/// Bluetooth device is connected, which wins either way (FaceTime does
|
|
398
|
+
/// the same; forcing the speaker over AirPods was a bug).
|
|
399
|
+
private func resolvedChoice(_ s: AVAudioSession = .sharedInstance()) -> OutputChoice {
|
|
400
|
+
if let wanted = outputWanted { return wanted }
|
|
401
|
+
return media == "video" && !externalConnected(s) ? .speaker : .privateRoute
|
|
402
|
+
}
|
|
403
|
+
|
|
398
404
|
private func applyRoute() {
|
|
399
405
|
guard audioActive else { return }
|
|
400
406
|
let s = AVAudioSession.sharedInstance()
|
|
401
|
-
let
|
|
407
|
+
if case .port(let uid) = outputWanted, selectablePort(uid, s) == nil { outputWanted = nil }
|
|
408
|
+
let choice = resolvedChoice(s)
|
|
402
409
|
do {
|
|
403
|
-
|
|
410
|
+
switch choice {
|
|
411
|
+
case .speaker:
|
|
404
412
|
try s.overrideOutputAudioPort(.speaker)
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
if s.
|
|
408
|
-
|
|
413
|
+
case .privateRoute:
|
|
414
|
+
try leaveSpeaker(s)
|
|
415
|
+
if s.preferredInput != nil { try s.setPreferredInput(nil) }
|
|
416
|
+
case .builtIn:
|
|
417
|
+
try leaveSpeaker(s)
|
|
418
|
+
let mic = s.availableInputs?.first { $0.portType == .builtInMic }
|
|
419
|
+
if let mic = mic, s.preferredInput?.uid != mic.uid { try s.setPreferredInput(mic) }
|
|
420
|
+
case .port(let uid):
|
|
421
|
+
try leaveSpeaker(s)
|
|
422
|
+
if let port = selectablePort(uid, s), s.preferredInput?.uid != uid { try s.setPreferredInput(port) }
|
|
409
423
|
}
|
|
410
424
|
} catch {
|
|
411
425
|
NSLog("[PoolseCalls] audio route failed: %@", "\(error)")
|
|
@@ -413,21 +427,77 @@ final class LiveKitMediaPort: NSObject, MediaPort {
|
|
|
413
427
|
reportRoute()
|
|
414
428
|
}
|
|
415
429
|
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
430
|
+
/// .videoChat forces the speaker; every private route needs .voiceChat.
|
|
431
|
+
private func leaveSpeaker(_ s: AVAudioSession) throws {
|
|
432
|
+
if s.mode == .videoChat { try s.setMode(.voiceChat) }
|
|
433
|
+
try s.overrideOutputAudioPort(.none)
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
private func kind(of port: AVAudioSession.Port) -> String {
|
|
419
437
|
switch port {
|
|
420
438
|
case .builtInSpeaker: return "speaker"
|
|
421
439
|
case .builtInReceiver: return "earpiece"
|
|
422
440
|
case .bluetoothHFP, .bluetoothA2DP, .bluetoothLE, .carAudio, .airPlay: return "bluetooth"
|
|
423
|
-
case .headphones, .usbAudio, .lineOut, .HDMI: return "headset"
|
|
424
441
|
default: return "headset"
|
|
425
442
|
}
|
|
426
443
|
}
|
|
427
444
|
|
|
445
|
+
private func currentRoute() -> String {
|
|
446
|
+
guard let port = AVAudioSession.sharedInstance().currentRoute.outputs.first?.portType else {
|
|
447
|
+
return media == "video" ? "speaker" : "earpiece"
|
|
448
|
+
}
|
|
449
|
+
return kind(of: port)
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
/// Every output the user can pick, and the one playing now.
|
|
453
|
+
///
|
|
454
|
+
/// iOS lists inputs, not outputs: a headset or Bluetooth device is
|
|
455
|
+
/// selectable through its microphone (its input and output share the
|
|
456
|
+
/// uid), while the earpiece and speaker are always there on a phone.
|
|
457
|
+
/// Wired headphones replace the earpiece rather than sitting beside it.
|
|
458
|
+
private func outputs() -> ([AudioOutput], String?) {
|
|
459
|
+
let s = AVAudioSession.sharedInstance()
|
|
460
|
+
let inputs = s.availableInputs ?? []
|
|
461
|
+
let current = s.currentRoute.outputs.first
|
|
462
|
+
var list: [AudioOutput] = []
|
|
463
|
+
|
|
464
|
+
let wiredOut = s.currentRoute.outputs.first { $0.portType == .headphones }
|
|
465
|
+
if inputs.contains(where: { $0.portType == .headsetMic }) || wiredOut != nil {
|
|
466
|
+
list.append(AudioOutput(id: "wired", kind: "headset", name: "Headphones"))
|
|
467
|
+
} else if UIDevice.current.userInterfaceIdiom == .phone {
|
|
468
|
+
list.append(AudioOutput(id: "earpiece", kind: "earpiece", name: UIDevice.current.model))
|
|
469
|
+
}
|
|
470
|
+
list.append(AudioOutput(id: "speaker", kind: "speaker", name: "Speaker"))
|
|
471
|
+
for p in inputs where Self.externalInputs.contains(p.portType) && p.portType != .headsetMic {
|
|
472
|
+
list.append(AudioOutput(id: "port:\(p.uid)", kind: p.portType == .usbAudio ? "headset" : "bluetooth", name: p.portName))
|
|
473
|
+
}
|
|
474
|
+
// Playing through something with no microphone (an A2DP speaker,
|
|
475
|
+
// AirPlay): show it so the selection is truthful, even though only
|
|
476
|
+
// the system picker can choose it.
|
|
477
|
+
if let c = current, Self.externalOutputs.contains(c.portType), c.portType != .headphones,
|
|
478
|
+
!list.contains(where: { $0.id == "port:\(c.uid)" || $0.name == c.portName }) {
|
|
479
|
+
list.append(AudioOutput(id: "port:\(c.uid)", kind: kind(of: c.portType), name: c.portName))
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
let selected: String? = current.map { c in
|
|
483
|
+
switch c.portType {
|
|
484
|
+
case .builtInSpeaker: return "speaker"
|
|
485
|
+
case .builtInReceiver: return "earpiece"
|
|
486
|
+
case .headphones: return "wired"
|
|
487
|
+
default:
|
|
488
|
+
return list.first { $0.id == "port:\(c.uid)" }?.id
|
|
489
|
+
?? list.first { $0.name == c.portName }?.id
|
|
490
|
+
?? "port:\(c.uid)"
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
return (list, selected)
|
|
494
|
+
}
|
|
495
|
+
|
|
428
496
|
private func reportRoute() {
|
|
429
497
|
guard joinedCallId != nil || audioActive else { return }
|
|
430
498
|
events?.onAudioRoute(currentRoute())
|
|
499
|
+
let (list, selected) = outputs()
|
|
500
|
+
events?.onAudioOutputs(list, selectedId: selected)
|
|
431
501
|
}
|
|
432
502
|
|
|
433
503
|
/// An interruption (another app's audio, Siri) ended: the session may
|
|
@@ -455,14 +525,27 @@ final class LiveKitMediaPort: NSObject, MediaPort {
|
|
|
455
525
|
self.reassertAudio()
|
|
456
526
|
return
|
|
457
527
|
}
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
528
|
+
switch reason {
|
|
529
|
+
case .newDeviceAvailable:
|
|
530
|
+
// Putting on a headset or AirPods mid-call moves the call to it,
|
|
531
|
+
// whatever was picked before — as the Phone app does.
|
|
532
|
+
self.outputWanted = nil
|
|
462
533
|
self.applyRoute()
|
|
463
|
-
|
|
534
|
+
case .oldDeviceUnavailable:
|
|
535
|
+
// A device went away. If it was the chosen one, applyRoute drops
|
|
536
|
+
// the choice and falls back to automatic — speaker for video,
|
|
537
|
+
// earpiece for voice; any other choice stands.
|
|
538
|
+
self.applyRoute()
|
|
539
|
+
default:
|
|
540
|
+
// The override is cleared by any route change: put the speaker
|
|
541
|
+
// back (or take it away) if that is not what the call wants.
|
|
542
|
+
let onSpeaker = self.currentRoute() == "speaker"
|
|
543
|
+
if (self.resolvedChoice() == .speaker) != onSpeaker && reason != .override {
|
|
544
|
+
self.applyRoute()
|
|
545
|
+
} else {
|
|
546
|
+
self.reportRoute()
|
|
547
|
+
}
|
|
464
548
|
}
|
|
465
|
-
self.reportRoute()
|
|
466
549
|
}
|
|
467
550
|
}
|
|
468
551
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@poolse/react-native-calls",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.0",
|
|
4
4
|
"description": "Native call core for poolse — Kotlin/Swift calling engine with a thin React Native bridge. Ring, answer and carry audio with the app killed and the phone locked.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
package/src/index.ts
CHANGED
|
@@ -41,7 +41,17 @@ export interface Snapshot {
|
|
|
41
41
|
/** True only with a published, subscribed, unmuted camera track (show video, not avatar). */
|
|
42
42
|
cameraOn: boolean;
|
|
43
43
|
}>;
|
|
44
|
-
|
|
44
|
+
/** The kind of output playing now — the short answer to "where is the audio". */
|
|
45
|
+
audioRoute: AudioOutputKind;
|
|
46
|
+
/**
|
|
47
|
+
* Every output the call can play through right now: always the phone
|
|
48
|
+
* (earpiece, or wired headphones in its place) and the speaker, plus
|
|
49
|
+
* each connected Bluetooth / USB device by its own name. Empty until
|
|
50
|
+
* the call's audio is up. Pass an `id` to `setAudioOutput`.
|
|
51
|
+
*/
|
|
52
|
+
audioOutputs: AudioOutput[];
|
|
53
|
+
/** The `audioOutputs` entry playing now; null before audio is up. */
|
|
54
|
+
audioOutputId: string | null;
|
|
45
55
|
mutedSelf: boolean;
|
|
46
56
|
/** True only while the local camera track is actually published and enabled. */
|
|
47
57
|
cameraOn: boolean;
|
|
@@ -49,6 +59,21 @@ export interface Snapshot {
|
|
|
49
59
|
cameraFacing?: 'front' | 'back';
|
|
50
60
|
}
|
|
51
61
|
|
|
62
|
+
export type AudioOutputKind = 'earpiece' | 'speaker' | 'bluetooth' | 'headset';
|
|
63
|
+
|
|
64
|
+
/** One place call audio can play. */
|
|
65
|
+
export interface AudioOutput {
|
|
66
|
+
/** Opaque; stable while the device stays connected. */
|
|
67
|
+
id: string;
|
|
68
|
+
kind: AudioOutputKind;
|
|
69
|
+
/**
|
|
70
|
+
* The device's own name for Bluetooth/USB ("AirPods Pro"); a plain
|
|
71
|
+
* English default for built-ins ("iPhone"/"Phone", "Speaker",
|
|
72
|
+
* "Headphones") — relabel those by `kind` to localise.
|
|
73
|
+
*/
|
|
74
|
+
name: string;
|
|
75
|
+
}
|
|
76
|
+
|
|
52
77
|
export interface ConfigureOptions {
|
|
53
78
|
/** Drawable resource NAME for the call notification's small icon. */
|
|
54
79
|
smallIcon?: string;
|
|
@@ -89,6 +114,7 @@ interface PoolseCallsNative {
|
|
|
89
114
|
setCamera(on: boolean): void;
|
|
90
115
|
switchCamera(): void;
|
|
91
116
|
setSpeaker(on: boolean): void;
|
|
117
|
+
setAudioOutput?(id: string): void;
|
|
92
118
|
onRingEnded(callId: string, reason: string): void;
|
|
93
119
|
onCallFinished(callId: string, reason: string | null): void;
|
|
94
120
|
canUseFullScreenIntent(): boolean;
|
|
@@ -143,6 +169,18 @@ export const switchCamera = (): void => native?.switchCamera();
|
|
|
143
169
|
/** Route call audio to the loudspeaker (`true`) or back to earpiece / headset (`false`). */
|
|
144
170
|
export const setSpeaker = (on: boolean): void => native?.setSpeaker(on);
|
|
145
171
|
|
|
172
|
+
/**
|
|
173
|
+
* Play call audio through one of `snapshot.audioOutputs`. The choice
|
|
174
|
+
* holds for the rest of the call, until that device disconnects or a new
|
|
175
|
+
* headset / Bluetooth device connects (which takes over, as in the
|
|
176
|
+
* phone's own dialer). Without a choice a connected headset wins, then
|
|
177
|
+
* the earpiece for voice calls or the speaker for video.
|
|
178
|
+
*/
|
|
179
|
+
export function setAudioOutput(id: string): void {
|
|
180
|
+
if (native?.setAudioOutput) native.setAudioOutput(id);
|
|
181
|
+
else native?.setSpeaker(id === 'speaker'); // an older native build
|
|
182
|
+
}
|
|
183
|
+
|
|
146
184
|
/**
|
|
147
185
|
* Socket forwarding for an open app: a `call:ring_ended` seen on the
|
|
148
186
|
* socket. Idempotent with the push path — the core dedupes.
|