@poolse/react-native-calls 0.4.1 → 0.4.3

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.
@@ -168,6 +168,9 @@ final class CallCore {
168
168
  eventsAdapter.core = self
169
169
  surfaceAdapter.core = self
170
170
  callKit.actions = self
171
+ // LiveKit's audio stays off until CallKit activates the session —
172
+ // configured now, at launch, before any activation can happen.
173
+ mediaPort.prepareAudio()
171
174
  }
172
175
 
173
176
  // MARK: - native entry points
@@ -55,19 +55,32 @@ final class LiveKitMediaPort: NSObject, MediaPort {
55
55
  private let surfaces = NSHashTable<LiveKitVideoSurface>.weakObjects()
56
56
  private var audioPrepared = false
57
57
  private var audioActive = false
58
+ /// The room whose audio engine has been kicked (once per room).
59
+ private var audioKicked: ObjectIdentifier?
58
60
 
59
61
  init(events: MediaEvents) {
60
62
  self.events = events
61
63
  super.init()
62
64
  NotificationCenter.default.addObserver(self, selector: #selector(routeChanged),
63
65
  name: AVAudioSession.routeChangeNotification, object: nil)
66
+ NotificationCenter.default.addObserver(self, selector: #selector(interrupted),
67
+ name: AVAudioSession.interruptionNotification, object: nil)
68
+ NotificationCenter.default.addObserver(self, selector: #selector(mediaServicesReset),
69
+ name: AVAudioSession.mediaServicesWereResetNotification,
70
+ object: nil)
64
71
  }
65
72
 
66
73
  // MARK: - audio session (CallKit-driven)
67
74
 
68
75
  /// Before any room exists: LiveKit must not configure the session nor
69
76
  /// start its engine outside CallKit's activation window.
70
- private func prepareAudio() {
77
+ ///
78
+ /// Called at app LAUNCH (CallCore.init, from the app-delegate
79
+ /// subscriber), as LiveKit's CallKit integration requires — before any
80
+ /// audio session can be activated. Doing it inside didActivate meant a
81
+ /// call answered with the app closed created and reconfigured LiveKit's
82
+ /// audio manager mid-activation, and came up with no audio.
83
+ func prepareAudio() {
71
84
  guard !audioPrepared else { return }
72
85
  audioPrepared = true
73
86
  AudioManager.shared.audioSession.isAutomaticConfigurationEnabled = false
@@ -80,17 +93,72 @@ final class LiveKitMediaPort: NSObject, MediaPort {
80
93
  func audioActivated(_ session: AVAudioSession, media: String) {
81
94
  prepareAudio()
82
95
  audioActive = true
83
- let video = media == "video"
96
+ if media != self.media && room == nil { self.media = media }
97
+ reassertAudio(session)
98
+ kickAudioIfReady()
99
+ }
100
+
101
+ /// Once per call, the first moment the session is active AND the mic is
102
+ /// published (in whichever order they happen): make LiveKit's audio
103
+ /// engine rebuild around the live mic.
104
+ ///
105
+ /// On a call answered with the app closed, the process is brand new and
106
+ /// LiveKit's engine never started for a mic published right after
107
+ /// CallKit made audio available — no capture, no playback, though the
108
+ /// session was correct (video, which needs no audio engine, worked). A
109
+ /// user-side mute → unmute cured it, because toggling the mic makes the
110
+ /// engine re-evaluate and start. This does the same, automatically: an
111
+ /// availability cycle first, then a mic off/on. A deliberate mute is
112
+ /// left alone. The remote hears at most a sub-second blip at connect.
113
+ private func kickAudioIfReady() {
114
+ guard audioActive, let r = room, connectedRoom == ObjectIdentifier(r),
115
+ audioKicked != ObjectIdentifier(r), !mutedWanted else { return }
116
+ guard r.localParticipant.isMicrophoneEnabled() else { return }
117
+ audioKicked = ObjectIdentifier(r)
84
118
  do {
85
- try session.setCategory(.playAndRecord, mode: video && speakerWanted != false ? .videoChat : .voiceChat,
86
- options: [.allowBluetooth, .allowBluetoothA2DP])
119
+ try AudioManager.shared.setEngineAvailability(.none)
120
+ try AudioManager.shared.setEngineAvailability(.default)
87
121
  } catch {
88
- NSLog("[PoolseCalls] audio session category failed: %@", "\(error)")
122
+ NSLog("[PoolseCalls] engine availability cycle failed: %@", "\(error)")
123
+ }
124
+ Task { [weak self] in
125
+ try? await Task.sleep(nanoseconds: 250_000_000)
126
+ let still = await MainActor.run { () -> Bool in
127
+ guard let self = self else { return false }
128
+ return self.room === r && self.audioActive && !self.mutedWanted
129
+ }
130
+ guard still else { return }
131
+ do {
132
+ try await r.localParticipant.setMicrophone(enabled: false)
133
+ try await r.localParticipant.setMicrophone(enabled: true)
134
+ } catch {
135
+ NSLog("[PoolseCalls] mic cycle failed: %@", "\(error)")
136
+ }
137
+ await MainActor.run { self?.reassertAudio() }
138
+ }
139
+ }
140
+
141
+ /// Put the call's audio back the way the call needs it: play-and-record
142
+ /// in the voice/video chat mode, LiveKit's engine available, the chosen
143
+ /// route. Idempotent — only touches what differs — so it is safe to run
144
+ /// whenever anything may have changed the session behind the call's
145
+ /// back (another audio library reconfiguring it as JS boots mid-call,
146
+ /// an interruption, a media-services reset). Only while CallKit (or the
147
+ /// no-CallKit fallback) holds the session active.
148
+ func reassertAudio(_ session: AVAudioSession = .sharedInstance()) {
149
+ guard audioActive else { return }
150
+ let mode: AVAudioSession.Mode = media == "video" && speakerWanted != false ? .videoChat : .voiceChat
151
+ if session.category != .playAndRecord || session.mode != mode {
152
+ do {
153
+ try session.setCategory(.playAndRecord, mode: mode,
154
+ options: [.allowBluetooth, .allowBluetoothA2DP])
155
+ } catch {
156
+ NSLog("[PoolseCalls] audio session category failed: %@", "\(error)")
157
+ }
89
158
  }
90
159
  do { try AudioManager.shared.setEngineAvailability(.default) } catch {
91
160
  NSLog("[PoolseCalls] setEngineAvailability(.default) failed: %@", "\(error)")
92
161
  }
93
- if media != self.media && room == nil { self.media = media }
94
162
  applyRoute()
95
163
  }
96
164
 
@@ -159,6 +227,13 @@ final class LiveKitMediaPort: NSObject, MediaPort {
159
227
  do { try await r.localParticipant.setMicrophone(enabled: !muted) } catch {
160
228
  NSLog("[PoolseCalls] microphone publish failed: %@", "\(error)")
161
229
  }
230
+ // Connected and publishing: make sure nothing changed the session
231
+ // while we connected (a cold answer boots JS in exactly this window),
232
+ // then make sure the engine is actually carrying the mic.
233
+ await MainActor.run {
234
+ self.reassertAudio()
235
+ self.kickAudioIfReady()
236
+ }
162
237
  let camera = await MainActor.run { self.cameraWanted ?? wantsVideo }
163
238
  if camera { await self.enableCamera(r, true) }
164
239
  await MainActor.run {
@@ -297,9 +372,28 @@ final class LiveKitMediaPort: NSObject, MediaPort {
297
372
  events?.onAudioRoute(currentRoute())
298
373
  }
299
374
 
375
+ /// An interruption (another app's audio, Siri) ended: the session may
376
+ /// have been reconfigured; restore the call's.
377
+ @objc private func interrupted(_ note: Notification) {
378
+ guard let raw = note.userInfo?[AVAudioSessionInterruptionTypeKey] as? UInt,
379
+ AVAudioSession.InterruptionType(rawValue: raw) == .ended else { return }
380
+ DispatchQueue.main.async { [weak self] in self?.reassertAudio() }
381
+ }
382
+
383
+ @objc private func mediaServicesReset(_ note: Notification) {
384
+ DispatchQueue.main.async { [weak self] in self?.reassertAudio() }
385
+ }
386
+
300
387
  @objc private func routeChanged(_ note: Notification) {
388
+ let reason = (note.userInfo?[AVAudioSessionRouteChangeReasonKey] as? UInt)
389
+ .flatMap(AVAudioSession.RouteChangeReason.init(rawValue:))
301
390
  DispatchQueue.main.async { [weak self] in
302
391
  guard let self = self, self.audioActive else { return }
392
+ // Something reconfigured the shared session mid-call: put it back.
393
+ if reason == .categoryChange {
394
+ self.reassertAudio()
395
+ return
396
+ }
303
397
  // A user's speaker choice survives route changes, except that a
304
398
  // newly connected headset/Bluetooth device always wins.
305
399
  let route = self.currentRoute()
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@poolse/react-native-calls",
3
- "version": "0.4.1",
3
+ "version": "0.4.3",
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",