@poolse/react-native-calls 0.8.0 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (28) hide show
  1. package/README.md +27 -0
  2. package/android/src/main/java/dev/poolse/callcore/CallCoreRuntime.kt +69 -6
  3. package/android/src/main/java/dev/poolse/callcore/CallMachine.kt +108 -3
  4. package/android/src/main/java/dev/poolse/callcore/Ports.kt +14 -0
  5. package/android/src/main/java/dev/poolse/callcore/android/AndroidRingSurface.kt +121 -0
  6. package/android/src/main/java/dev/poolse/callcore/android/CallActionReceiver.kt +16 -0
  7. package/android/src/main/java/dev/poolse/callcore/android/CallCore.kt +14 -2
  8. package/android/src/main/java/dev/poolse/callcore/android/CallHttpClient.kt +1 -0
  9. package/android/src/main/java/dev/poolse/callcore/android/SnapshotJson.kt +17 -0
  10. package/android/src/main/java/expo/modules/poolsecalls/PoolseCallsModule.kt +2 -0
  11. package/android/src/main/livekit/java/dev/poolse/callcore/livekit/LiveKitMediaPort.kt +7 -3
  12. package/dist/index.cjs +4 -0
  13. package/dist/index.cjs.map +1 -1
  14. package/dist/index.d.cts +31 -1
  15. package/dist/index.d.ts +31 -1
  16. package/dist/index.js +3 -1
  17. package/dist/index.js.map +1 -1
  18. package/ios/Core/CallCoreRuntime.swift +76 -6
  19. package/ios/Core/CallHttpClient.swift +2 -1
  20. package/ios/Core/CallMachine.swift +91 -1
  21. package/ios/Core/Ports.swift +37 -2
  22. package/ios/Core/SnapshotEncoding.swift +16 -1
  23. package/ios/Module/PoolseCallsModule.swift +2 -0
  24. package/ios/Platform/CallCore.swift +69 -16
  25. package/ios/Platform/CallKitController.swift +7 -1
  26. package/ios/Platform/LiveKitMediaPort.swift +6 -2
  27. package/package.json +1 -1
  28. package/src/index.ts +35 -0
@@ -36,6 +36,12 @@ public enum Event: Equatable {
36
36
  /// CONNECTING watchdog.
37
37
  case connectTimeout
38
38
  case lingerElapsed
39
+ /// Call waiting: leave the current call and answer the waiting one.
40
+ case userAnswerWaiting
41
+ /// Call waiting: turn the waiting call away — its caller hears busy.
42
+ case userDeclineWaiting
43
+ /// The waiting call's ring deadline passed.
44
+ case waitingDeadline
39
45
  }
40
46
 
41
47
  /// Abstract effects — each adapter maps them to its platform.
