@stream-io/video-react-native-sdk 1.38.2 → 1.39.1-beta.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 (50) hide show
  1. package/CHANGELOG.md +1629 -0
  2. package/android/src/main/java/com/streamvideo/reactnative/StreamVideoReactNativeModule.kt +81 -0
  3. package/android/src/main/java/com/streamvideo/reactnative/recorder/AudioPipeline.kt +436 -0
  4. package/android/src/main/java/com/streamvideo/reactnative/recorder/EncoderConstants.kt +17 -0
  5. package/android/src/main/java/com/streamvideo/reactnative/recorder/PipelineHost.kt +36 -0
  6. package/android/src/main/java/com/streamvideo/reactnative/recorder/RecorderPlaybackSamplesSink.kt +60 -0
  7. package/android/src/main/java/com/streamvideo/reactnative/recorder/RecorderVideoSink.kt +31 -0
  8. package/android/src/main/java/com/streamvideo/reactnative/recorder/TracksRecorderManager.kt +329 -0
  9. package/android/src/main/java/com/streamvideo/reactnative/recorder/VideoPipeline.kt +472 -0
  10. package/dist/commonjs/components/Participant/ParticipantView/ParticipantLabel.js +4 -3
  11. package/dist/commonjs/components/Participant/ParticipantView/ParticipantLabel.js.map +1 -1
  12. package/dist/commonjs/hooks/index.js +11 -0
  13. package/dist/commonjs/hooks/index.js.map +1 -1
  14. package/dist/commonjs/hooks/useLoopbackRecording.js +243 -0
  15. package/dist/commonjs/hooks/useLoopbackRecording.js.map +1 -0
  16. package/dist/commonjs/utils/internal/callingx/callingx.js +2 -2
  17. package/dist/commonjs/utils/internal/callingx/callingx.js.map +1 -1
  18. package/dist/commonjs/version.js +1 -1
  19. package/dist/commonjs/version.js.map +1 -1
  20. package/dist/module/components/Participant/ParticipantView/ParticipantLabel.js +5 -4
  21. package/dist/module/components/Participant/ParticipantView/ParticipantLabel.js.map +1 -1
  22. package/dist/module/hooks/index.js +1 -0
  23. package/dist/module/hooks/index.js.map +1 -1
  24. package/dist/module/hooks/useLoopbackRecording.js +238 -0
  25. package/dist/module/hooks/useLoopbackRecording.js.map +1 -0
  26. package/dist/module/utils/internal/callingx/callingx.js +2 -2
  27. package/dist/module/utils/internal/callingx/callingx.js.map +1 -1
  28. package/dist/module/version.js +1 -1
  29. package/dist/module/version.js.map +1 -1
  30. package/dist/typescript/components/Participant/ParticipantView/ParticipantLabel.d.ts.map +1 -1
  31. package/dist/typescript/hooks/index.d.ts +1 -0
  32. package/dist/typescript/hooks/index.d.ts.map +1 -1
  33. package/dist/typescript/hooks/useLoopbackRecording.d.ts +85 -0
  34. package/dist/typescript/hooks/useLoopbackRecording.d.ts.map +1 -0
  35. package/dist/typescript/version.d.ts +1 -1
  36. package/dist/typescript/version.d.ts.map +1 -1
  37. package/ios/StreamVideoReactNative-Bridging-Header.h +2 -0
  38. package/ios/StreamVideoReactNative.m +81 -0
  39. package/ios/TracksRecorder/AudioPipeline.swift +270 -0
  40. package/ios/TracksRecorder/PipelineHost.swift +56 -0
  41. package/ios/TracksRecorder/RecorderAudioRenderTap.swift +154 -0
  42. package/ios/TracksRecorder/RecorderVideoSink.swift +137 -0
  43. package/ios/TracksRecorder/TracksRecorderManager.swift +327 -0
  44. package/ios/TracksRecorder/VideoPipeline.swift +297 -0
  45. package/package.json +7 -6
  46. package/src/components/Participant/ParticipantView/ParticipantLabel.tsx +5 -3
  47. package/src/hooks/index.ts +1 -0
  48. package/src/hooks/useLoopbackRecording.ts +438 -0
  49. package/src/utils/internal/callingx/callingx.ts +2 -2
  50. package/src/version.ts +1 -1
@@ -25,8 +25,10 @@ import com.facebook.react.bridge.ReactContextBaseJavaModule
25
25
  import com.facebook.react.bridge.ReactMethod
