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.
- package/README.md +197 -125
- package/android/src/main/java/com/rnsrecplay/RecPlayModule.kt +228 -64
- package/index.d.ts +15 -26
- package/index.js +26 -6
- package/ios/RecPlayModule.m +1 -0
- package/ios/RecPlayModule.swift +239 -169
- package/package.json +1 -1
- package/withNativeRecPlay.js +1 -2
package/ios/RecPlayModule.swift
CHANGED
|
@@ -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
|
-
|
|
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",
|
|
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
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
.
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
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
|
-
|
|
104
|
-
|
|
105
|
-
|
|
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(
|
|
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
|
-
|
|
132
|
-
|
|
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
|
-
|
|
139
|
-
|
|
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
|
-
|
|
153
|
-
case .denied
|
|
154
|
-
|
|
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
|
|
173
|
-
|
|
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
|
-
|
|
185
|
-
}
|
|
236
|
+
if shouldStopPrevious { self.stopPlaybackInternal() }
|
|
237
|
+
|
|
186
238
|
let session = AVAudioSession.sharedInstance()
|
|
187
239
|
do {
|
|
188
|
-
|
|
189
|
-
|
|
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: .
|
|
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
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
228
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
274
|
-
|
|
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
|
-
|
|
293
|
-
|
|
294
|
-
let interval = CMTime(seconds: 0.
|
|
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
|
|
301
|
-
|
|
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
|
-
|
|
344
|
-
|
|
345
|
-
|
|
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
|
-
|
|
363
|
-
|
|
364
|
-
switch type {
|
|
365
|
-
case .began:
|
|
426
|
+
else { return }
|
|
427
|
+
|
|
428
|
+
if type == .began {
|
|
366
429
|
sendEvent(withName: "onAudioInterruption", body: ["type": "began"])
|
|
367
|
-
|
|
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
|
-
|
|
376
|
-
|
|
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
package/withNativeRecPlay.js
CHANGED
|
@@ -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']) {
|