@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 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
- /** Speaker forced by the user; null = the media-type default. */
65
- private var speakerWanted: Boolean? = null
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
- speakerWanted = null
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
- speakerWanted = on
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
- * A voice call is held to the ear: earpiece by default (a connected
268
- * headset or Bluetooth device first); video defaults to the speaker.
269
- * Set before connect — the handler starts during connect.
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
- // A user's speaker choice survives device-list changes, except
290
- // that plugging in a headset always wins (as the dialer does).
291
- val wanted = speakerWanted
292
- if (wanted != null && selected != null && room?.audioHandler === handler) {
293
- val external = selected is AudioDevice.BluetoothHeadset || selected is AudioDevice.WiredHeadset
294
- val isSpeaker = selected is AudioDevice.Speakerphone
295
- if (!external && isSpeaker != wanted) devices.pick(wanted)?.let(handler::selectDevice)
296
- }
297
- events.onAudioRoute(routeOf(handler.selectedAudioDevice ?: selected))
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 want = speakerWanted ?: (media == "video")
303
- handler.availableAudioDevices.pick(want)?.let(handler::selectDevice)
304
- events.onAudioRoute(routeOf(handler.selectedAudioDevice))
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
- /** Speaker, or the best private route: Bluetooth → wired → earpiece. */
308
- private fun List<AudioDevice>.pick(speaker: Boolean): AudioDevice? =
309
- if (speaker) firstOrNull { it is AudioDevice.Speakerphone }
310
- else firstOrNull { it is AudioDevice.BluetoothHeadset }
311
- ?: firstOrNull { it is AudioDevice.WiredHeadset }
312
- ?: firstOrNull { it is AudioDevice.Earpiece }
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 { if (speakerWanted != null) applyRoute(it) }
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;
@@ -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":"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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 /** 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
- audioRoute: 'earpiece' | 'speaker' | 'bluetooth' | 'headset';
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
- audioRoute: 'earpiece' | 'speaker' | 'bluetooth' | 'headset';
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) } } }
@@ -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
- /// Speaker forced by the user; nil = the media-type default.
70
- private var speakerWanted: Bool?
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" && speakerWanted != false ? .videoChat : .voiceChat
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 speakerWanted != nil { applyRoute() }
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
- speakerWanted = nil
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
- speakerWanted = on
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
- /// A voice call is held to the ear (earpiece, or a headset/Bluetooth
397
- /// route first); video defaults to the loudspeaker.
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 want = speakerWanted ?? (media == "video")
407
+ if case .port(let uid) = outputWanted, selectablePort(uid, s) == nil { outputWanted = nil }
408
+ let choice = resolvedChoice(s)
402
409
  do {
403
- if want {
410
+ switch choice {
411
+ case .speaker:
404
412
  try s.overrideOutputAudioPort(.speaker)
405
- } else {
406
- // .videoChat forces the speaker; the private route needs .voiceChat.
407
- if s.mode == .videoChat { try s.setMode(.voiceChat) }
408
- try s.overrideOutputAudioPort(.none)
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
- private func currentRoute() -> String {
417
- let outputs = AVAudioSession.sharedInstance().currentRoute.outputs
418
- guard let port = outputs.first?.portType else { return media == "video" ? "speaker" : "earpiece" }
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
- // A user's speaker choice survives route changes, except that a
459
- // newly connected headset/Bluetooth device always wins.
460
- let route = self.currentRoute()
461
- if let wanted = self.speakerWanted, route == "speaker" || route == "earpiece", (route == "speaker") != wanted {
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
- return
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.4.5",
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
- audioRoute: 'earpiece' | 'speaker' | 'bluetooth' | 'headset';
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.