@@ -44,6 +50,10 @@ public enum Effect: Equatable {
44
50
  case httpAnswer, httpJoin, httpPlace, httpDecline
45
51
  case httpDeclineBusy(callId: String)
46
52
  case httpHangup, httpReconnect, mediaJoin, mediaLeave, callSessionStart, callSessionStop
53
+ case showWaiting, dismissWaiting
54
+ /// The waiting call becomes the current one: retire the old call's
55
+ /// grant/bundle/generation and take the waiting ring's.
56
+ case promoteWaiting
47
57
 
48
58
  public var wire: String {
49
59
  switch self {
@@ -64,6 +74,9 @@ public enum Effect: Equatable {
64
74
  case .mediaLeave: return "MEDIA_LEAVE"
65
75
  case .callSessionStart: return "CALL_SESSION_START"
66
76
  case .callSessionStop: return "CALL_SESSION_STOP"
77
+ case .showWaiting: return "SHOW_WAITING"
78
+ case .dismissWaiting: return "DISMISS_WAITING"
79
+ case .promoteWaiting: return "PROMOTE_WAITING"
67
80
  }
68
81
  }
69
82
  }
@@ -91,7 +104,80 @@ private extension MachineState {
91
104
 
92
105
  public enum CallMachine {
93
106
 
107
+ /// Phases that can hold a call waiting beside them: this device is on
108
+ /// (or placing, or joining) a call. A second ring while one is ringING
109
+ /// in is still busy — two rings at once is not a choice anyone can make.
110
+ private static func canHoldWaiting(_ s: MachineState) -> Bool {
111
+ switch s.phase {
112
+ case .ringingOut, .connecting, .active, .reconnecting: return true
113
+ default: return false
114
+ }
115
+ }
116
+
117
+ /// The ring surface effects the end-and-answer composite skips: the
118
+ /// waiting call goes straight from waiting to connecting, never ringing.
119
+ private static let ringSurfaceEffects: [Effect] = [.showRing, .ringtoneStart, .ringtoneStop, .dismissRing]
120
+
94
121
  public static func transition(_ s: MachineState, _ e: Event) -> Transition {
122
+ guard let w = s.waiting else {
123
+ switch e {
124
+ case let .ring(callId, conversationId, media, stale)
125
+ where !stale && canHoldWaiting(s) && callId != s.callId:
126
+ var next = s
127
+ next.waiting = WaitingCall(callId: callId, conversationId: conversationId, media: media)
128
+ return Transition(next, [.showWaiting])
129
+ case .userAnswerWaiting, .userDeclineWaiting, .waitingDeadline:
130
+ return noop(s)
131
+ default:
132
+ return single(s, e)
133
+ }
134
+ }
135
+
136
+ var cleared = s
137
+ cleared.waiting = nil
138
+ switch e {
139
+ case let .ring(callId, _, _, stale):
140
+ if callId == w.callId || stale { return noop(s) }
141
+ if callId == s.callId { return carry(single(cleared, e), w) }
142
+ return Transition(s, [.httpDeclineBusy(callId: callId)])
143
+
144
+ case let .ringEnded(callId, _) where callId == w.callId:
145
+ return Transition(cleared, [.dismissWaiting])
146
+ case let .callFinished(callId, _) where callId == w.callId:
147
+ return Transition(cleared, [.dismissWaiting])
148
+ case .waitingDeadline:
149
+ return Transition(cleared, [.dismissWaiting])
150
+ case .userDeclineWaiting:
151
+ return Transition(cleared, [.httpDeclineBusy(callId: w.callId), .dismissWaiting])
152
+
153
+ case .userAnswerWaiting:
154
+ // Leaving rings the waiting call (below); answering it at once
155
+ // makes that ring a no-op the surface never sees.
156
+ let left = transition(s, .userHangup)
157
+ guard left.state.phase == .ringingIn, left.state.callId == w.callId else { return noop(s) }
158
+ let answered = single(left.state, .userAnswer)
159
+ let effects = (left.effects + answered.effects).filter { !ringSurfaceEffects.contains($0) }
160
+ return Transition(answered.state, effects)
161
+
162
+ default:
163
+ let t = single(cleared, e)
164
+ guard t.state.phase == .ended || t.state.phase == .idle else { return carry(t, w) }
165
+ // The current call is over while another rings beside it: that
166
+ // ring is now simply THE ring (no ended screen in between).
167
+ let ringing = MachineState(phase: .ringingIn, callId: w.callId, conversationId: w.conversationId,
168
+ direction: .incoming, media: w.media)
169
+ return Transition(ringing, t.effects + [.promoteWaiting, .showRing, .ringtoneStart])
170
+ }
171
+ }
172
+
173
+ private static func carry(_ t: Transition, _ w: WaitingCall) -> Transition {
174
+ var state = t.state
175
+ state.waiting = w
176
+ return Transition(state, t.effects)
177
+ }
178
+
179
+ /// The one-call machine: everything that is not about a waiting call.
180
+ private static func single(_ s: MachineState, _ e: Event) -> Transition {
95
181
  switch e {
96
182
 
97
183
  case let .ring(callId, conversationId, media, stale):
@@ -271,6 +357,9 @@ public enum CallMachine {
271
357
 
272
358
  case .lingerElapsed:
273
359
  return s.phase == .ended ? Transition(.IDLE, []) : noop(s)
360
+
361
+ case .userAnswerWaiting, .userDeclineWaiting, .waitingDeadline:
362
+ return noop(s) // handled by `transition`, which owns the waiting slot
274
363
  }
275
364
  }
276
365
 
@@ -285,7 +374,8 @@ public enum CallMachine {
285
374
  direction: .incoming, media: media),
286
375
  [.showRing, .ringtoneStart])
287
376
  }
288
- // Busy with another call: auto-decline the newcomer.
377
+ // Busy ringing another call (a call in progress holds it waiting
378
+ // instead — see `transition`): auto-decline the newcomer.
289
379
  return Transition(s, [.httpDeclineBusy(callId: callId)])
290
380
  }
291
381
 
@@ -46,9 +46,12 @@ public struct CallSessionBundle: Equatable {
46
46
  public var livekitUrl: String
47
47
  public var livekitToken: String
48
48
  public var controlToken: String
49
+ /// `room` (rings nobody, CONTRACT.md §5.6) or `call`.
50
+ public var kind: String
49
51
 
50
52
  public init(callId: String, conversationId: String, conversationName: String?, media: String,
51
- created: Bool, livekitUrl: String, livekitToken: String, controlToken: String) {
53
+ created: Bool, livekitUrl: String, livekitToken: String, controlToken: String,
54
+ kind: String = "call") {
52
55
  self.callId = callId
53
56
  self.conversationId = conversationId
54
57
  self.conversationName = conversationName
@@ -57,6 +60,7 @@ public struct CallSessionBundle: Equatable {
57
60
  self.livekitUrl = livekitUrl
58
61
  self.livekitToken = livekitToken
59
62
  self.controlToken = controlToken
63
+ self.kind = kind
60
64
  }
61
65
  }
62
66
 
@@ -90,6 +94,20 @@ public protocol RingSurfacePort: AnyObject {
90
94
  func ringtoneStop()
91
95
  func ringbackStart()
92
96
  func ringbackStop()
97
+ /// A call waiting beside the current one: its own quiet surface (iOS:
98
+ /// CallKit's call-waiting UI; Android: a heads-up with a waiting tone).
99
+ func showWaiting(ring: RingContext)
100
+ /// The waiting call is over (declined, cancelled, timed out).
101
+ func dismissWaiting(callId: String)
102
+ /// The waiting call becomes THE ring (SHOW_RING follows): drop its
103
+ /// waiting surface without resolving it.
104
+ func promoteWaiting(callId: String)
105
+ }
106
+
107
+ public extension RingSurfacePort {
108
+ func showWaiting(ring: RingContext) {}
109
+ func dismissWaiting(callId: String) {}
110
+ func promoteWaiting(callId: String) {}
93
111
  }
94
112
 
95
113
  public struct RingContext: Equatable {
@@ -173,6 +191,20 @@ public enum EndReason: String {
173
191
  public var wire: String { rawValue.lowercased() }
174
192
  }
175
193
 
194
+ /// A second call ringing while this device is on one (CONTRACT.md §7,
195
+ /// call waiting): shown beside the current call, never instead of it.
196
+ public struct WaitingCall: Equatable {
197
+ public var callId: String
198
+ public var conversationId: String?
199
+ public var media: String
200
+
201
+ public init(callId: String, conversationId: String?, media: String) {
202
+ self.callId = callId
203
+ self.conversationId = conversationId
204
+ self.media = media
205
+ }
206
+ }
207
+
176
208
  /// Everything the machine knows. Immutable value; transitions return a new one.
177
209
  public struct MachineState: Equatable {
178
210
  public var phase: Phase
@@ -183,10 +215,12 @@ public struct MachineState: Equatable {
183
215
  public var reason: EndReason?
184
216
  /// HTTP attempts made for the current connecting request (retry budget = 2).
185
217
  public var httpAttempts: Int
218
+ /// A second call ringing beside this one, if any.
219
+ public var waiting: WaitingCall?
186
220
 
187
221
  public init(phase: Phase = .idle, callId: String? = nil, conversationId: String? = nil,
188
222
  direction: Direction? = nil, media: String = "audio", reason: EndReason? = nil,
189
- httpAttempts: Int = 0) {
223
+ httpAttempts: Int = 0, waiting: WaitingCall? = nil) {
190
224
  self.phase = phase
191
225
  self.callId = callId
192
226
  self.conversationId = conversationId
@@ -194,6 +228,7 @@ public struct MachineState: Equatable {
194
228
  self.media = media
195
229
  self.reason = reason
196
230
  self.httpAttempts = httpAttempts
231
+ self.waiting = waiting
197
232
  }
198
233
 
199
234
  public static var IDLE: MachineState { MachineState() }
@@ -66,19 +66,22 @@ public extension MachineState {
66
66
  /// fields are merged in by the bridge from the media adapter.
67
67
  /// `conversationName` nil → NSNull; `reason`/`cameraFacing` omitted when nil.
68
68
  func toSnapshot(conversationName: String? = nil,
69
+ kind: String = "call",
69
70
  participants: [SnapshotParticipant] = [],
70
71
  audioRoute: String = "earpiece",
71
72
  audioOutputs: [AudioOutput] = [],
72
73
  audioOutputId: String? = nil,
73
74
  mutedSelf: Bool = false,
74
75
  cameraOn: Bool = false,
75
- cameraFacing: String? = nil) -> [String: Any]? {
76
+ cameraFacing: String? = nil,
77
+ waiting: RingContext? = nil) -> [String: Any]? {
76
78
  if phase == .idle { return nil }
77
79
  var out: [String: Any] = [
78
80
  "cv": 1,
79
81
  "callId": callId.map { $0 as Any } ?? NSNull(),
80
82
  "conversationId": conversationId.map { $0 as Any } ?? NSNull(),
81
83
  "conversationName": conversationName.map { $0 as Any } ?? NSNull(),
84
+ "kind": kind,
82
85
  "direction": (direction ?? .incoming).wire,
83
86
  "media": media,
84
87
  "phase": phase.wire,
@@ -89,6 +92,18 @@ public extension MachineState {
89
92
  "mutedSelf": mutedSelf,
90
93
  "cameraOn": cameraOn,
91
94
  ]
95
+ // The call waiting beside this one: null when none.
96
+ if let w = self.waiting {
97
+ out["waiting"] = [
98
+ "callId": w.callId,
99
+ "conversationId": (waiting?.conversationId ?? w.conversationId).map { $0 as Any } ?? NSNull(),
100
+ "conversationName": waiting?.conversationName.map { $0 as Any } ?? NSNull(),
101
+ "callerName": waiting?.callerName.map { $0 as Any } ?? NSNull(),
102
+ "media": w.media,
103
+ ] as [String: Any]
104
+ } else {
105
+ out["waiting"] = NSNull()
106
+ }
92
107
  if let reason = reason { out["reason"] = reason.wire }
93
108
  // Optional in the contract: present only while a camera is published.
94
109
  if let cameraFacing = cameraFacing { out["cameraFacing"] = cameraFacing }
@@ -66,6 +66,8 @@ public class PoolseCallsModule: Module {
66
66
  Function("answer") { onCore { $0.answer() } }
67
67
  Function("decline") { onCore { $0.decline() } }
68
68
  Function("hangUp") { onCore { $0.hangUp() } }
69
+ Function("answerWaiting") { (callId: String?) in onCore { $0.answerWaiting(callId: callId) } }
70
+ Function("declineWaiting") { (callId: String?) in onCore { $0.declineWaiting(callId: callId) } }
69
71
  Function("setMuted") { (muted: Bool) in onCore { $0.setMuted(muted) } }
70
72
  Function("setCamera") { (on: Bool) in onCore { $0.setCamera(on) } }
71
73
  Function("switchCamera") { onCore { $0.switchCamera() } }
@@ -139,6 +139,7 @@ final class CallCore {
139
139
  private var extrasMedia: String?
140
140
  private var roster: [SnapshotParticipant] = []
141
141
  private var conversationName: String?
142
+ private var callKind = "call"
142
143
  private var mutedSelf = false
143
144
  private var cameraOn = false
144
145
  private var cameraFacing: String?
@@ -232,6 +233,24 @@ final class CallCore {
232
233
  }
233
234
  }
234
235
 
236
+ /// Call waiting: end this call, answer the waiting one. CallKit follows
237
+ /// from the resulting state (reconcileCallKit ends the old call, then
238
+ /// answers the new one).
239
+ func answerWaiting(callId: String? = nil) {
240
+ onMain { [self] in
241
+ localEnd = true
242
+ runtime.answerWaiting(callId: callId)
243
+ }
244
+ }
245
+
246
+ /// Call waiting: turn the waiting call away (its caller hears busy).
247
+ func declineWaiting(callId: String? = nil) {
248
+ onMain { [self] in runtime.declineWaiting(callId: callId) }
249
+ }
250
+
251
+ /// True while `callId` is the call waiting beside the current one. Main thread.
252
+ func isWaiting(_ callId: String) -> Bool { runtime.currentState().waiting?.callId == callId }
253
+
235
254
  func place(conversationId: String, media: String, sessionToken: String) {
236
255
  onMain { [self] in
237
256
  localEnd = false
@@ -299,6 +318,9 @@ final class CallCore {
299
318
  if let old = extrasCallId, let new = state.callId { callKit.rename(from: old, to: new) }
300
319
  extrasCallId = state.callId
301
320
  } else {
321
+ // A different call (a waiting one promoted): nothing of the last
322
+ // carries over — not even "the user ended it here".
323
+ if extrasCallId != nil { localEnd = false }
302
324
  resetExtras(state.callId, state.media)
303
325
  }
304
326
  }
@@ -307,16 +329,20 @@ final class CallCore {
307
329
  extrasMedia = state.media
308
330
  }
309
331
  if let name = runtime.conversationName { conversationName = name }
332
+ if let kind = runtime.callKind { callKind = kind }
310
333
 
311
334
  let snap = state.toSnapshot(
312
335
  conversationName: conversationName,
336
+ kind: callKind,
313
337
  participants: roster,
314
- audioRoute: audioRoute ?? (state.media == "video" ? "speaker" : "earpiece"),
338
+ // Video calls and rooms default to the speaker (the media port agrees).
339
+ audioRoute: audioRoute ?? (state.media == "video" || callKind == "room" ? "speaker" : "earpiece"),
315
340
  audioOutputs: audioOutputs,
316
341
  audioOutputId: audioOutputId,
317
342
  mutedSelf: mutedSelf,
318
343
  cameraOn: cameraOn,
319
- cameraFacing: cameraFacing
344
+ cameraFacing: cameraFacing,
345
+ waiting: runtime.waitingRing
320
346
  )
321
347
  snapshotLock.lock(); lastSnapshot = snap; snapshotLock.unlock()
322
348
  reconcileCallKit(state)
@@ -329,6 +355,7 @@ final class CallCore {
329
355
  extrasMedia = media
330
356
  roster = []
331
357
  conversationName = nil
358
+ callKind = "call"
332
359
  mutedSelf = false
333
360
  cameraOn = false
334
361
  cameraFacing = nil
@@ -340,25 +367,27 @@ final class CallCore {
340
367
  /// CallKit follows the FINAL state of each dispatch.
341
368
  private func reconcileCallKit(_ state: MachineState) {
342
369
  let terminal = state.phase == .idle || state.phase == .ended
343
- for t in callKit.liveCalls {
344
- let isCurrent = !terminal && t.callId == state.callId
345
- if isCurrent {
346
- switch state.phase {
347
- case .connecting, .active, .reconnecting, .ringingOut:
348
- // Answered from the app's own UI: tell CallKit (audio follows).
349
- if !t.outgoing && !t.answered && state.direction == .incoming { callKit.requestAnswer(callId: t.callId) }
350
- if t.outgoing && state.phase == .active { callKit.reportConnected(callId: t.callId) }
351
- callKit.updateTitle(callId: t.callId, title: conversationName, video: state.media == "video")
352
- default:
353
- break
354
- }
355
- continue
356
- }
370
+ // The call waiting beside this one stays on CallKit's call-waiting UI.
371
+ let live = callKit.liveCalls.filter { terminal || $0.callId != state.waiting?.callId }
372
+ // Ends first: answering the next call (end-and-answer) while CallKit
373
+ // still holds the last one would ask it for two calls at once.
374
+ for t in live where terminal || t.callId != state.callId {
357
375
  // Not the call the machine is in (ended, busy-declined, superseded).
358
376
  // Terminal: whatever CallKit still shows is the call that just ended.
359
377
  let reason = endReason(for: t, state: state, current: terminal || t.callId == state.callId)
360
378
  callKit.end(t, reason: reason)
361
379
  }
380
+ for t in live where !terminal && t.callId == state.callId {
381
+ switch state.phase {
382
+ case .connecting, .active, .reconnecting, .ringingOut:
383
+ // Answered from the app's own UI: tell CallKit (audio follows).
384
+ if !t.outgoing && !t.answered && state.direction == .incoming { callKit.requestAnswer(callId: t.callId) }
385
+ if t.outgoing && state.phase == .active { callKit.reportConnected(callId: t.callId) }
386
+ callKit.updateTitle(callId: t.callId, title: conversationName, video: state.media == "video")
387
+ default:
388
+ break
389
+ }
390
+ }
362
391
  if terminal {
363
392
  localEnd = false
364
393
  if let id = state.callId { ringEndReasons.removeValue(forKey: id) }
@@ -414,6 +443,13 @@ final class CallCore {
414
443
  callKit.reportIncoming(ring: ring)
415
444
  }
416
445
 
446
+ /// CallKit shows a second incoming call over a live one as call
447
+ /// waiting (End & Accept / Decline). Usually already reported by the
448
+ /// push path; idempotent.
449
+ fileprivate func showWaiting(_ ring: RingContext) {
450
+ callKit.reportIncoming(ring: ring)
451
+ }
452
+
417
453
  fileprivate var ringbackPlayer: Ringback { ringback }
418
454
 
419
455
  // MediaEvents → runtime
@@ -446,12 +482,24 @@ final class CallCore {
446
482
  extension CallCore: CallKitActions {
447
483
  func callKitAnswer(callId: String) {
448
484
  let s = runtime.currentState()
485
+ // End & Accept may answer the waiting call before ending the current
486
+ // one: answering it ends the current call itself.
487
+ if s.waiting?.callId == callId {
488
+ localEnd = true
489
+ runtime.answerWaiting(callId: callId)
490
+ return
491
+ }
449
492
  guard s.callId == callId, s.phase == .ringingIn else { return }
450
493
  runtime.answer()
451
494
  }
452
495
 
453
496
  func callKitEnd(callId: String) {
454
497
  let s = runtime.currentState()
498
+ // Decline on CallKit's call-waiting UI.
499
+ if s.waiting?.callId == callId {
500
+ runtime.declineWaiting(callId: callId)
501
+ return
502
+ }
455
503
  guard s.callId == callId || (s.callId?.hasPrefix("pending:") == true) else { return }
456
504
  localEnd = true
457
505
  if s.phase == .ringingIn { runtime.decline() } else { runtime.hangUp() }
@@ -489,6 +537,11 @@ private final class SurfaceAdapter: RingSurfacePort, CallSessionPort {
489
537
 
490
538
  func show(ring: RingContext) { core?.showRing(ring) }
491
539
  func dismiss(callId: String) {}
540
+ func showWaiting(ring: RingContext) { core?.showWaiting(ring) }
541
+ // Dismissal is reconciled from the final state; promotion keeps the
542
+ // CallKit call, which SHOW_RING then finds already reported.
543
+ func dismissWaiting(callId: String) {}
544
+ func promoteWaiting(callId: String) {}
492
545
  func ringtoneStart() {}
493
546
  func ringtoneStop() {}
494
547
  func ringbackStart() { core?.ringbackPlayer.start() }
@@ -124,7 +124,13 @@ final class CallKitController: NSObject, CXProviderDelegate {
124
124
  DispatchQueue.main.async {
125
125
  t.ended = true
126
126
  self?.calls.removeValue(forKey: t.uuid)
127
- CallCore.peek()?.onRingEnded(callId: ring.callId, reason: "cancelled")
127
+ // A refused call-waiting report: the caller would ring on into
128
+ // a void — turn it away as busy instead.
129
+ if let core = CallCore.peek(), core.isWaiting(ring.callId) {
130
+ core.declineWaiting(callId: ring.callId)
131
+ } else {
132
+ CallCore.peek()?.onRingEnded(callId: ring.callId, reason: "cancelled")
133
+ }
128
134
  }
129
135
  }
130
136
  }
@@ -66,6 +66,9 @@ final class LiveKitMediaPort: NSObject, MediaPort {
66
66
  private(set) var room: Room?
67
67
  private var observer: RoomObserver?
68
68
  private var media = "audio"
69
+ /// Speaker unless a headset is in: a video call, or a room (a drop-in
70
+ /// room is held like a meeting, not to the ear).
71
+ private var loud = false
69
72
  private var connectedRoom: ObjectIdentifier?
70
73
 
71
74
  /// Where the user asked the audio to go, for the CURRENT call.
@@ -209,6 +212,7 @@ final class LiveKitMediaPort: NSObject, MediaPort {
209
212
  detach()
210
213
  media = bundle.media
211
214
  let wantsVideo = bundle.media == "video"
215
+ loud = wantsVideo || bundle.kind == "room"
212
216
 
213
217
  // A 1:1/small call: never let the SFU pause a subscribed camera for
214
218
  // "not visible" — a view that mounts late would stay black.
@@ -398,7 +402,7 @@ final class LiveKitMediaPort: NSObject, MediaPort {
398
402
  /// the same; forcing the speaker over AirPods was a bug).
399
403
  private func resolvedChoice(_ s: AVAudioSession = .sharedInstance()) -> OutputChoice {
400
404
  if let wanted = outputWanted { return wanted }
401
- return media == "video" && !externalConnected(s) ? .speaker : .privateRoute
405
+ return loud && !externalConnected(s) ? .speaker : .privateRoute
402
406
  }
403
407
 
404
408
  private func applyRoute() {
@@ -444,7 +448,7 @@ final class LiveKitMediaPort: NSObject, MediaPort {
444
448
 
445
449
  private func currentRoute() -> String {
446
450
  guard let port = AVAudioSession.sharedInstance().currentRoute.outputs.first?.portType else {
447
- return media == "video" ? "speaker" : "earpiece"
451
+ return loud ? "speaker" : "earpiece"
448
452
  }
449
453
  return kind(of: port)
450
454
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@poolse/react-native-calls",
3
- "version": "0.8.0",
3
+ "version": "0.10.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
@@ -21,6 +21,12 @@ export interface Snapshot {
21
21
  conversationId: string;
22
22
  conversationName: string | null;
23
23
  direction: 'incoming' | 'outgoing' | 'joined';
24
+ /**
25
+ * `room` — rings nobody, joined and left at will (`startRoom`); `call`
26
+ * otherwise. Known once the server answered: a room being joined reads
27
+ * `call` while `connecting`. Absent before 0.9.0: treat as `call`.
28
+ */
29
+ kind?: 'call' | 'room';
24
30
  media: 'audio' | 'video';
25
31
  phase: 'ringing_in' | 'ringing_out' | 'connecting' | 'active' | 'reconnecting' | 'ended';
26
32
  reason?:
@@ -55,12 +61,29 @@ export interface Snapshot {
55
61
  mutedSelf: boolean;
56
62
  /** True only while the local camera track is actually published and enabled. */
57
63
  cameraOn: boolean;
64
+ /**
65
+ * A second call ringing while this one is in progress (call waiting).
66
+ * Offer `answerWaiting` ("End & answer" — this call ends) and
67
+ * `declineWaiting` (its caller hears busy). If this call ends first,
68
+ * the waiting one simply becomes the ringing call. null when none;
69
+ * absent before 0.10.0.
70
+ */
71
+ waiting?: WaitingCall | null;
58
72
  /** Facing of the published local camera; absent when the camera is off. */
59
73
  cameraFacing?: 'front' | 'back';
60
74
  }
61
75
 
62
76
  export type AudioOutputKind = 'earpiece' | 'speaker' | 'bluetooth' | 'headset';
63
77
 
78
+ /** The call waiting beside the current one. */
79
+ export interface WaitingCall {
80
+ callId: string;
81
+ conversationId: string | null;
82
+ conversationName: string | null;
83
+ callerName: string | null;
84
+ media: 'audio' | 'video';
85
+ }
86
+
64
87
  /** One place call audio can play. */
65
88
  export interface AudioOutput {
66
89
  /** Opaque; stable while the device stays connected. */
@@ -122,6 +145,8 @@ interface PoolseCallsNative {
122
145
  answer(): void;
123
146
  decline(): void;
124
147
  hangUp(): void;
148
+ answerWaiting?(callId: string | null): void;
149
+ declineWaiting?(callId: string | null): void;
125
150
  setMuted(muted: boolean): void;
126
151
  setCamera(on: boolean): void;
127
152
  switchCamera(): void;
@@ -233,6 +258,16 @@ export async function startRoom(
233
258
  export const answer = (): void => native?.answer();
234
259
  export const decline = (): void => native?.decline();
235
260
  export const hangUp = (): void => native?.hangUp();
261
+
262
+ /**
263
+ * Call waiting: end the current call and answer `snapshot.waiting`.
264
+ * Pass its `callId`: a tap on a card for a call that is no longer the
265
+ * one waiting is then ignored instead of answering a different call.
266
+ */
267
+ export const answerWaiting = (callId?: string): void => native?.answerWaiting?.(callId ?? null);
268
+
269
+ /** Call waiting: turn `snapshot.waiting` away — its caller hears busy. */
270
+ export const declineWaiting = (callId?: string): void => native?.declineWaiting?.(callId ?? null);
236
271
  export const setMuted = (muted: boolean): void => native?.setMuted(muted);
237
272
  export const setCamera = (on: boolean): void => native?.setCamera(on);
238
273
  export const switchCamera = (): void => native?.switchCamera();