rns-recplay 1.3.6 → 1.3.7
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 +5 -3
- package/android/src/main/java/com/rnsrecplay/RecPlayModule.kt +74 -28
- package/ios/RecPlayModule.swift +193 -149
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -130,12 +130,14 @@ Status Meanings:
|
|
|
130
130
|
|
|
131
131
|
### 🎙️ Recording
|
|
132
132
|
|
|
133
|
-
#### `startRecording({ fileName?, shouldStopPlayback?, onSecondsUpdate? })`
|
|
133
|
+
#### `startRecording({ fileName?, shouldStopPlayback?, duck?, mixWithOthers?, onSecondsUpdate? })`
|
|
134
134
|
|
|
135
135
|
| Parameter | Type | Default | Description |
|
|
136
136
|
|-----------|------|---------|-------------|
|
|
137
137
|
| `fileName` | `string` | `null` | Custom `.m4a` file name |
|
|
138
138
|
| `shouldStopPlayback` | `boolean` | `true` | Stops any playing audio |
|
|
139
|
+
| `duck` | `boolean` | `true` | Reduce volume of other audio when recording |
|
|
140
|
+
| `mixWithOthers` | `boolean` | `true` | Mix recording with device playing audio |
|
|
139
141
|
| `onSecondsUpdate` | `function` | `null` | Called every second |
|
|
140
142
|
|
|
141
143
|
**Returns:** `Promise<string>` (file name)
|
|
@@ -200,6 +202,7 @@ Toggles between play and pause.
|
|
|
200
202
|
- `BUFFERING`
|
|
201
203
|
- `PLAYING`
|
|
202
204
|
- `PAUSED`
|
|
205
|
+
- `ERROR`
|
|
203
206
|
- `ENDED`
|
|
204
207
|
- `IDLE`
|
|
205
208
|
|
|
@@ -217,5 +220,4 @@ Toggles between play and pause.
|
|
|
217
220
|
|
|
218
221
|
## 📄 License
|
|
219
222
|
|
|
220
|
-
MIT License
|
|
221
|
-
|
|
223
|
+
MIT License
|
|
@@ -17,6 +17,7 @@ import com.facebook.react.modules.core.PermissionListener
|
|
|
17
17
|
import com.google.android.exoplayer2.ExoPlayer
|
|
18
18
|
import com.google.android.exoplayer2.MediaItem
|
|
19
19
|
import com.google.android.exoplayer2.Player
|
|
20
|
+
import com.google.android.exoplayer2.audio.AudioAttributes
|
|
20
21
|
import java.io.File
|
|
21
22
|
|
|
22
23
|
class RecPlayModule(
|
|
@@ -214,23 +215,62 @@ class RecPlayModule(
|
|
|
214
215
|
) {
|
|
215
216
|
handler.post {
|
|
216
217
|
try {
|
|
217
|
-
val finalUri =
|
|
218
|
-
when {
|
|
219
|
-
uriString.startsWith("http://") || uriString.startsWith("https://") -> {
|
|
220
|
-
Uri.parse(uriString)
|
|
221
|
-
}
|
|
222
|
-
|
|
223
|
-
uriString.startsWith("file://") || uriString.startsWith("content://") -> {
|
|
224
|
-
Uri.parse(uriString)
|
|
225
|
-
}
|
|
226
|
-
|
|
227
|
-
else -> {
|
|
228
|
-
Uri.fromFile(File(uriString))
|
|
229
|
-
}
|
|
230
|
-
}
|
|
231
|
-
|
|
232
218
|
if (player == null) {
|
|
233
219
|
player = ExoPlayer.Builder(reactContext).build()
|
|
220
|
+
|
|
221
|
+
// ✅ RESTORE PLAYBACK STATUS EVENTS
|
|
222
|
+
player?.addListener(
|
|
223
|
+
object : Player.Listener {
|
|
224
|
+
override fun onPlaybackStateChanged(state: Int) {
|
|
225
|
+
val params = Arguments.createMap()
|
|
226
|
+
val status =
|
|
227
|
+
when (state) {
|
|
228
|
+
Player.STATE_BUFFERING -> "BUFFERING"
|
|
229
|
+
Player.STATE_READY -> if (player?.isPlaying == true) "PLAYING" else "PAUSED"
|
|
230
|
+
Player.STATE_ENDED -> "ENDED"
|
|
231
|
+
else -> "IDLE"
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
params.putString("status", status)
|
|
235
|
+
reactContext
|
|
236
|
+
.getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter::class.java)
|
|
237
|
+
.emit("onPlaybackStatus", params)
|
|
238
|
+
|
|
239
|
+
if (state == Player.STATE_ENDED) {
|
|
240
|
+
playbackHandler.removeCallbacks(playbackRunnable)
|
|
241
|
+
val finishParams = Arguments.createMap()
|
|
242
|
+
finishParams.putBoolean("finished", true)
|
|
243
|
+
reactContext
|
|
244
|
+
.getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter::class.java)
|
|
245
|
+
.emit("onPlaybackFinished", finishParams)
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
override fun onIsPlayingChanged(isPlaying: Boolean) {
|
|
250
|
+
val params = Arguments.createMap()
|
|
251
|
+
params.putString("status", if (isPlaying) "PLAYING" else "PAUSED")
|
|
252
|
+
reactContext
|
|
253
|
+
.getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter::class.java)
|
|
254
|
+
.emit("onPlaybackStatus", params)
|
|
255
|
+
|
|
256
|
+
if (isPlaying) {
|
|
257
|
+
playbackHandler.post(playbackRunnable)
|
|
258
|
+
} else {
|
|
259
|
+
playbackHandler.removeCallbacks(playbackRunnable)
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
override fun onPlayerError(error: com.google.android.exoplayer2.ExoPlaybackException) {
|
|
264
|
+
super.onPlayerError(error)
|
|
265
|
+
val params = Arguments.createMap()
|
|
266
|
+
params.putString("status", "ERROR")
|
|
267
|
+
params.putString("message", error.localizedMessage)
|
|
268
|
+
reactContext
|
|
269
|
+
.getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter::class.java)
|
|
270
|
+
.emit("onPlaybackStatus", params)
|
|
271
|
+
}
|
|
272
|
+
},
|
|
273
|
+
)
|
|
234
274
|
}
|
|
235
275
|
|
|
236
276
|
player?.apply {
|
|
@@ -239,22 +279,23 @@ class RecPlayModule(
|
|
|
239
279
|
clearMediaItems()
|
|
240
280
|
}
|
|
241
281
|
|
|
282
|
+
val usage =
|
|
283
|
+
when {
|
|
284
|
+
duck -> com.google.android.exoplayer2.C.USAGE_ASSISTANCE_SONIFICATION
|
|
285
|
+
mixWithOthers -> com.google.android.exoplayer2.C.USAGE_MEDIA
|
|
286
|
+
else -> com.google.android.exoplayer2.C.USAGE_MEDIA
|
|
287
|
+
}
|
|
288
|
+
|
|
242
289
|
val audioAttributes =
|
|
243
|
-
|
|
290
|
+
AudioAttributes
|
|
244
291
|
.Builder()
|
|
245
|
-
.setUsage(
|
|
292
|
+
.setUsage(usage)
|
|
246
293
|
.setContentType(com.google.android.exoplayer2.C.AUDIO_CONTENT_TYPE_MUSIC)
|
|
247
294
|
.build()
|
|
248
295
|
|
|
249
|
-
setAudioAttributes(audioAttributes,
|
|
296
|
+
setAudioAttributes(audioAttributes, !mixWithOthers)
|
|
250
297
|
|
|
251
|
-
val mediaItem =
|
|
252
|
-
MediaItem
|
|
253
|
-
.Builder()
|
|
254
|
-
.setUri(finalUri)
|
|
255
|
-
// REQUIRED for remote .m4a from PHP
|
|
256
|
-
.setMimeType("audio/mp4")
|
|
257
|
-
.build()
|
|
298
|
+
val mediaItem = MediaItem.fromUri(Uri.parse(uriString))
|
|
258
299
|
|
|
259
300
|
repeatMode =
|
|
260
301
|
if (loop) Player.REPEAT_MODE_ONE else Player.REPEAT_MODE_OFF
|
|
@@ -262,12 +303,17 @@ class RecPlayModule(
|
|
|
262
303
|
setMediaItem(mediaItem)
|
|
263
304
|
prepare()
|
|
264
305
|
playWhenReady = true
|
|
265
|
-
|
|
266
|
-
playbackHandler.removeCallbacks(playbackRunnable)
|
|
267
|
-
playbackHandler.post(playbackRunnable)
|
|
268
306
|
}
|
|
269
307
|
} catch (e: Exception) {
|
|
270
308
|
Log.e(TAG, "Playback Error", e)
|
|
309
|
+
|
|
310
|
+
// ✅ Emit ERROR event to JS
|
|
311
|
+
val params = Arguments.createMap()
|
|
312
|
+
params.putString("status", "ERROR")
|
|
313
|
+
params.putString("message", e.localizedMessage)
|
|
314
|
+
reactContext
|
|
315
|
+
.getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter::class.java)
|
|
316
|
+
.emit("onPlaybackStatus", params)
|
|
271
317
|
}
|
|
272
318
|
}
|
|
273
319
|
}
|
package/ios/RecPlayModule.swift
CHANGED
|
@@ -19,7 +19,10 @@ class RecPlayModule: RCTEventEmitter {
|
|
|
19
19
|
}
|
|
20
20
|
|
|
21
21
|
override func supportedEvents() -> [String]! {
|
|
22
|
-
return [
|
|
22
|
+
return [
|
|
23
|
+
"onTimerUpdate", "onPlaybackStatus", "onPlaybackProgress", "onPlaybackFinished",
|
|
24
|
+
"onAudioInterruption",
|
|
25
|
+
]
|
|
23
26
|
}
|
|
24
27
|
|
|
25
28
|
override init() {
|
|
@@ -44,76 +47,76 @@ class RecPlayModule: RCTEventEmitter {
|
|
|
44
47
|
|
|
45
48
|
// MARK: - Recording Logic
|
|
46
49
|
@objc(startRecording:shouldStopPlayback:duck:mixWithOthers:resolver:rejecter:)
|
|
47
|
-
func startRecording(
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
) {
|
|
55
|
-
|
|
56
|
-
if shouldStopPlayback {
|
|
57
|
-
stopPlaybackInternal()
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
let session = AVAudioSession.sharedInstance()
|
|
61
|
-
|
|
62
|
-
do {
|
|
63
|
-
// Build options dynamically based on new params
|
|
64
|
-
var options: AVAudioSession.CategoryOptions = [.defaultToSpeaker]
|
|
50
|
+
func startRecording(
|
|
51
|
+
fileName: String?,
|
|
52
|
+
shouldStopPlayback: Bool,
|
|
53
|
+
duck: Bool,
|
|
54
|
+
mixWithOthers: Bool,
|
|
55
|
+
resolve: @escaping RCTPromiseResolveBlock,
|
|
56
|
+
reject: @escaping RCTPromiseRejectBlock
|
|
57
|
+
) {
|
|
65
58
|
|
|
66
|
-
if
|
|
67
|
-
|
|
68
|
-
}
|
|
69
|
-
if mixWithOthers {
|
|
70
|
-
options.insert(.mixWithOthers)
|
|
59
|
+
if shouldStopPlayback {
|
|
60
|
+
stopPlaybackInternal()
|
|
71
61
|
}
|
|
72
62
|
|
|
73
|
-
|
|
74
|
-
try? session.setActive(false)
|
|
75
|
-
try session.setCategory(.playAndRecord, mode: .default, options: options)
|
|
76
|
-
try session.setActive(true)
|
|
77
|
-
|
|
78
|
-
let name = fileName ?? "rec_\(Int(Date().timeIntervalSince1970))"
|
|
79
|
-
let fileURL = FileManager.default.temporaryDirectory
|
|
80
|
-
.appendingPathComponent("\(name).m4a")
|
|
81
|
-
|
|
82
|
-
let settings: [String: Any] = [
|
|
83
|
-
AVFormatIDKey: Int(kAudioFormatMPEG4AAC),
|
|
84
|
-
AVSampleRateKey: 44100,
|
|
85
|
-
AVNumberOfChannelsKey: 1,
|
|
86
|
-
AVEncoderAudioQualityKey: AVAudioQuality.high.rawValue,
|
|
87
|
-
AVEncoderBitRateKey: 128000
|
|
88
|
-
]
|
|
63
|
+
let session = AVAudioSession.sharedInstance()
|
|
89
64
|
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
65
|
+
do {
|
|
66
|
+
// Build options dynamically based on new params
|
|
67
|
+
var options: AVAudioSession.CategoryOptions = [.defaultToSpeaker]
|
|
93
68
|
|
|
94
|
-
|
|
95
|
-
|
|
69
|
+
if duck {
|
|
70
|
+
options.insert(.duckOthers)
|
|
71
|
+
}
|
|
72
|
+
if mixWithOthers {
|
|
73
|
+
options.insert(.mixWithOthers)
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// Reset session if previously active
|
|
77
|
+
try? session.setActive(false)
|
|
78
|
+
try session.setCategory(.playAndRecord, mode: .default, options: options)
|
|
79
|
+
try session.setActive(true)
|
|
96
80
|
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
81
|
+
let name = fileName ?? "rec_\(Int(Date().timeIntervalSince1970))"
|
|
82
|
+
let fileURL = FileManager.default.temporaryDirectory
|
|
83
|
+
.appendingPathComponent("\(name).m4a")
|
|
84
|
+
|
|
85
|
+
let settings: [String: Any] = [
|
|
86
|
+
AVFormatIDKey: Int(kAudioFormatMPEG4AAC),
|
|
87
|
+
AVSampleRateKey: 44100,
|
|
88
|
+
AVNumberOfChannelsKey: 1,
|
|
89
|
+
AVEncoderAudioQualityKey: AVAudioQuality.high.rawValue,
|
|
90
|
+
AVEncoderBitRateKey: 128000,
|
|
91
|
+
]
|
|
92
|
+
|
|
93
|
+
audioRecorder = try AVAudioRecorder(url: fileURL, settings: settings)
|
|
94
|
+
audioRecorder?.prepareToRecord()
|
|
95
|
+
audioRecorder?.record()
|
|
96
|
+
|
|
97
|
+
secondsElapsed = 0
|
|
98
|
+
isPaused = false
|
|
99
|
+
|
|
100
|
+
DispatchQueue.main.async {
|
|
101
|
+
self.recordingTimer?.invalidate()
|
|
102
|
+
self.recordingTimer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) {
|
|
103
|
+
_ in
|
|
104
|
+
if !self.isPaused {
|
|
105
|
+
self.sendEvent(
|
|
106
|
+
withName: "onTimerUpdate",
|
|
107
|
+
body: ["seconds": self.secondsElapsed]
|
|
108
|
+
)
|
|
109
|
+
self.secondsElapsed += 1
|
|
110
|
+
}
|
|
106
111
|
}
|
|
107
112
|
}
|
|
108
|
-
}
|
|
109
113
|
|
|
110
|
-
|
|
114
|
+
resolve(name)
|
|
111
115
|
|
|
112
|
-
|
|
113
|
-
|
|
116
|
+
} catch {
|
|
117
|
+
reject("REC_ERROR", "Failed to start recording", error)
|
|
118
|
+
}
|
|
114
119
|
}
|
|
115
|
-
}
|
|
116
|
-
|
|
117
120
|
|
|
118
121
|
@objc(stopRecording:rejecter:)
|
|
119
122
|
func stopRecording(
|
|
@@ -130,7 +133,8 @@ func startRecording(
|
|
|
130
133
|
|
|
131
134
|
// notify other audio that we deactivated so they can resume
|
|
132
135
|
do {
|
|
133
|
-
try AVAudioSession.sharedInstance().setActive(
|
|
136
|
+
try AVAudioSession.sharedInstance().setActive(
|
|
137
|
+
false, options: .notifyOthersOnDeactivation)
|
|
134
138
|
} catch {
|
|
135
139
|
// non-fatal
|
|
136
140
|
print("⚠️ setActive(false) failed: \(error.localizedDescription)")
|
|
@@ -196,69 +200,136 @@ func startRecording(
|
|
|
196
200
|
uri: String,
|
|
197
201
|
shouldStopPrevious: Bool,
|
|
198
202
|
loop: Bool,
|
|
199
|
-
|
|
203
|
+
mixWithOthers: Bool,
|
|
200
204
|
duckOthers: Bool
|
|
201
|
-
) {
|
|
202
|
-
|
|
205
|
+
) {
|
|
206
|
+
DispatchQueue.main.async { [weak self] in
|
|
207
|
+
guard let self = self else { return }
|
|
203
208
|
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
209
|
+
if shouldStopPrevious {
|
|
210
|
+
self.stopPlaybackInternal()
|
|
211
|
+
}
|
|
207
212
|
|
|
208
|
-
|
|
213
|
+
let session = AVAudioSession.sharedInstance()
|
|
214
|
+
do {
|
|
215
|
+
try session.setActive(false)
|
|
209
216
|
|
|
210
|
-
|
|
211
|
-
try session.setActive(false)
|
|
217
|
+
var options: AVAudioSession.CategoryOptions = []
|
|
212
218
|
|
|
213
|
-
|
|
219
|
+
if mixWithOthers {
|
|
220
|
+
options.insert(.mixWithOthers)
|
|
221
|
+
} else if duckOthers {
|
|
222
|
+
options.insert(.duckOthers)
|
|
223
|
+
}
|
|
214
224
|
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
225
|
+
try session.setCategory(.playback, mode: .default, options: options)
|
|
226
|
+
try session.setActive(true)
|
|
227
|
+
|
|
228
|
+
} catch {
|
|
229
|
+
print("⚠️ Audio Session Error: \(error.localizedDescription)")
|
|
230
|
+
self.sendEvent(withName: "onPlaybackStatus", body: ["status": "ERROR"])
|
|
231
|
+
return
|
|
219
232
|
}
|
|
220
233
|
|
|
221
|
-
|
|
222
|
-
|
|
234
|
+
let url: URL
|
|
235
|
+
if uri.hasPrefix("http") || uri.hasPrefix("file://") {
|
|
236
|
+
guard let u = URL(string: uri) else {
|
|
237
|
+
self.sendEvent(withName: "onPlaybackStatus", body: ["status": "ERROR"])
|
|
238
|
+
return
|
|
239
|
+
}
|
|
240
|
+
url = u
|
|
241
|
+
} else {
|
|
242
|
+
url = URL(fileURLWithPath: uri)
|
|
243
|
+
}
|
|
223
244
|
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
}
|
|
245
|
+
let asset = AVURLAsset(url: url)
|
|
246
|
+
self.playerItem = AVPlayerItem(asset: asset)
|
|
247
|
+
self.isLooping = loop
|
|
228
248
|
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
}
|
|
249
|
+
// Add observers for buffering & ready-to-play
|
|
250
|
+
self.playerItem?.addObserver(self, forKeyPath: "status", options: [.new], context: nil)
|
|
251
|
+
self.playerItem?.addObserver(
|
|
252
|
+
self, forKeyPath: "playbackBufferEmpty", options: [.new], context: nil)
|
|
253
|
+
self.playerItem?.addObserver(
|
|
254
|
+
self, forKeyPath: "playbackLikelyToKeepUp", options: [.new], context: nil)
|
|
236
255
|
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
256
|
+
NotificationCenter.default.addObserver(
|
|
257
|
+
self,
|
|
258
|
+
selector: #selector(self.playerDidFinishPlaying),
|
|
259
|
+
name: .AVPlayerItemDidPlayToEndTime,
|
|
260
|
+
object: self.playerItem
|
|
261
|
+
)
|
|
240
262
|
|
|
241
|
-
|
|
263
|
+
if self.audioPlayer == nil {
|
|
264
|
+
self.audioPlayer = AVPlayer(playerItem: self.playerItem)
|
|
265
|
+
} else {
|
|
266
|
+
self.audioPlayer?.replaceCurrentItem(with: self.playerItem)
|
|
267
|
+
}
|
|
242
268
|
|
|
243
|
-
|
|
244
|
-
self
|
|
245
|
-
selector: #selector(self.playerDidFinishPlaying),
|
|
246
|
-
name: .AVPlayerItemDidPlayToEndTime,
|
|
247
|
-
object: self.playerItem
|
|
248
|
-
)
|
|
269
|
+
self.setupProgressObserver()
|
|
270
|
+
self.audioPlayer?.play()
|
|
249
271
|
|
|
250
|
-
|
|
251
|
-
self.audioPlayer = AVPlayer(playerItem: self.playerItem)
|
|
252
|
-
} else {
|
|
253
|
-
self.audioPlayer?.replaceCurrentItem(with: self.playerItem)
|
|
272
|
+
self.sendEvent(withName: "onPlaybackStatus", body: ["status": "PLAYING"])
|
|
254
273
|
}
|
|
274
|
+
}
|
|
255
275
|
|
|
256
|
-
|
|
257
|
-
|
|
276
|
+
// MARK: - Observers for Status / Buffering
|
|
277
|
+
override func observeValue(
|
|
278
|
+
forKeyPath keyPath: String?,
|
|
279
|
+
of object: Any?,
|
|
280
|
+
change: [NSKeyValueChangeKey: Any]?,
|
|
281
|
+
context: UnsafeMutableRawPointer?
|
|
282
|
+
) {
|
|
283
|
+
guard let item = object as? AVPlayerItem else { return }
|
|
284
|
+
|
|
285
|
+
switch keyPath {
|
|
286
|
+
case "status":
|
|
287
|
+
switch item.status {
|
|
288
|
+
case .readyToPlay:
|
|
289
|
+
let isPlaying = self.audioPlayer?.timeControlStatus == .playing
|
|
290
|
+
self.sendEvent(
|
|
291
|
+
withName: "onPlaybackStatus", body: ["status": isPlaying ? "PLAYING" : "PAUSED"]
|
|
292
|
+
)
|
|
293
|
+
case .failed:
|
|
294
|
+
self.sendEvent(withName: "onPlaybackStatus", body: ["status": "ERROR"])
|
|
295
|
+
default:
|
|
296
|
+
self.sendEvent(withName: "onPlaybackStatus", body: ["status": "IDLE"])
|
|
297
|
+
}
|
|
258
298
|
|
|
259
|
-
|
|
260
|
-
|
|
299
|
+
case "playbackBufferEmpty":
|
|
300
|
+
if item.isPlaybackBufferEmpty {
|
|
301
|
+
self.sendEvent(withName: "onPlaybackStatus", body: ["status": "BUFFERING"])
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
case "playbackLikelyToKeepUp":
|
|
305
|
+
if item.isPlaybackLikelyToKeepUp {
|
|
306
|
+
let isPlaying = self.audioPlayer?.timeControlStatus == .playing
|
|
307
|
+
self.sendEvent(
|
|
308
|
+
withName: "onPlaybackStatus", body: ["status": isPlaying ? "PLAYING" : "PAUSED"]
|
|
309
|
+
)
|
|
310
|
+
}
|
|
261
311
|
|
|
312
|
+
default:
|
|
313
|
+
break
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
@objc func playerDidFinishPlaying(note: NSNotification) {
|
|
318
|
+
if isLooping {
|
|
319
|
+
audioPlayer?.seek(to: .zero)
|
|
320
|
+
audioPlayer?.play()
|
|
321
|
+
self.sendEvent(withName: "onPlaybackStatus", body: ["status": "PLAYING"])
|
|
322
|
+
} else {
|
|
323
|
+
sendEvent(withName: "onPlaybackFinished", body: ["finished": true])
|
|
324
|
+
sendEvent(withName: "onPlaybackStatus", body: ["status": "ENDED"])
|
|
325
|
+
do {
|
|
326
|
+
try AVAudioSession.sharedInstance().setActive(
|
|
327
|
+
false, options: .notifyOthersOnDeactivation)
|
|
328
|
+
} catch {
|
|
329
|
+
print("⚠️ setActive(false) failed: \(error.localizedDescription)")
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
}
|
|
262
333
|
|
|
263
334
|
private func setupProgressObserver() {
|
|
264
335
|
|
|
@@ -283,7 +354,7 @@ func startRecording(
|
|
|
283
354
|
withName: "onPlaybackProgress",
|
|
284
355
|
body: [
|
|
285
356
|
"currentPosition": time.seconds,
|
|
286
|
-
"duration": duration
|
|
357
|
+
"duration": duration,
|
|
287
358
|
]
|
|
288
359
|
)
|
|
289
360
|
}
|
|
@@ -294,7 +365,8 @@ func startRecording(
|
|
|
294
365
|
stopPlaybackInternal()
|
|
295
366
|
// notify others so they can resume
|
|
296
367
|
do {
|
|
297
|
-
try AVAudioSession.sharedInstance().setActive(
|
|
368
|
+
try AVAudioSession.sharedInstance().setActive(
|
|
369
|
+
false, options: .notifyOthersOnDeactivation)
|
|
298
370
|
} catch {
|
|
299
371
|
print("⚠️ setActive(false) failed: \(error.localizedDescription)")
|
|
300
372
|
}
|
|
@@ -338,46 +410,13 @@ func startRecording(
|
|
|
338
410
|
}
|
|
339
411
|
}
|
|
340
412
|
|
|
341
|
-
override func observeValue(
|
|
342
|
-
forKeyPath keyPath: String?,
|
|
343
|
-
of object: Any?,
|
|
344
|
-
change: [NSKeyValueChangeKey: Any]?,
|
|
345
|
-
context: UnsafeMutableRawPointer?
|
|
346
|
-
) {
|
|
347
|
-
|
|
348
|
-
if keyPath == "status", let item = object as? AVPlayerItem {
|
|
349
|
-
if item.status == .failed {
|
|
350
|
-
sendEvent(withName: "onPlaybackStatus", body: ["status": "ERROR"])
|
|
351
|
-
} else if item.status == .readyToPlay {
|
|
352
|
-
// ready, but we don't force other players to resume here
|
|
353
|
-
print("DEBUG: Audio Ready")
|
|
354
|
-
}
|
|
355
|
-
}
|
|
356
|
-
}
|
|
357
|
-
|
|
358
|
-
@objc func playerDidFinishPlaying(note: NSNotification) {
|
|
359
|
-
|
|
360
|
-
if isLooping {
|
|
361
|
-
audioPlayer?.seek(to: .zero)
|
|
362
|
-
audioPlayer?.play()
|
|
363
|
-
} else {
|
|
364
|
-
sendEvent(withName: "onPlaybackFinished", body: ["finished": true])
|
|
365
|
-
sendEvent(withName: "onPlaybackStatus", body: ["status": "ENDED"])
|
|
366
|
-
// deactivate so others can resume
|
|
367
|
-
do {
|
|
368
|
-
try AVAudioSession.sharedInstance().setActive(false, options: .notifyOthersOnDeactivation)
|
|
369
|
-
} catch {
|
|
370
|
-
print("⚠️ setActive(false) failed: \(error.localizedDescription)")
|
|
371
|
-
}
|
|
372
|
-
}
|
|
373
|
-
}
|
|
374
|
-
|
|
375
413
|
// MARK: - Interruption & Route Handling
|
|
376
414
|
|
|
377
415
|
@objc private func handleAudioSessionInterruption(_ notification: Notification) {
|
|
378
416
|
guard let info = notification.userInfo,
|
|
379
|
-
|
|
380
|
-
|
|
417
|
+
let typeValue = info[AVAudioSessionInterruptionTypeKey] as? UInt,
|
|
418
|
+
let type = AVAudioSession.InterruptionType(rawValue: typeValue)
|
|
419
|
+
else {
|
|
381
420
|
return
|
|
382
421
|
}
|
|
383
422
|
|
|
@@ -389,15 +428,19 @@ func startRecording(
|
|
|
389
428
|
case .ended:
|
|
390
429
|
// interruption ended — optionally reactivate
|
|
391
430
|
let optionsValue = info[AVAudioSessionInterruptionOptionKey] as? UInt
|
|
392
|
-
let shouldResume =
|
|
393
|
-
|
|
431
|
+
let shouldResume =
|
|
432
|
+
(optionsValue ?? 0) & AVAudioSession.InterruptionOptions.shouldResume.rawValue != 0
|
|
433
|
+
sendEvent(
|
|
434
|
+
withName: "onAudioInterruption",
|
|
435
|
+
body: ["type": "ended", "shouldResume": shouldResume])
|
|
394
436
|
if shouldResume {
|
|
395
437
|
// try to reactivate and resume playback if appropriate
|
|
396
438
|
do {
|
|
397
439
|
try AVAudioSession.sharedInstance().setActive(true)
|
|
398
440
|
audioPlayer?.play()
|
|
399
441
|
} catch {
|
|
400
|
-
print(
|
|
442
|
+
print(
|
|
443
|
+
"⚠️ Failed to reactivate after interruption: \(error.localizedDescription)")
|
|
401
444
|
}
|
|
402
445
|
}
|
|
403
446
|
print("🔊 Audio interruption ended. shouldResume: \(shouldResume)")
|
|
@@ -408,8 +451,9 @@ func startRecording(
|
|
|
408
451
|
|
|
409
452
|
@objc private func handleRouteChange(_ notification: Notification) {
|
|
410
453
|
guard let userInfo = notification.userInfo,
|
|
411
|
-
|
|
412
|
-
|
|
454
|
+
let reasonValue = userInfo[AVAudioSessionRouteChangeReasonKey] as? UInt,
|
|
455
|
+
let reason = AVAudioSession.RouteChangeReason(rawValue: reasonValue)
|
|
456
|
+
else { return }
|
|
413
457
|
|
|
414
458
|
switch reason {
|
|
415
459
|
case .oldDeviceUnavailable:
|