26
26
  import com.facebook.react.bridge.WritableMap
27
27
  import com.facebook.react.modules.core.DeviceEventManagerModule.RCTDeviceEventEmitter
28
+ import com.facebook.react.bridge.ReadableMap
28
29
  import com.oney.WebRTCModule.WebRTCModule
29
30
  import com.oney.WebRTCModule.WebRTCModuleOptions
31
+ import com.streamvideo.reactnative.recorder.TracksRecorderManager
30
32
  import com.streamvideo.reactnative.screenshare.ScreenAudioCapture
31
33
  import com.streamvideo.reactnative.keepalive.StreamCallKeepAliveHeadlessService
32
34
  import com.streamvideo.reactnative.util.CallAlivePermissionsHelper
@@ -41,6 +43,7 @@ import kotlinx.coroutines.launch
41
43
  import org.webrtc.VideoSink
42
44
  import org.webrtc.VideoTrack
43
45
  import java.io.ByteArrayOutputStream
46
+ import java.io.File
44
47
  import kotlin.math.sin
45
48
 
46
49
 
@@ -606,8 +609,86 @@ class StreamVideoReactNativeModule(reactContext: ReactApplicationContext) :
606
609
  }
607
610
  }
608
611
 
612
+ // ── Track recorder bridge ────────────────────────────────────────────
613
+
614
+ @ReactMethod
615
+ fun startTrackRecording(options: ReadableMap, promise: Promise) {
616
+ val videoTrackId = if (options.hasKey("videoTrackId") && !options.isNull("videoTrackId")) {
617
+ options.getString("videoTrackId")
618
+ } else {
619
+ null
620
+ }
621
+ val maxDurationMs = if (options.hasKey("maxDurationMs") && !options.isNull("maxDurationMs")) {
622
+ options.getInt("maxDurationMs").toLong()
623
+ } else {
624
+ DEFAULT_RECORDING_DURATION_MS
625
+ }
626
+ val targetWidth = if (options.hasKey("targetWidth") && !options.isNull("targetWidth")) {
627
+ options.getInt("targetWidth")
628
+ } else {
629
+ 0
630
+ }
631
+ val targetHeight = if (options.hasKey("targetHeight") && !options.isNull("targetHeight")) {
632
+ options.getInt("targetHeight")
633
+ } else {
634
+ 0
635
+ }
636
+
637
+ val webRTCModule = reactApplicationContext.getNativeModule(WebRTCModule::class.java)
638
+ if (webRTCModule == null) {
639
+ promise.reject(RECORDING_ERROR_CODE, "WebRTCModule not available")
640
+ return
641
+ }
642
+
643
+ TracksRecorderManager.shared.startRecording(
644
+ context = reactApplicationContext,
645
+ webRTCModule = webRTCModule,
646
+ videoTrackId = videoTrackId,
647
+ maxDurationMs = maxDurationMs,
648
+ targetWidth = targetWidth,
649
+ targetHeight = targetHeight,
650
+ ) { file, error ->
651
+ if (error != null) {
652
+ promise.reject(RECORDING_ERROR_CODE, error.message ?: "recording failed", error)
653
+ } else {
654
+ promise.resolve(file?.let { "file://${it.absolutePath}" })
655
+ }
656
+ }
657
+ }
658
+
659
+ @ReactMethod
660
+ fun stopTrackRecording(promise: Promise) {
661
+ TracksRecorderManager.shared.stopRecording {
662
+ promise.resolve(null)
663
+ }
664
+ }
665
+
666
+ @ReactMethod
667
+ fun clearStreamRecordings(promise: Promise) {
668
+ TracksRecorderManager.shared.clearRecordingsDirectory(reactApplicationContext) { error ->
669
+ if (error != null) {
670
+ promise.reject(RECORDING_CLEAR_ERROR_CODE, error.message ?: "clear failed", error)
671
+ } else {
672
+ promise.resolve(null)
673
+ }
674
+ }
675
+ }
676
+
677
+ @ReactMethod
678
+ fun getStreamRecordings(promise: Promise) {
679
+ val files: List<File> = TracksRecorderManager.shared.listRecordings(reactApplicationContext)
680
+ val arr = Arguments.createArray()
681
+ for (f in files) {
682
+ arr.pushString("file://${f.absolutePath}")
683
+ }
684
+ promise.resolve(arr)
685
+ }
686
+
609
687
  companion object {
610
688
  private const val NAME = "StreamVideoReactNative"
611
689
  private const val SAMPLE_RATE = 22050
690
+ private const val DEFAULT_RECORDING_DURATION_MS = 5000L
691
+ private const val RECORDING_ERROR_CODE = "recording_error"
692
+ private const val RECORDING_CLEAR_ERROR_CODE = "clear_error"
612
693
  }
