Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
34 commits
Select commit Hold shift + click to select a range
d68133f
feat: add separate audio file
SamuelBarnholdt Dec 6, 2025
61e43d1
Merge pull request #1 from SamuelBarnholdt/add-audio-recording
SamuelBarnholdt Dec 6, 2025
56033c8
chore: bump version
SamuelBarnholdt Dec 6, 2025
c0a4717
chore: add entire build
SamuelBarnholdt Dec 6, 2025
2334bec
chore: nitrogen
SamuelBarnholdt Dec 6, 2025
83f8d32
chore: bump
SamuelBarnholdt Dec 6, 2025
0978500
feat: decouple app audio
SamuelBarnholdt Dec 9, 2025
dfc9666
chore: bump
SamuelBarnholdt Dec 9, 2025
b87152d
fix: nitrogen binding
SamuelBarnholdt Dec 9, 2025
42643d0
fix: patched enitlements
SamuelBarnholdt Dec 9, 2025
40090d5
feat: make startglobalrecording a promise
SamuelBarnholdt Dec 9, 2025
e27aa80
chore: run prepare
SamuelBarnholdt Dec 9, 2025
a7247ef
chore: bump
SamuelBarnholdt Dec 9, 2025
561c560
fix: mmaybe didnt work
SamuelBarnholdt Dec 9, 2025
7e72ed8
fix: revert
SamuelBarnholdt Dec 9, 2025
d23dbc3
feat: add live chunking API for global recordings
SamuelBarnholdt Jan 6, 2026
33ff077
chore: update api
SamuelBarnholdt Jan 6, 2026
3cbcbd3
fix: simplify and fix bugs
SamuelBarnholdt Jan 6, 2026
93da38d
feat: add chunk support for android
SamuelBarnholdt Jan 9, 2026
8cce391
Merge pull request #2 from SamuelBarnholdt/add-android-support
SamuelBarnholdt Jan 9, 2026
4b8d46a
fix: isRecording bug
SamuelBarnholdt Jan 9, 2026
391ef1f
feat: android improvements
SamuelBarnholdt Jan 12, 2026
4103dc5
fix: audiofile in chunked androdi
SamuelBarnholdt Jan 12, 2026
588809d
fix: add audio padding
SamuelBarnholdt Jan 16, 2026
9cbb04c
fix: safe cleanup
SamuelBarnholdt Jan 16, 2026
0f68e22
fix: add AVAudioSession config
SamuelBarnholdt Jan 25, 2026
666c98a
fix: bitrate issues
SamuelBarnholdt Jan 26, 2026
10a829e
fix: stopping point
SamuelBarnholdt Jan 26, 2026
6a1d877
fix: better reliability
SamuelBarnholdt Jan 26, 2026
c2e94b2
fix: reliablity
SamuelBarnholdt Jan 29, 2026
74e5921
test
SamuelBarnholdt Feb 3, 2026
956277e
fix
SamuelBarnholdt Feb 3, 2026
ece202a
fix chunk id
SamuelBarnholdt Feb 3, 2026
5ce3917
improvements
SamuelBarnholdt Feb 3, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 0 additions & 4 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -77,12 +77,8 @@ android/keystores/debug.keystore
# Turborepo
.turbo/

# generated by bob
lib/

# React Native Codegen
ios/generated
android/generated

