rns-recplay 2.0.3 → 3.0.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.
@@ -9,18 +9,26 @@ class RecPlayModule: RCTEventEmitter {
9
9
  private var playerItem: AVPlayerItem?
10
10
  private var timeObserverToken: Any?
11
11
  private var recordingTimer: Timer?
12
- private var secondsElapsed = 0
12
+
13
13
  private var isPaused = false
14
14
  private var isLooping = false
15
15
 
16
+ private var secondsElapsed: Double = 0
17
+ private var recordingStartTime: Date?
18
+ private var timeAccumulatedBeforePause: Double = 0
19
+
16
20
  override static func requiresMainQueueSetup() -> Bool {
17
21
  return true
18
22
  }
19
23
 
20
24
  override func supportedEvents() -> [String]! {
21
25
  return [
22
- "onTimerUpdate", "onPlaybackStatus", "onPlaybackProgress", "onPlaybackFinished",
26
+ "onTimerUpdate",
27
+ "onPlaybackStatus",
28
+ "onPlaybackProgress",
29
+ "onPlaybackFinished",
23
30
  "onAudioInterruption",
31
+ "onVolumeUpdate",
24
32
  ]
25
33
  }
26
34
 
@@ -45,64 +53,104 @@ class RecPlayModule: RCTEventEmitter {
45
53
  }
46
54
 
47
55
  // MARK: - Recording Logic
48
- @objc(startRecording:shouldStopPlayback:duck:mixWithOthers:resolver:rejecter:)
49
- func startRecording(
50
- fileName: String?,
51
- shouldStopPlayback: Bool,
52
- duck: Bool,
53
- mixWithOthers: Bool,
54
- resolve: @escaping RCTPromiseResolveBlock,
55
- reject: @escaping RCTPromiseRejectBlock
56
- ) {
57
- if shouldStopPlayback {
58
- stopPlaybackInternal()
59
- }
60
- let session = AVAudioSession.sharedInstance()
61
- do {
62
- var options: AVAudioSession.CategoryOptions = [.defaultToSpeaker]
63
- if duck {
64
- options.insert(.duckOthers)
65
- }
66
- if mixWithOthers {
67
- options.insert(.mixWithOthers)
68
- }
69
-
70
- // Fix: Don't deactivate the session before changing category if WebRTC is active
71
- try session.setCategory(.playAndRecord, mode: .default, options: options)
72
- try session.setActive(true)
73
-
74
- let name = fileName ?? "rec_\(Int(Date().timeIntervalSince1970))"
75
- let fileURL = FileManager.default.temporaryDirectory
76
- .appendingPathComponent("\(name).m4a")
77
- let settings: [String: Any] = [
78
- AVFormatIDKey: Int(kAudioFormatMPEG4AAC),
79
- AVSampleRateKey: 44100,
80
- AVNumberOfChannelsKey: 1,
81
- AVEncoderAudioQualityKey: AVAudioQuality.high.rawValue,
82
- AVEncoderBitRateKey: 128000,
83
- ]
84
- audioRecorder = try AVAudioRecorder(url: fileURL, settings: settings)
85
- audioRecorder?.prepareToRecord()
86
- audioRecorder?.record()
87
- secondsElapsed = 0
88
- isPaused = false
89
- DispatchQueue.main.async {
90
- self.recordingTimer?.invalidate()
91
- self.recordingTimer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) {
92
- _ in
93
- if !self.isPaused {
94
- self.sendEvent(
95
- withName: "onTimerUpdate",
96
- body: ["seconds": self.secondsElapsed]
97
- )
98
- self.secondsElapsed += 1
99
- }
56
+
57
+ @objc(startRecording:shouldStopPlayback:duck:mixWithOthers:useBT:resolver:rejecter:)
58
+ func startRecording(
59
+ fileName: String?,
60
+ shouldStopPlayback: Bool,
61
+ duck: Bool,
62
+ mixWithOthers: Bool,
63
+ useBT: Bool,
64
+ resolve: @escaping RCTPromiseResolveBlock,
65
+ reject: @escaping RCTPromiseRejectBlock
66
+ ) {
67
+ if shouldStopPlayback { stopPlaybackInternal() }
68
+
69
+ let session = AVAudioSession.sharedInstance()
70
+
71
+ do {
72
+ /* ✅ THE FIX FOR "LIGHTNING-BLUETOOTH" HEADPHONES:
73
+ We ONLY allow .allowBluetoothA2DP.
74
+ We EXPLICITLY do NOT include .allowBluetoothHFP.
75
+
76
+ This keeps those "Fake Lightning" headphones in high-quality mode.
77
+ Because HFP is disabled, iOS is FORCED to use the phone's built-in mic.
78
+ */
79
+ var options: AVAudioSession.CategoryOptions = [.defaultToSpeaker, .allowBluetoothA2DP]
80
+
81
+ if duck { options.insert(.duckOthers) }
82
+ if mixWithOthers { options.insert(.mixWithOthers) }
83
+
84
+ // Use .measurement to keep the signal raw and avoid the "ducking" effect
85
+ try session.setCategory(.playAndRecord, mode: .videoRecording, options: options)
86
+ try session.setActive(true)
87
+
88
+ // Force the Built-in Mic again just to be safe
89
+ if let builtInMic = session.availableInputs?.first(where: { $0.portType == .builtInMic }) {
90
+ try session.setPreferredInput(builtInMic)
100
91
  }
92
+
93
+ let rawName = fileName ?? "rec_\(Int(Date().timeIntervalSince1970))"
94
+ let baseName = (rawName as NSString).deletingPathExtension
95
+ let fileURL = FileManager.default.temporaryDirectory.appendingPathComponent(baseName).appendingPathExtension("m4a")
96
+
97
+ let settings: [String: Any] = [
98
+ AVFormatIDKey: Int(kAudioFormatMPEG4AAC),
99
+ AVSampleRateKey: 44100,
100
+ AVNumberOfChannelsKey: 1,
101
+ AVEncoderAudioQualityKey: AVAudioQuality.high.rawValue,
102
+ AVEncoderBitRateKey: 128000,
103
+ ]
104
+
105
+ audioRecorder = try AVAudioRecorder(url: fileURL, settings: settings)
106
+ audioRecorder?.isMeteringEnabled = true
107
+ audioRecorder?.prepareToRecord()
108
+ audioRecorder?.record()
109
+
110
+ self.secondsElapsed = 0
111
+ self.timeAccumulatedBeforePause = 0
112
+ self.recordingStartTime = Date()
113
+ self.isPaused = false
114
+
115
+ DispatchQueue.main.async { self.startTimer() }
116
+ resolve(baseName)
117
+
118
+ } catch {
119
+ reject("REC_ERROR", "Failed to start recording", error)
101
120
  }
121
+ }
102
122
 
103
- resolve(name)
104
- } catch {
105
- reject("REC_ERROR", "Failed to start recording", error)
123
+ private func startTimer() {
124
+ recordingTimer?.invalidate()
125
+
126
+ recordingTimer = Timer.scheduledTimer(withTimeInterval: 0.1, repeats: true) {
127
+ [weak self] _ in
128
+ guard let self = self, let recorder = self.audioRecorder, !self.isPaused else { return }
129
+
130
+ recorder.updateMeters()
131
+ let db = recorder.averagePower(forChannel: 0)
132
+ let normalizedVolume = max(0, (db + 60) / 60)
133
+
134
+ self.sendEvent(
135
+ withName: "onVolumeUpdate",
136
+ body: [
137
+ "value": db,
138
+ "normalized": normalizedVolume,
139
+ ])
140
+
141
+ if let startTime = self.recordingStartTime {
142
+ let sessionDuration = Date().timeIntervalSince(startTime)
143
+ let totalCurrentElapsed = self.timeAccumulatedBeforePause + sessionDuration
144
+
145
+ let oldSecond = Int(floor(self.secondsElapsed))
146
+ let newSecond = Int(floor(totalCurrentElapsed))
147
+
148
+ if newSecond > oldSecond {
149
+ self.sendEvent(withName: "onTimerUpdate", body: ["seconds": newSecond])
150
+ }
151
+
152
+ self.secondsElapsed = totalCurrentElapsed
153
+ }
106
154
  }
107
155
  }
108
156
 
@@ -111,16 +159,20 @@ class RecPlayModule: RCTEventEmitter {
111
159
  resolve: @escaping RCTPromiseResolveBlock,
112
160
  reject: @escaping RCTPromiseRejectBlock
113
161
  ) {
162
+ let finalDuration = secondsElapsed
163
+
114
164
  recordingTimer?.invalidate()
115
165
  recordingTimer = nil
166
+
116
167
  audioRecorder?.stop()
117
168
  let url = audioRecorder?.url
118
169
  audioRecorder = nil
119
-
120
- // Fix: Removed setActive(false) to prevent killing WebRTC streams
121
-
170
+
122
171
  if let fileUrl = url {
123
- resolve(fileUrl.absoluteString)
172
+ resolve([
173
+ "uri": fileUrl.absoluteString,
174
+ "duration": finalDuration,
175
+ ])
124
176
  } else {
125
177
  reject("STOP_ERROR", "No recording found", nil)
126
178
  }
@@ -128,111 +180,129 @@ class RecPlayModule: RCTEventEmitter {
128
180
 
129
181
  @objc(pauseRecording:rejecter:)
130
182
  func pauseRecording(resolve: RCTPromiseResolveBlock, reject: RCTPromiseRejectBlock) {
131
- audioRecorder?.pause()
132
- isPaused = true
183
+ if !isPaused {
184
+ audioRecorder?.pause()
185
+ // Save the work done so far
186
+ if let startTime = recordingStartTime {
187
+ timeAccumulatedBeforePause += Date().timeIntervalSince(startTime)
188
+ }
189
+ recordingStartTime = nil
190
+ isPaused = true
191
+ }
133
192
  resolve(true)
134
193
  }
135
194
 
136
195
  @objc(resumeRecording:rejecter:)
137
196
  func resumeRecording(resolve: RCTPromiseResolveBlock, reject: RCTPromiseRejectBlock) {
138
- audioRecorder?.record()
139
- isPaused = false
197
+ if isPaused {
198
+ // Set a new reference start time for this segment
199
+ recordingStartTime = Date()
200
+ audioRecorder?.record()
201
+ isPaused = false
202
+ }
140
203
  resolve(true)
141
204
  }
142
205
 
143
206
  // MARK: - Permissions
144
207
  @objc(checkPermission:rejecter:)
145
208
  func checkPermission(
146
- resolve: @escaping RCTPromiseResolveBlock,
147
- reject: @escaping RCTPromiseRejectBlock
209
+ resolve: @escaping RCTPromiseResolveBlock, reject: @escaping RCTPromiseRejectBlock
148
210
  ) {
149
211
  let status = AVAudioSession.sharedInstance().recordPermission
150
212
  switch status {
151
- case .granted:
152
- resolve("granted")
153
- case .denied:
154
- resolve("blocked")
155
- case .undetermined:
156
- resolve("denied")
157
- @unknown default:
158
- resolve("unavailable")
213
+ case .granted: resolve("granted")
214
+ case .denied: resolve("blocked")
215
+ case .undetermined: resolve("denied")
216
+ @unknown default: resolve("unavailable")
159
217
  }
160
218
  }
161
219
 
162
220
  @objc(requestPermission:rejecter:)
163
221
  func requestPermission(
164
- resolve: @escaping RCTPromiseResolveBlock,
165
- reject: @escaping RCTPromiseRejectBlock
222
+ resolve: @escaping RCTPromiseResolveBlock, reject: @escaping RCTPromiseRejectBlock
166
223
  ) {
167
224
  AVAudioSession.sharedInstance().requestRecordPermission { granted in
168
225
  resolve(granted ? "granted" : "blocked")
169
226
  }
170
227
  }
171
228
 
172
- // MARK: - Playback Logic (UPDATED for WebRTC Compatibility)
173
- @objc(playAudio:shouldStopPrevious:loop:mixWithOthers:duckOthers:)
229
+ // MARK: - Playback Logic
230
+ @objc(playAudio:shouldStopPrevious:loop:mixWithOthers:duckOthers:)
174
231
  func playAudio(
175
- uri: String,
176
- shouldStopPrevious: Bool,
177
- loop: Bool,
178
- mixWithOthers: Bool,
179
- duckOthers: Bool
232
+ uri: String, shouldStopPrevious: Bool, loop: Bool, mixWithOthers: Bool, duckOthers: Bool
180
233
  ) {
181
234
  DispatchQueue.main.async { [weak self] in
182
235
  guard let self = self else { return }
183
- if shouldStopPrevious {
184
- self.stopPlaybackInternal()
185
- }
236
+ if shouldStopPrevious { self.stopPlaybackInternal() }
237
+
186
238
  let session = AVAudioSession.sharedInstance()
187
239
  do {
188
- // Fix: Removed setActive(false)
189
- var options: AVAudioSession.CategoryOptions = []
240
+ var options: AVAudioSession.CategoryOptions = [
241
+ .allowBluetoothHFP, .allowBluetoothA2DP, .defaultToSpeaker,
242
+ ]
190
243
  if mixWithOthers {
191
244
  options.insert(.mixWithOthers)
192
245
  } else if duckOthers {
193
246
  options.insert(.duckOthers)
194
247
  }
195
-
196
- // Fix: If WebRTC is currently recording/calling, don't force .playback category
197
- // This keeps the microphone open.
248
+
198
249
  if session.category != .playAndRecord {
199
- try session.setCategory(.playback, mode: .default, options: options)
250
+ try session.setCategory(.playback, mode: .moviePlayback, options: options)
251
+ } else {
252
+ try session.setCategory(.playAndRecord, mode: .videoRecording, options: options)
200
253
  }
201
-
202
254
  try session.setActive(true)
255
+
256
+ if session.category == .playAndRecord {
257
+ if let builtInMic = session.availableInputs?.first(where: {
258
+ $0.portType == .builtInMic
259
+ }) {
260
+ try? session.setPreferredInput(builtInMic)
261
+ }
262
+ }
263
+
203
264
  } catch {
204
265
  print("⚠️ Audio Session Error: \(error.localizedDescription)")
205
266
  }
206
- let url: URL
207
- if uri.hasPrefix("http") || uri.hasPrefix("file://") {
208
- guard let u = URL(string: uri) else {
209
- self.sendEvent(withName: "onPlaybackStatus", body: ["status": "ERROR"])
210
- return
211
- }
212
- url = u
267
+
268
+ // --- LITERAL PATH LOGIC (Matching the Mixer) ---
269
+ var url: URL?
270
+ if uri.hasPrefix("http") {
271
+ url = URL(string: uri)
213
272
  } else {
214
- url = URL(fileURLWithPath: uri)
273
+ // Remove the scheme to get the literal path string
274
+ let literalPath = uri.replacingOccurrences(of: "file://", with: "")
275
+
276
+ // Use fileURLWithPath to handle literal spaces and % symbols on disk
277
+ url = URL(fileURLWithPath: literalPath)
215
278
  }
216
- let asset = AVURLAsset(url: url)
279
+
280
+ guard let validUrl = url else {
281
+ print("❌ Playback Failed: Could not resolve URL for \(uri)")
282
+ self.sendEvent(withName: "onPlaybackStatus", body: ["status": "ERROR"])
283
+ return
284
+ }
285
+
286
+ let asset = AVURLAsset(url: validUrl)
217
287
  self.playerItem = AVPlayerItem(asset: asset)
218
288
  self.isLooping = loop
219
-
289
+
220
290
  self.playerItem?.addObserver(self, forKeyPath: "status", options: [.new], context: nil)
221
291
  self.playerItem?.addObserver(
222
292
  self, forKeyPath: "playbackBufferEmpty", options: [.new], context: nil)
223
293
  self.playerItem?.addObserver(
224
294
  self, forKeyPath: "playbackLikelyToKeepUp", options: [.new], context: nil)
295
+
225
296
  NotificationCenter.default.addObserver(
226
- self,
227
- selector: #selector(self.playerDidFinishPlaying),
228
- name: .AVPlayerItemDidPlayToEndTime,
229
- object: self.playerItem
230
- )
297
+ self, selector: #selector(self.playerDidFinishPlaying),
298
+ name: .AVPlayerItemDidPlayToEndTime, object: self.playerItem)
299
+
231
300
  if self.audioPlayer == nil {
232
301
  self.audioPlayer = AVPlayer(playerItem: self.playerItem)
233
302
  } else {
234
303
  self.audioPlayer?.replaceCurrentItem(with: self.playerItem)
235
304
  }
305
+
236
306
  self.setupProgressObserver()
237
307
  self.audioPlayer?.play()
238
308
  self.sendEvent(withName: "onPlaybackStatus", body: ["status": "PLAYING"])
@@ -240,38 +310,27 @@ class RecPlayModule: RCTEventEmitter {
240
310
  }
241
311
 
242
312
  override func observeValue(
243
- forKeyPath keyPath: String?,
244
- of object: Any?,
245
- change: [NSKeyValueChangeKey: Any]?,
313
+ forKeyPath keyPath: String?, of object: Any?, change: [NSKeyValueChangeKey: Any]?,
246
314
  context: UnsafeMutableRawPointer?
247
315
  ) {
248
316
  guard let item = object as? AVPlayerItem else { return }
249
317
  switch keyPath {
250
318
  case "status":
251
- switch item.status {
252
- case .readyToPlay:
319
+ if item.status == .readyToPlay {
253
320
  let isPlaying = self.audioPlayer?.timeControlStatus == .playing
254
321
  self.sendEvent(
255
322
  withName: "onPlaybackStatus", body: ["status": isPlaying ? "PLAYING" : "PAUSED"]
256
323
  )
257
- case .failed:
324
+ } else if item.status == .failed {
258
325
  self.sendEvent(withName: "onPlaybackStatus", body: ["status": "ERROR"])
259
- default:
260
- self.sendEvent(withName: "onPlaybackStatus", body: ["status": "IDLE"])
261
- }
262
- case "playbackBufferEmpty":
263
- if item.isPlaybackBufferEmpty {
264
- self.sendEvent(withName: "onPlaybackStatus", body: ["status": "BUFFERING"])
265
- }
266
- case "playbackLikelyToKeepUp":
267
- if item.isPlaybackLikelyToKeepUp {
268
- let isPlaying = self.audioPlayer?.timeControlStatus == .playing
269
- self.sendEvent(
270
- withName: "onPlaybackStatus", body: ["status": isPlaying ? "PLAYING" : "PAUSED"]
271
- )
272
326
  }
273
- default:
274
- break
327
+ case "playbackBufferEmpty" where item.isPlaybackBufferEmpty:
328
+ self.sendEvent(withName: "onPlaybackStatus", body: ["status": "BUFFERING"])
329
+ case "playbackLikelyToKeepUp" where item.isPlaybackLikelyToKeepUp:
330
+ let isPlaying = self.audioPlayer?.timeControlStatus == .playing
331
+ self.sendEvent(
332
+ withName: "onPlaybackStatus", body: ["status": isPlaying ? "PLAYING" : "PAUSED"])
333
+ default: break
275
334
  }
276
335
  }
277
336
 
@@ -279,43 +338,41 @@ class RecPlayModule: RCTEventEmitter {
279
338
  if isLooping {
280
339
  audioPlayer?.seek(to: .zero)
281
340
  audioPlayer?.play()
282
- self.sendEvent(withName: "onPlaybackStatus", body: ["status": "PLAYING"])
283
341
  } else {
284
342
  sendEvent(withName: "onPlaybackFinished", body: ["finished": true])
285
343
  sendEvent(withName: "onPlaybackStatus", body: ["status": "ENDED"])
286
- // Fix: Removed setActive(false) to keep WebRTC alive
287
344
  }
288
345
  }
289
346
 
290
347
  private func setupProgressObserver() {
291
- if let token = timeObserverToken {
292
- audioPlayer?.removeTimeObserver(token)
293
- }
294
- let interval = CMTime(seconds: 0.5, preferredTimescale: 1000)
348
+ if let token = timeObserverToken { audioPlayer?.removeTimeObserver(token) }
349
+
350
+ // Update every 50ms for smooth UI and accurate end-handle stopping
351
+ let interval = CMTime(seconds: 0.05, preferredTimescale: 1000)
352
+
295
353
  timeObserverToken = audioPlayer?.addPeriodicTimeObserver(
296
- forInterval: interval,
297
- queue: .main
354
+ forInterval: interval, queue: .main
298
355
  ) { [weak self] time in
299
- guard
300
- let self = self,
301
- let duration = self.audioPlayer?.currentItem?.duration.seconds,
302
- duration > 0,
303
- !duration.isNaN
356
+ guard let self = self,
357
+ let currentItem = self.audioPlayer?.currentItem,
358
+ currentItem.status == .readyToPlay
304
359
  else { return }
360
+
361
+ let duration = currentItem.duration.seconds
362
+ guard !duration.isNaN else { return }
363
+
305
364
  self.sendEvent(
306
365
  withName: "onPlaybackProgress",
307
366
  body: [
308
367
  "currentPosition": time.seconds,
309
368
  "duration": duration,
310
- ]
311
- )
369
+ ])
312
370
  }
313
371
  }
314
372
 
315
373
  @objc(stopPlayback:rejecter:)
316
374
  func stopPlayback(resolve: RCTPromiseResolveBlock, reject: RCTPromiseRejectBlock) {
317
375
  stopPlaybackInternal()
318
- // Fix: Removed setActive(false)
319
376
  resolve(true)
320
377
  }
321
378
 
@@ -331,20 +388,28 @@ class RecPlayModule: RCTEventEmitter {
331
388
  item.removeObserver(self, forKeyPath: "playbackBufferEmpty")
332
389
  item.removeObserver(self, forKeyPath: "playbackLikelyToKeepUp")
333
390
  NotificationCenter.default.removeObserver(
334
- self,
335
- name: .AVPlayerItemDidPlayToEndTime,
336
- object: item
337
- )
391
+ self, name: .AVPlayerItemDidPlayToEndTime, object: item)
338
392
  }
339
393
  playerItem = nil
340
394
  }
341
395
 
342
396
  @objc(seekTo:)
343
- func seekTo(seconds: Double) {
344
- let time = CMTime(seconds: seconds, preferredTimescale: 1000)
345
- audioPlayer?.seek(to: time)
346
- }
397
+ func seekTo(seconds: Double) {
398
+ guard let player = audioPlayer else { return }
399
+
400
+ // Use a high timescale for precision (60,000 is standard for high-res audio)
401
+ let time = CMTime(seconds: seconds, preferredTimescale: 60000)
347
402
 
403
+ // ZERO tolerance ensures it lands exactly on the requested sample
404
+ player.seek(to: time, toleranceBefore: .zero, toleranceAfter: .zero) { finished in
405
+ if finished {
406
+ // 'self' was removed from the capture list because it wasn't being used here.
407
+ // If you decide to call a function like self.sendEvent later,
408
+ // you should add [weak self] back.
409
+ print("🎯 Precision Seek Finished to: \(seconds)")
410
+ }
411
+ }
412
+ }
348
413
  @objc(togglePlayback)
349
414
  func togglePlayback() {
350
415
  if audioPlayer?.timeControlStatus == .playing {
@@ -358,13 +423,11 @@ class RecPlayModule: RCTEventEmitter {
358
423
  guard let info = notification.userInfo,
359
424
  let typeValue = info[AVAudioSessionInterruptionTypeKey] as? UInt,
360
425
  let type = AVAudioSession.InterruptionType(rawValue: typeValue)
361
- else {
362
- return
363
- }
364
- switch type {
365
- case .began:
426
+ else { return }
427
+
428
+ if type == .began {
366
429
  sendEvent(withName: "onAudioInterruption", body: ["type": "began"])
367
- case .ended:
430
+ } else if type == .ended {
368
431
  let optionsValue = info[AVAudioSessionInterruptionOptionKey] as? UInt
369
432
  let shouldResume =
370
433
  (optionsValue ?? 0) & AVAudioSession.InterruptionOptions.shouldResume.rawValue != 0
@@ -372,30 +435,37 @@ class RecPlayModule: RCTEventEmitter {
372
435
  withName: "onAudioInterruption",
373
436
  body: ["type": "ended", "shouldResume": shouldResume])
374
437
  if shouldResume {
375
- do {
376
- try AVAudioSession.sharedInstance().setActive(true)
377
- audioPlayer?.play()
378
- } catch {
379
- print("⚠️ Failed to reactivate: \(error.localizedDescription)")
380
- }
438
+ try? AVAudioSession.sharedInstance().setActive(true)
439
+ audioPlayer?.play()
381
440
  }
382
- @unknown default:
383
- break
384
441
  }
385
442
  }
386
443
 
387
444
  @objc private func handleRouteChange(_ notification: Notification) {
445
+
388
446
  guard let userInfo = notification.userInfo,
447
+
389
448
  let reasonValue = userInfo[AVAudioSessionRouteChangeReasonKey] as? UInt,
449
+
390
450
  let reason = AVAudioSession.RouteChangeReason(rawValue: reasonValue)
451
+
391
452
  else { return }
453
+
392
454
  switch reason {
455
+
393
456
  case .oldDeviceUnavailable:
457
+
394
458
  print("🔊 Route changed: old device unavailable")
459
+
395
460
  case .newDeviceAvailable:
461
+
396
462
  print("🔊 Route changed: new device available")
463
+
397
464
  default:
465
+
398
466
  break
467
+
399
468
  }
469
+
400
470
  }
401
- }
471
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "rns-recplay",
3
- "version": "2.0.3",
3
+ "version": "3.0.0",
4
4
  "description": "High-performance React Native module for audio recording and audio playback on Android and iOS.",
5
5
  "main": "index.js",
6
6
  "types": "index.d.ts",
@@ -33,8 +33,7 @@ const withAndroidPermissions = (config) => {
33
33
  // 3. FOREGROUND_SERVICE_MICROPHONE: Required for Android 14+ if recording continues in background.
34
34
  const permissionsToAdd = [
35
35
  'android.permission.RECORD_AUDIO',
36
- 'android.permission.MODIFY_AUDIO_SETTINGS',
37
- 'android.permission.FOREGROUND_SERVICE_MICROPHONE'
36
+ 'android.permission.MODIFY_AUDIO_SETTINGS'
38
37
  ];
39
38
 
40
39
  if (!manifest['uses-permission']) {