613
694
  }
@@ -0,0 +1,436 @@
1
+ package com.streamvideo.reactnative.recorder
2
+
3
+ import android.media.MediaCodec
4
+ import android.media.MediaCodecInfo
5
+ import android.media.MediaFormat
6
+ import android.media.MediaMuxer
7
+ import android.util.Log
8
+ import com.oney.WebRTCModule.WebRTCModule
9
+ import com.oney.WebRTCModule.WebRTCModuleOptions
10
+ import java.nio.ByteBuffer
11
+
12
+ /**
13
+ * Audio pipeline owned by [TracksRecorderManager]. Encapsulates the AAC
14
+ * audio path:
15
+ * - the [RecorderPlaybackSamplesSink] registered with
16
+ * `WebRTCModuleOptions.addPlaybackSamplesObserver` (the fork's
17
+ * multi-tenant fan-out over `JavaAudioDeviceModule.PlaybackSamplesReadyCallback`),
18
+ * - the [MediaCodec] AAC encoder + its configuration,
19
+ * - reflection-based speaker mute via `WebRtcAudioTrack.audioTrack.setVolume(0)`
20
+ * so the SFU loopback echo doesn't feed back into the mic,
21
+ * - encoder output drain (format-locked addTrack, sample append, EOS),
22
+ * - per-recording counters surfaced for the end-of-recording log line.
23
+ *
24
+ * All state mutation runs on the host's handler thread.
25
+ */
26
+ internal class AudioPipeline(
27
+ private val host: PipelineHost,
28
+ private val webRTCModule: WebRTCModule,
29
+ ) {
30
+ private companion object {
31
+ const val TAG = "TracksRecorder.Audio"
32
+ const val MIME = "audio/mp4a-latm"
33
+ const val BIT_RATE = 64_000
34
+ }
35
+
36
+ private val handler = host.handler
37
+
38
+ private var encoder: MediaCodec? = null
39
+ private var sink: RecorderPlaybackSamplesSink? = null
40
+
41
+ private var trackIndex: Int = -1
42
+ private var formatLocked = false
43
+
44
+ private var sampleRate: Int = 0
45
+ private var channelCount: Int = 0
46
+ private var bitsPerSample: Int = 0
47
+
48
+ /**
49
+ * Reference to the system [android.media.AudioTrack] held while
50
+ * playback is muted for recording, so cleanup can restore the
51
+ * original volume. `@Volatile` because [restoreLoopbackPlaybackMute]
52
+ * is invoked from both the recorder handler (manual stop) and the
53
+ * main looper (auto-stop runnable).
54
+ */
55
+ @Volatile
56
+ private var mutedSystemAudioTrack: android.media.AudioTrack? = null
57
+
58
+ @Volatile
59
+ private var muteUnavailable: Boolean = false
60
+
61
+ //diagnostic counters
62
+ private var buffersReceived = 0
63
+ private var samplesAppended = 0
64
+ private var buffersDropped = 0
65
+ private var firstSamplePtsUs: Long = -1
66
+ private var lastSamplePtsUs: Long = -1
67
+
68
+ /**
69
+ * Register the playback-samples observer with the fork's fan-out and
70
+ * mute the system playback track. Future audio buffers post to the
71
+ * handler.
72
+ */
73
+ fun start() {
74
+ val s = RecorderPlaybackSamplesSink { data, sr, ch, frames ->
75
+ onAudioBufferDelivered(data, sr, ch, frames)
76
+ }
77
+ sink = s
78
+ WebRTCModuleOptions.getInstance().addPlaybackSamplesObserver(s)
79
+
80
+ // Always mute the speaker so the loopback echo doesn't feed
81
+ // back into the mic. First attempt may fail due to a race condition
82
+ // where the system AudioTrack hasn't been created yet.
83
+ ensureLoopbackPlaybackMuted()
84
+ }
85
+
86
+ /**
87
+ * Detach the sink and restore speaker volume synchronously from any
88
+ * thread. Both operations are off-handler so they take effect
89
+ * immediately — routing them through the handler would queue them
90
+ * behind the encoder backlog and keep accepting samples / leave the
91
+ * speaker muted past the user-facing stop point.
92
+ */
93
+ fun detachSink() {
94
+ try {
95
+ sink?.let {
96
+ WebRTCModuleOptions.getInstance().removePlaybackSamplesObserver(it)
97
+ }
98
+ } catch (t: Throwable) {
99
+ Log.w(TAG, "removePlaybackSamplesObserver threw", t)
100
+ }
101
+ sink = null
102
+ restoreLoopbackPlaybackMute()
103
+ }
104
+
105
+ /** On-handler. Submit EOS to the encoder. Returns `true` if queued. */
106
+ fun signalEndOfStream(muxerInstance: MediaMuxer): Boolean {
107
+ val enc = encoder ?: return false
108
+ return signalEoS(enc, muxerInstance)
109
+ }
110
+
111
+ /** On-handler. Drain until BUFFER_FLAG_END_OF_STREAM or budget expires. */
112
+ fun drainAfterEoS(muxerInstance: MediaMuxer) {
113
+ val enc = encoder ?: return
114
+ drain(enc, muxerInstance, endOfStream = true)
115
+ }
116
+
117
+ /** On-handler. Stop + release the encoder. */
118
+ fun stopAndRelease() {
119
+ try {
120
+ encoder?.stop()
121
+ } catch (t: Throwable) {
122
+ Log.w(TAG, "encoder.stop() threw", t)
123
+ }
124
+ try {
125
+ encoder?.release()
126
+ } catch (t: Throwable) {
127
+ Log.w(TAG, "encoder.release() threw", t)
128
+ }
129
+ encoder = null
130
+ }
131
+
132
+ fun logSummary() {
133
+ val durationMs = if (firstSamplePtsUs >= 0 && lastSamplePtsUs >= firstSamplePtsUs) {
134
+ (lastSamplePtsUs - firstSamplePtsUs) / 1000
135
+ } else {
136
+ -1
137
+ }
138
+ Log.i(
139
+ TAG,
140
+ "summary received=$buffersReceived appended=$samplesAppended dropped=$buffersDropped firstPtsUs=$firstSamplePtsUs lastPtsUs=$lastSamplePtsUs durationMs=$durationMs",
141
+ )
142
+ }
143
+
144
+ /**
145
+ * Idempotent: try to mute the system playback track by setting its
146
+ * volume to 0. Returns `true` if mute is now in place (either it
147
+ * was just applied, or it was already applied).
148
+ *
149
+ * `WebRtcAudioTrack.audioTrack` is `null` until WebRTC calls
150
+ * `startPlayout()`, which happens **asynchronously after** the
151
+ * loopback `MediaStreamTrack` is enabled. The retry path
152
+ * lives in [handleAudioBufferOnHandler], which only runs after
153
+ * `startPlayout()` has created the system AudioTrack (that's the
154
+ * precondition for the JADM playback callback firing at all).
155
+ *
156
+ * Reflection is required because `WebRtcAudioTrack` is package-
157
+ * private and its `audioTrack` field private. Best-effort — if
158
+ * reflection fails because the class layout changed, recording
159
+ * still works; the user just hears the echo.
160
+ */
161
+ private fun ensureLoopbackPlaybackMuted(): Boolean {
162
+ if (mutedSystemAudioTrack != null) return true
163
+ if (muteUnavailable) return false
164
+
165
+ try {
166
+ val adm = webRTCModule.audioDeviceModule ?: return false
167
+ val audioOutputField = adm.javaClass.getField("audioOutput")
168
+ val audioOutput = audioOutputField.get(adm) ?: return false
169
+ val audioTrackField = audioOutput.javaClass.getDeclaredField("audioTrack")
170
+ audioTrackField.isAccessible = true
171
+ val systemAudioTrack = audioTrackField.get(audioOutput) as? android.media.AudioTrack
172
+ if (systemAudioTrack == null) {
173
+ // Expected on initial call from start(); WebRTC's startPlayout hasn't created the AudioTrack yet.
174
+ return false
175
+ }
176
+ systemAudioTrack.setVolume(0f)
177
+ mutedSystemAudioTrack = systemAudioTrack
178
+ return true
179
+ } catch (t: Throwable) {
180
+ muteUnavailable = true
181
+ Log.w(TAG, "speaker mute failed — recording will still work but speaker will play loopback", t)
182
+ return false
183
+ }
184
+ }
185
+
186
+ private fun restoreLoopbackPlaybackMute() {
187
+ muteUnavailable = false
188
+ val track = mutedSystemAudioTrack ?: return
189
+ try {
190
+ track.setVolume(1f)
191
+ } catch (t: Throwable) {
192
+ Log.w(TAG, "restoreLoopbackPlaybackMute failed", t)
193
+ }
194
+ mutedSystemAudioTrack = null
195
+ }
196
+
197
+ private fun onAudioBufferDelivered(
198
+ data: ByteBuffer,
199
+ sampleRate: Int,
200
+ channels: Int,
201
+ frames: Int,
202
+ ) {
203
+ // System.nanoTime() shares the monotonic source WebRTC uses
204
+ // for video frame timestamps, so audio and video PTS line up
205
+ // against the same origin.
206
+ val arrivalNs = System.nanoTime()
207
+ handler.post {
208
+ try {
209
+ handleAudioBufferOnHandler(data, sampleRate, channels, frames, arrivalNs)
210
+ } catch (t: Throwable) {
211
+ Log.e(TAG, "handleAudioBufferOnHandler threw", t)
212
+ }
213
+ }
214
+ }
215
+
216
+ private fun handleAudioBufferOnHandler(
217
+ data: ByteBuffer,
218
+ sr: Int,
219
+ ch: Int,
220
+ frames: Int,
221
+ arrivalNs: Long,
222
+ ) {
223
+ // Retry the speaker mute on every sample arrival until it
224
+ // sticks.The very first delivery is the earliest moment we can be sure
225
+ // WebRTC's `startPlayout()` has created the system AudioTrack
226
+ // — the JADM playback callback wouldn't fire otherwise — so
227
+ // this is the tightest mute window we can achieve from native.
228
+ ensureLoopbackPlaybackMuted()
229
+
230
+ val muxerInstance = host.muxer ?: return
231
+
232
+ // JADM playback always uses 16-bit signed PCM
233
+ // (`WebRtcAudioTrack.BITS_PER_SAMPLE`).
234
+ val bps = 16
235
+ val enc = encoder ?: createEncoder(sr, ch, bps) ?: return
236
+
237
+ // MediaCodec can't re-negotiate format mid-stream; drop
238
+ // mismatched buffers. Rare in practice — WebRTC keeps PCM
239
+ // format constant per render session.
240
+ if (sr != sampleRate || ch != channelCount) {
241
+ buffersDropped++
242
+ if (buffersDropped <= 5 || buffersDropped % 30 == 0) {
243
+ Log.w(
244
+ TAG,
245
+ "dropping audio buffer sr=$sr ch=$ch (configured sr=$sampleRate ch=$channelCount dropped=$buffersDropped)",
246
+ )
247
+ }
248
+ return
249
+ }
250
+
251
+ // First buffer of any kind establishes the shared origin so
252
+ // video and audio PTS share zero.
253
+ val originNs = host.seedOriginNs(arrivalNs)
254
+
255
+ // Wall-clock PTS bounds the file's reported duration to the
256
+ // actual recording window. A sample-counter approach would
257
+ // over-count if JADM delivered a burst (e.g. playback-startup
258
+ // buffer flush), making the MP4 longer than the recording
259
+ // window. Jitter in delivery cadence shows up as a few-ms
260
+ // variance in sample spacing — below audible threshold.
261
+ val ptsUs = ((arrivalNs - originNs) / 1000L).coerceAtLeast(0L)
262
+
263
+ val byteCount = frames * ch * (bps / 8)
264
+ val inputIndex = try {
265
+ enc.dequeueInputBuffer(EncoderConstants.DEQUEUE_TIMEOUT_US)
266
+ } catch (t: Throwable) {
267
+ Log.e(TAG, "audio dequeueInputBuffer threw", t)
268
+ buffersDropped++
269
+ return
270
+ }
271
+ if (inputIndex < 0) {
272
+ buffersDropped++
273
+ return
274
+ }
275
+ val input = enc.getInputBuffer(inputIndex)
276
+ if (input == null) {
277
+ buffersDropped++
278
+ enc.queueInputBuffer(inputIndex, 0, 0, ptsUs, 0)
279
+ return
280
+ }
281
+ input.clear()
282
+ if (input.capacity() < byteCount) {
283
+ Log.w(
284
+ TAG,
285
+ "audio input buffer too small (${input.capacity()} < $byteCount) — dropping",
286
+ )
287
+ enc.queueInputBuffer(inputIndex, 0, 0, ptsUs, 0)
288
+ buffersDropped++
289
+ return
290
+ }
291
+ input.put(data)
292
+ enc.queueInputBuffer(inputIndex, 0, byteCount, ptsUs, 0)
293
+ buffersReceived++
294
+
295
+ drain(enc, muxerInstance, endOfStream = false)
296
+ }
297
+
298
+ private fun createEncoder(sr: Int, ch: Int, bps: Int): MediaCodec? {
299
+ val format = MediaFormat.createAudioFormat(MIME, sr, ch).apply {
300
+ setInteger(
301
+ MediaFormat.KEY_AAC_PROFILE,
302
+ MediaCodecInfo.CodecProfileLevel.AACObjectLC,
303
+ )
304
+ setInteger(MediaFormat.KEY_BIT_RATE, BIT_RATE)
305
+ // 4× the typical 10 ms PCM buffer — generous so the input
306
+ // slot is never tight.
307
+ val pcmBytesPerBuffer = sr * ch * (bps / 8) / 100
308
+ setInteger(MediaFormat.KEY_MAX_INPUT_SIZE, pcmBytesPerBuffer * 4)
309
+ }
310
+ return try {
311
+ val enc = MediaCodec.createEncoderByType(MIME)
312
+ enc.configure(format, null, null, MediaCodec.CONFIGURE_FLAG_ENCODE)
313
+ enc.start()
314
+ encoder = enc
315
+ sampleRate = sr
316
+ channelCount = ch
317
+ bitsPerSample = bps
318
+ // INVARIANT: see the symmetric comment in [VideoPipeline.createEncoder].
319
+ // The host's pending-pipeline counter must remain positive for this
320
+ // pipeline until `muxer.addTrack(audio)` has been called or the
321
+ // muxer would start without the audio track.
322
+ enc
323
+ } catch (t: Throwable) {
324
+ Log.e(TAG, "failed to create AAC encoder", t)
325
+ host.onFatalError(t)
326
+ null
327
+ }
328
+ }
329
+
330
+ /**
331
+ * Queues a zero-length input buffer with `BUFFER_FLAG_END_OF_STREAM`.
332
+ * Drains output between input attempts so the encoder's input
333
+ * slots can free up — a polling-only loop deadlocks when output
334
+ * queues are full. Returns `true` only if the marker was
335
+ * successfully queued; the caller skips the EOS drain otherwise so
336
+ * the handler doesn't hang waiting for a marker that won't arrive.
337
+ */
338
+ private fun signalEoS(enc: MediaCodec, muxerInstance: MediaMuxer): Boolean {
339
+ repeat(EncoderConstants.EOS_INPUT_RETRIES) {
340
+ val idx = try {
341
+ enc.dequeueInputBuffer(EncoderConstants.DEQUEUE_TIMEOUT_US)
342
+ } catch (t: Throwable) {
343
+ Log.w(TAG, "dequeueInputBuffer during EOS threw", t)
344
+ return false
345
+ }
346
+ if (idx >= 0) {
347
+ enc.queueInputBuffer(
348
+ idx, 0, 0, 0L,
349
+ MediaCodec.BUFFER_FLAG_END_OF_STREAM,
350
+ )
351
+ return true
352
+ }
353
+ // Keep output flowing so input slots can be released.
354
+ try {
355
+ drain(enc, muxerInstance, endOfStream = false)
356
+ } catch (t: Throwable) {
357
+ Log.w(TAG, "drainEncoder during EOS retry threw", t)
358
+ }
359
+ }
360
+ Log.w(TAG, "could not queue EOS — skipping EOS drain to avoid hang")
361
+ return false
362
+ }
363
+
364
+ private fun drain(
365
+ enc: MediaCodec,
366
+ muxerInstance: MediaMuxer,
367
+ endOfStream: Boolean,
368
+ ) {
369
+ val info = MediaCodec.BufferInfo()
370
+ val timeoutUs =
371
+ if (endOfStream) EncoderConstants.DEQUEUE_TIMEOUT_US_EOS else 0L
372
+ val deadlineMs = System.currentTimeMillis() +
373
+ if (endOfStream) EncoderConstants.EOS_DRAIN_BUDGET_MS else Long.MAX_VALUE
374
+
375
+ while (true) {
376
+ val outIndex = try {
377
+ enc.dequeueOutputBuffer(info, timeoutUs)
378
+ } catch (t: Throwable) {
379
+ Log.e(TAG, "dequeueOutputBuffer threw", t)
380
+ return
381
+ }
382
+
383
+ when {
384
+ outIndex == MediaCodec.INFO_TRY_AGAIN_LATER -> {
385
+ if (!endOfStream) return
386
+ if (System.currentTimeMillis() >= deadlineMs) {
387
+ Log.w(
388
+ TAG,
389
+ "EOS drain timed out without seeing BUFFER_FLAG_END_OF_STREAM — bailing out",
390
+ )
391
+ return
392
+ }
393
+ // During EOS keep polling until the marker arrives.
394
+ }
395
+
396
+ outIndex == MediaCodec.INFO_OUTPUT_FORMAT_CHANGED -> {
397
+ if (formatLocked) {
398
+ Log.w(TAG, "output format changed twice — ignoring")
399
+ continue
400
+ }
401
+ val newIndex = muxerInstance.addTrack(enc.outputFormat)
402
+ trackIndex = newIndex
403
+ formatLocked = true
404
+ host.onTrackAdded()
405
+ }
406
+
407
+ outIndex >= 0 -> {
408
+ val out = enc.getOutputBuffer(outIndex)
409
+ if (out != null && info.size > 0 && host.muxerStarted &&
410
+ trackIndex >= 0 &&
411
+ info.flags and MediaCodec.BUFFER_FLAG_CODEC_CONFIG == 0
412
+ ) {
413
+ out.position(info.offset)
414
+ out.limit(info.offset + info.size)
415
+ try {
416
+ muxerInstance.writeSampleData(trackIndex, out, info)
417
+ samplesAppended++
418
+ if (firstSamplePtsUs < 0 || info.presentationTimeUs < firstSamplePtsUs) {
419
+ firstSamplePtsUs = info.presentationTimeUs
420
+ }
421
+ if (info.presentationTimeUs > lastSamplePtsUs) {
422
+ lastSamplePtsUs = info.presentationTimeUs
423
+ }
424
+ } catch (t: Throwable) {
425
+ Log.e(TAG, "writeSampleData failed", t)
426
+ }
427
+ }
428
+ enc.releaseOutputBuffer(outIndex, false)
429
+ if (info.flags and MediaCodec.BUFFER_FLAG_END_OF_STREAM != 0) return
430
+ }
431
+ }
432
+
433
+ if (!endOfStream && outIndex < 0) return
434
+ }
435
+ }
436
+ }
@@ -0,0 +1,17 @@
1
+ package com.streamvideo.reactnative.recorder
2
+
3
+ internal object EncoderConstants {
4
+ /** Non-blocking dequeue timeout used during normal frame/buffer feed. */
5
+ const val DEQUEUE_TIMEOUT_US: Long = 10_000L
6
+
7
+ /** Slower poll used while waiting for the EOS marker to surface. */
8
+ const val DEQUEUE_TIMEOUT_US_EOS: Long = 100_000L
9
+
10
+ /** Max attempts to queue an EOS input buffer before giving up. */
11
+ const val EOS_INPUT_RETRIES: Int = 50
12
+
13
+ /** Wall-clock cap on the EOS-mode drain so a missed EOS can't hang the handler. */
14
+ const val EOS_DRAIN_BUDGET_MS: Long = 2_000L
15
+ }
16
+
17
+ class RecordingError(message: String) : RuntimeException(message)
@@ -0,0 +1,36 @@
1
+ package com.streamvideo.reactnative.recorder
2
+
3
+ import android.media.MediaMuxer
4
+ import android.os.Handler
5
+
6
+ /**
7
+ * Internal coordination contract between [TracksRecorderManager] and audio/video pipelines.
8
+ * The pipelines own their encoder + sink + drain logic; the host owns lifecycle, the muxer,
9
+ * the muxer-start gate, the shared time origin, and the terminal-completion barrier.
10
+ *
11
+ * Every method on this interface is called from the host's handler thread
12
+ * — pipelines must post to [handler] before calling back into the host.
13
+ */
14
+ internal interface PipelineHost {
15
+ /** The recorder's serial handler thread. */
16
+ val handler: Handler
17
+
18
+ val muxer: MediaMuxer?
19
+
20
+ val muxerStarted: Boolean
21
+
22
+ /**
23
+ * Returns the recording's shared time origin in nanoseconds. The
24
+ * first pipeline to deliver a sample seeds the origin with its
25
+ * timestamp; subsequent calls return the established value.
26
+ */
27
+ fun seedOriginNs(timestampNs: Long): Long
28
+
29
+ /**
30
+ * Pipeline has added a track to the muxer. The host
31
+ * muxer once all expected pipelines have reported their track.
32
+ */
33
+ fun onTrackAdded()
34
+
35
+ fun onFatalError(error: Throwable)
36
+ }
@@ -0,0 +1,60 @@
1
+ package com.streamvideo.reactnative.recorder
2
+
3
+ import android.util.Log
4
+ import org.webrtc.audio.JavaAudioDeviceModule
5
+ import java.nio.ByteBuffer
6
+ import java.nio.ByteOrder
7
+
8
+ /**
9
+ * Render-side playback sink for [TracksRecorderManager]. Registered
10
+ * via `WebRTCModuleOptions.addPlaybackSamplesObserver` for the
11
+ * duration of a recording. Fires on the JADM audio thread, downstream
12
+ * of any audio processing factory — works regardless of whether a
13
+ * custom APM, noise cancellation, or the default is configured.
14
+ *
15
+ * `AudioSamples` carries 16-bit signed little-endian PCM in `data`.
16
+ * Wrapped in a fresh direct ByteBuffer so the manager can write it
17
+ * straight into a MediaCodec input slot without a second copy.
18
+ */
19
+ internal class RecorderPlaybackSamplesSink(
20
+ private val handler: (
21
+ data: ByteBuffer,
22
+ sampleRate: Int,
23
+ channels: Int,
24
+ frames: Int,
25
+ ) -> Unit,
26
+ ) : JavaAudioDeviceModule.PlaybackSamplesReadyCallback {
27
+
28
+ companion object {
29
+ private const val TAG = "TracksRecorder.PbSink"
30
+ }
31
+
32
+ @Volatile
33
+ private var _callCount: Int = 0
34
+ val callCount: Int get() = _callCount
35
+
36
+ override fun onWebRtcAudioTrackSamplesReady(samples: JavaAudioDeviceModule.AudioSamples) {
37
+ _callCount++
38
+
39
+ val data = samples.data
40
+ val sampleRate = samples.sampleRate
41
+ val channels = samples.channelCount
42
+ val byteCount = data.size
43
+ if (byteCount <= 0 || sampleRate <= 0 || channels <= 0) return
44
+
45
+ // 16-bit PCM (`WebRtcAudioTrack.BITS_PER_SAMPLE`); `AudioSamples`
46
+ // doesn't expose a direct frames field.
47
+ val frames = byteCount / (channels * 2)
48
+
49
+ val copy = ByteBuffer.allocateDirect(byteCount).order(ByteOrder.LITTLE_ENDIAN)
50
+ copy.put(data)
51
+ copy.position(0)
52
+ copy.limit(byteCount)
53
+
54
+ try {
55
+ handler(copy, sampleRate, channels, frames)
56
+ } catch (t: Throwable) {
57
+ Log.e(TAG, "playback samples handler threw", t)
58
+ }
59
+ }
60
+ }
@@ -0,0 +1,31 @@
1
+ package com.streamvideo.reactnative.recorder
2
+
3
+ import org.webrtc.VideoFrame
4
+ import org.webrtc.VideoSink
5
+
6
+ /**
7
+ * Per-track [VideoSink] for [TracksRecorderManager]. Stays dumb —
8
+ * retains each incoming frame and forwards it; the manager handles
9
+ * queue hops, I420 → NV12 conversion, and the encoder feed.
10
+ *
11
+ * `onFrame` is invoked from a WebRTC delivery thread. **Never** call
12
+ * `removeSink` from inside `onFrame` — Android WebRTC has a known
13
+ * deadlock there. The manager removes from its handler thread.
14
+ */
15
+ internal class RecorderVideoSink(
16
+ private val handler: (VideoFrame) -> Unit,
17
+ ) : VideoSink {
18
+
19
+ override fun onFrame(frame: VideoFrame) {
20
+ // Retain so the buffer outlives this delivery-thread call.
21
+ // The manager releases after the encoder consumes it.
22
+ frame.retain()
23
+ try {
24
+ handler.invoke(frame)
25
+ } catch (t: Throwable) {
26
+ // Balance the retain when the manager rejects the frame.
27
+ frame.release()
28
+ throw t
29
+ }
30
+ }
31
+ }