# React Native Nitro Modules
nitrogen/
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ class NitroScreenRecorder : HybridNitroScreenRecorderSpec() {
private var globalRecordingService: ScreenRecordingService? = null
private var isServiceBound = false
private var lastGlobalRecording: File? = null
private var lastGlobalAudioRecording: File? = null
private var globalRecordingErrorCallback: ((RecordingError) -> Unit)? = null

private val screenRecordingListeners =
Expand Down Expand Up @@ -64,12 +65,14 @@ class NitroScreenRecorder : HybridNitroScreenRecorderSpec() {

fun notifyGlobalRecordingFinished(
file: File,
audioFile: File?,
event: ScreenRecordingEvent,
enabledMic: Boolean
) {
Log.d(TAG, "🏁 notifyGlobalRecordingFinished called with file: ${file.absolutePath}")
Log.d(TAG, "🏁 notifyGlobalRecordingFinished called with file: ${file.absolutePath}, audioFile: ${audioFile?.absolutePath}")
instance?.let { recorder ->
recorder.lastGlobalRecording = file
recorder.lastGlobalAudioRecording = audioFile
recorder.notifyListeners(event)
}
}
Expand All @@ -89,11 +92,52 @@ class NitroScreenRecorder : HybridNitroScreenRecorderSpec() {
mediaProjectionManager =
ctx.getSystemService(Context.MEDIA_PROJECTION_SERVICE) as MediaProjectionManager
instance = this

// Try to rebind to existing service if it's running (handles hot reload case)
if (isServiceRunning(ctx)) {
Log.d(TAG, "🔄 Service is running, attempting to rebind...")
rebindToExistingService(ctx)
}

Log.d(TAG, "✅ NitroScreenRecorder initialization complete")
} ?: run {
Log.e(TAG, "❌ NitroScreenRecorder: applicationContext was null")
}
}

/**
* Check if the ScreenRecordingService is currently running.
* This works even if we're not bound to the service.
*/
@Suppress("DEPRECATION")
private fun isServiceRunning(context: Context): Boolean {
val manager = context.getSystemService(Context.ACTIVITY_SERVICE) as android.app.ActivityManager
for (service in manager.getRunningServices(Int.MAX_VALUE)) {
if (ScreenRecordingService::class.java.name == service.service.className) {
return true
}
}
return false
}

/**
* Attempt to rebind to an existing ScreenRecordingService.
* Called on init to handle hot reload scenarios.
*/
private fun rebindToExistingService(context: Context) {
if (isServiceBound) {
Log.d(TAG, "Already bound to service")
return
}

val intent = Intent(context, ScreenRecordingService::class.java)
try {
context.bindService(intent, serviceConnection, Context.BIND_AUTO_CREATE)
Log.d(TAG, "🔗 Rebind to existing service initiated")
} catch (e: Exception) {
Log.e(TAG, "❌ Failed to rebind to service: ${e.message}")
}
}

private fun notifyListeners(event: ScreenRecordingEvent) {
Log.d(
Expand Down Expand Up @@ -275,6 +319,7 @@ class NitroScreenRecorder : HybridNitroScreenRecorderSpec() {
enableCamera: Boolean,
cameraPreviewStyle: RecorderCameraStyle,
cameraDevice: CameraDevice,
separateAudioFile: Boolean,
onRecordingFinished: (ScreenRecordingFile) -> Unit
) {
// no-op
Expand All @@ -297,7 +342,7 @@ class NitroScreenRecorder : HybridNitroScreenRecorderSpec() {

// --- Global Recording Methods ---

override fun startGlobalRecording(enableMic: Boolean, onRecordingError: (RecordingError) -> Unit) {
override fun startGlobalRecording(enableMic: Boolean, separateAudioFile: Boolean, onRecordingError: (RecordingError) -> Unit) {
if (globalRecordingService?.isCurrentlyRecording() == true) {
Log.w(TAG, "⚠️ Global recording already in progress")
return
Expand All @@ -317,7 +362,8 @@ class NitroScreenRecorder : HybridNitroScreenRecorderSpec() {
action = ScreenRecordingService.ACTION_START_RECORDING
putExtra(ScreenRecordingService.EXTRA_RESULT_CODE, resultCode)
putExtra(ScreenRecordingService.EXTRA_RESULT_DATA, resultData)
putExtra(ScreenRecordingService.EXTRA_ENABLE_MIC, enableMic) // Use the parameter instead of hardcoded true
putExtra(ScreenRecordingService.EXTRA_ENABLE_MIC, enableMic)
putExtra(ScreenRecordingService.EXTRA_SEPARATE_AUDIO, separateAudioFile)
}

if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
Expand All @@ -336,38 +382,81 @@ class NitroScreenRecorder : HybridNitroScreenRecorderSpec() {

override fun stopGlobalRecording(settledTimeMs: Double): Promise<ScreenRecordingFile?> {
return Promise.async {
val ctx = NitroModules.applicationContext ?: return@async null

if (globalRecordingService?.isCurrentlyRecording() != true) {
Log.w(TAG, "No active recording to stop")
return@async null
}
try {
val ctx = NitroModules.applicationContext
if (ctx == null) {
Log.w(TAG, "No application context")
return@async null
}

val stopIntent = Intent(ctx, ScreenRecordingService::class.java).apply {
action = ScreenRecordingService.ACTION_STOP_RECORDING
}
ctx.startService(stopIntent)
// Check if we have an active session (MediaProjection exists)
val service = globalRecordingService
val hasActiveSession = service?.hasActiveSession() == true
val serviceRunning = isServiceRunning(ctx)

if (!hasActiveSession && !serviceRunning) {
Log.w(TAG, "No active recording session to stop")
return@async null
}

// If service is running but we're not bound, we still send the stop intent
// This handles hot reload scenarios where the service is orphaned
if (serviceRunning) {
Log.d(TAG, "🛑 Stopping recording service (bound: $isServiceBound, hasSession: $hasActiveSession)")

val stopIntent = Intent(ctx, ScreenRecordingService::class.java).apply {
action = ScreenRecordingService.ACTION_STOP_RECORDING
}
ctx.startService(stopIntent)
}

if (isServiceBound) {
ctx.unbindService(serviceConnection)
isServiceBound = false
}
if (isServiceBound) {
try {
ctx.unbindService(serviceConnection)
} catch (e: Exception) {
Log.w(TAG, "Service already unbound: ${e.message}")
}
isServiceBound = false
}

globalRecordingService = null

delay(settledTimeMs.toLong())
delay(settledTimeMs.toLong())

return@async retrieveLastGlobalRecording()
return@async retrieveLastGlobalRecording()
} catch (e: Exception) {
Log.e(TAG, "Error stopping global recording: ${e.message}")
e.printStackTrace()
return@async null
}
}
}

override fun retrieveLastGlobalRecording(): ScreenRecordingFile? {
return lastGlobalRecording?.let { file ->
if (file.exists()) {
// Build audio file info if available
val audioFile = lastGlobalAudioRecording?.let { audioFile ->
if (audioFile.exists()) {
AudioRecordingFile(
path = "file://${audioFile.absolutePath}",
name = audioFile.name,
size = audioFile.length().toDouble(),
duration = RecorderUtils.getAudioDuration(audioFile)
)
} else {
null
}
}

ScreenRecordingFile(
path = "file://${file.absolutePath}",
name = file.name,
size = file.length().toDouble(),
duration = RecorderUtils.getVideoDuration(file),
enabledMicrophone = true // Assume true for global recordings
enabledMicrophone = true, // Assume true for global recordings
audioFile = audioFile,
appAudioFile = null // App audio capture not supported on Android
)
} else {
null
Expand All @@ -381,5 +470,117 @@ class NitroScreenRecorder : HybridNitroScreenRecorderSpec() {
val globalDir = File(ctx.filesDir, "recordings")
RecorderUtils.clearDirectory(globalDir)
lastGlobalRecording = null
lastGlobalAudioRecording = null
}

// --- Chunking ---

override fun markChunkStart(chunkId: String?): Promise<Double> {
return Promise.async {
Log.d(TAG, "📍 markChunkStart called with chunkId=$chunkId")
val startTime = System.currentTimeMillis()
globalRecordingService?.markChunkStart() ?: run {
Log.w(TAG, "⚠️ markChunkStart: Service not bound")
}
val elapsedMs = (System.currentTimeMillis() - startTime).toDouble()
return@async elapsedMs
}
}

override fun finalizeChunk(chunkId: String?, settledTimeMs: Double): Promise<ScreenRecordingFile?> {
return Promise.async {
Log.d(TAG, "📦 finalizeChunk called with chunkId=$chunkId, settledTimeMs=$settledTimeMs")

val service = globalRecordingService
if (service == null) {
Log.w(TAG, "⚠️ finalizeChunk: Service not bound")
return@async null
}

val chunkFile = service.finalizeChunk()

if (chunkFile == null) {
Log.w(TAG, "⚠️ finalizeChunk: No chunk file returned")
return@async null
}

// Wait for file to settle
delay(settledTimeMs.toLong())

// Store as last recording for retrieval
lastGlobalRecording = chunkFile

// Get audio file if extracted
val audioFile = service.getLastAudioFile()
lastGlobalAudioRecording = audioFile

// Build audio file info if available
val audioFileInfo = audioFile?.let { af ->
if (af.exists()) {
AudioRecordingFile(
path = "file://${af.absolutePath}",
name = af.name,
size = af.length().toDouble(),
duration = RecorderUtils.getAudioDuration(af)
)
} else {
null
}
}

// Return the chunk file with audio if available
return@async if (chunkFile.exists()) {
ScreenRecordingFile(
path = "file://${chunkFile.absolutePath}",
name = chunkFile.name,
size = chunkFile.length().toDouble(),
duration = RecorderUtils.getVideoDuration(chunkFile),
enabledMicrophone = service.isMicrophoneEnabled(),
audioFile = audioFileInfo,
appAudioFile = null
)
} else {
null
}
}
}

// --- Extension Status ---

override fun getExtensionStatus(): RawExtensionStatus {
val service = globalRecordingService
return RawExtensionStatus(
isMicrophoneEnabled = service?.isMicrophoneEnabled() ?: false,
isCapturingChunk = service?.isCapturingChunk() ?: false,
chunkStartedAt = service?.getChunkStartedAt() ?: 0.0,
captureMode = service?.getCaptureMode() ?: CaptureMode.UNKNOWN
)
}

override fun isScreenBeingRecorded(): Boolean {
val service = globalRecordingService
val hasSession = service?.hasActiveSession() == true
val isRecording = service?.isCurrentlyRecording() == true
val ctx = NitroModules.applicationContext
val serviceRunning = if (ctx != null) isServiceRunning(ctx) else false

// Log for debugging
Log.d(TAG, "📊 isScreenBeingRecorded: hasSession=$hasSession, isRecording=$isRecording, serviceRunning=$serviceRunning, isBound=$isServiceBound")

// Return true if we have an active MediaProjection session (even if paused between chunks)
if (hasSession) {
return true
}

// Fallback: check if the service is running even if we're not bound
if (ctx == null) return false

// If service is running but we're not bound, try to rebind
if (serviceRunning && !isServiceBound) {
Log.d(TAG, "📡 Service running but not bound, attempting rebind...")
rebindToExistingService(ctx)
}

return serviceRunning
}
}
Loading