@elizaos/capacitor-screencapture 1.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.
@@ -0,0 +1,777 @@
1
+ package ai.eliza.plugins.screencapture
2
+
3
+ import android.Manifest
4
+ import android.app.Activity
5
+ import android.app.Notification
6
+ import android.app.NotificationChannel
7
+ import android.app.NotificationManager
8
+ import android.content.Context
9
+ import android.content.Intent
10
+ import android.graphics.Bitmap
11
+ import android.graphics.PixelFormat
12
+ import android.hardware.display.DisplayManager
13
+ import android.hardware.display.VirtualDisplay
14
+ import android.media.Image
15
+ import android.media.ImageReader
16
+ import android.media.MediaRecorder
17
+ import android.media.projection.MediaProjection
18
+ import android.media.projection.MediaProjectionManager
19
+ import android.os.Build
20
+ import android.os.Handler
21
+ import android.os.Looper
22
+ import android.util.Base64
23
+ import android.util.DisplayMetrics
24
+ import android.util.Log
25
+ import android.view.WindowManager
26
+ import androidx.activity.result.ActivityResult
27
+ import com.getcapacitor.JSArray
28
+ import com.getcapacitor.JSObject
29
+ import com.getcapacitor.Plugin
30
+ import com.getcapacitor.PluginCall
31
+ import com.getcapacitor.PluginMethod
32
+ import com.getcapacitor.annotation.ActivityCallback
33
+ import com.getcapacitor.annotation.CapacitorPlugin
34
+ import com.getcapacitor.annotation.Permission
35
+ import com.getcapacitor.annotation.PermissionCallback
36
+ import kotlinx.coroutines.*
37
+ import java.io.ByteArrayOutputStream
38
+ import java.io.File
39
+ import java.text.SimpleDateFormat
40
+ import java.util.*
41
+
42
+ @CapacitorPlugin(
43
+ name = "ScreenCapture",
44
+ permissions = [
45
+ Permission(alias = "microphone", strings = [Manifest.permission.RECORD_AUDIO])
46
+ ]
47
+ )
48
+ class ScreenCapturePlugin : Plugin() {
49
+ companion object {
50
+ private const val TAG = "ScreenCapture"
51
+ private const val NOTIFICATION_CHANNEL_ID = "screen_capture_channel"
52
+ private const val NOTIFICATION_ID = 9001
53
+ }
54
+
55
+ private var mediaProjectionManager: MediaProjectionManager? = null
56
+ private var mediaProjection: MediaProjection? = null
57
+ private var virtualDisplay: VirtualDisplay? = null
58
+ private var mediaRecorder: MediaRecorder? = null
59
+ private var imageReader: ImageReader? = null
60
+
61
+ // Recording state
62
+ private var isRecording = false
63
+ private var isPaused = false
64
+ private var recordingStartTime = 0L
65
+ private var pausedDurationMs = 0L
66
+ private var pauseStartTime = 0L
67
+ private var outputFile: File? = null
68
+ private var recordingTimer: Handler? = null
69
+ private var recordingRunnable: Runnable? = null
70
+ private var maxDurationMs: Long? = null
71
+ private var maxFileSize: Long? = null
72
+
73
+ // Pending permission flow
74
+ private var pendingCall: PluginCall? = null
75
+ private var pendingAction: String? = null
76
+ private var pendingRecordingOptions: RecordingConfig? = null
77
+
78
+ private val scope = CoroutineScope(Dispatchers.Main + SupervisorJob())
79
+ private var screenDensity = 0
80
+ private var screenWidth = 0
81
+ private var screenHeight = 0
82
+
83
+ // ── Lifecycle ────────────────────────────────────────────────────────
84
+
85
+ override fun load() {
86
+ super.load()
87
+ mediaProjectionManager =
88
+ context.getSystemService(Context.MEDIA_PROJECTION_SERVICE) as MediaProjectionManager
89
+
90
+ updateScreenMetrics()
91
+ createNotificationChannel()
92
+ }
93
+
94
+ private fun updateScreenMetrics() {
95
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
96
+ val windowManager = context.getSystemService(Context.WINDOW_SERVICE) as WindowManager
97
+ val metrics = windowManager.currentWindowMetrics
98
+ val bounds = metrics.bounds
99
+ screenWidth = bounds.width()
100
+ screenHeight = bounds.height()
101
+ val config = context.resources.configuration
102
+ screenDensity = config.densityDpi
103
+ } else {
104
+ val windowManager = context.getSystemService(Context.WINDOW_SERVICE) as WindowManager
105
+ val metrics = DisplayMetrics()
106
+ @Suppress("DEPRECATION")
107
+ windowManager.defaultDisplay.getMetrics(metrics)
108
+ screenDensity = metrics.densityDpi
109
+ screenWidth = metrics.widthPixels
110
+ screenHeight = metrics.heightPixels
111
+ }
112
+ }
113
+
114
+ /**
115
+ * Notification channel required for the foreground service on Android 14+.
116
+ */
117
+ private fun createNotificationChannel() {
118
+ val nm = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
119
+ if (nm.getNotificationChannel(NOTIFICATION_CHANNEL_ID) != null) return
120
+ val channel = NotificationChannel(
121
+ NOTIFICATION_CHANNEL_ID,
122
+ "Screen Capture",
123
+ NotificationManager.IMPORTANCE_LOW
124
+ ).apply {
125
+ description = "Used while capturing or recording the screen"
126
+ }
127
+ nm.createNotificationChannel(channel)
128
+ }
129
+
130
+ // ── Plugin methods ──────────────────────────────────────────────────
131
+
132
+ @PluginMethod
133
+ fun isSupported(call: PluginCall) {
134
+ val features = JSArray()
135
+ features.put("screenshot")
136
+ features.put("recording")
137
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
138
+ features.put("system_audio")
139
+ }
140
+ features.put("microphone")
141
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
142
+ features.put("pause_resume")
143
+ }
144
+
145
+ call.resolve(JSObject().apply {
146
+ put("supported", true)
147
+ put("features", features)
148
+ })
149
+ }
150
+
151
+ @PluginMethod
152
+ fun captureScreenshot(call: PluginCall) {
153
+ pendingCall = call
154
+ pendingAction = "screenshot"
155
+
156
+ val intent = mediaProjectionManager?.createScreenCaptureIntent()
157
+ if (intent != null) {
158
+ startActivityForResult(call, intent, "handleProjectionResult")
159
+ } else {
160
+ call.reject("Screen capture not available")
161
+ }
162
+ }
163
+
164
+ @PluginMethod
165
+ fun startRecording(call: PluginCall) {
166
+ if (isRecording) {
167
+ call.reject("Recording already in progress")
168
+ return
169
+ }
170
+
171
+ // Parse recording options
172
+ val config = parseRecordingConfig(call)
173
+ pendingRecordingOptions = config
174
+
175
+ // Check mic permission if microphone capture requested
176
+ if (config.captureMicrophone &&
177
+ getPermissionState("microphone") != com.getcapacitor.PermissionState.GRANTED
178
+ ) {
179
+ pendingCall = call
180
+ pendingAction = "recording"
181
+ requestPermissionForAlias("microphone", call, "handleMicPermissionResult")
182
+ return
183
+ }
184
+
185
+ pendingCall = call
186
+ pendingAction = "recording"
187
+
188
+ val intent = mediaProjectionManager?.createScreenCaptureIntent()
189
+ if (intent != null) {
190
+ startActivityForResult(call, intent, "handleProjectionResult")
191
+ } else {
192
+ call.reject("Screen capture not available")
193
+ }
194
+ }
195
+
196
+ @PluginMethod
197
+ fun stopRecording(call: PluginCall) {
198
+ if (!isRecording) {
199
+ call.reject("Not recording")
200
+ return
201
+ }
202
+
203
+ scope.launch {
204
+ val result = stopRecordingInternal()
205
+ if (result != null) {
206
+ call.resolve(result)
207
+ } else {
208
+ call.reject("Failed to stop recording")
209
+ }
210
+ }
211
+ }
212
+
213
+ @PluginMethod
214
+ fun pauseRecording(call: PluginCall) {
215
+ if (!isRecording) {
216
+ call.reject("Not recording")
217
+ return
218
+ }
219
+ if (isPaused) {
220
+ call.reject("Already paused")
221
+ return
222
+ }
223
+
224
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
225
+ try {
226
+ mediaRecorder?.pause()
227
+ isPaused = true
228
+ pauseStartTime = System.currentTimeMillis()
229
+
230
+ notifyListeners("recordingState", JSObject().apply {
231
+ put("isRecording", true)
232
+ put("isPaused", true)
233
+ put("duration", getRecordingDuration())
234
+ put("fileSize", outputFile?.length() ?: 0)
235
+ })
236
+ call.resolve()
237
+ } catch (e: Exception) {
238
+ Log.e(TAG, "Failed to pause recording", e)
239
+ notifyError("pause_failed", "Failed to pause: ${e.message}")
240
+ call.reject("Failed to pause recording: ${e.message}")
241
+ }
242
+ } else {
243
+ call.reject("Pause is not supported on this Android version (requires API 24+)")
244
+ }
245
+ }
246
+
247
+ @PluginMethod
248
+ fun resumeRecording(call: PluginCall) {
249
+ if (!isRecording) {
250
+ call.reject("Not recording")
251
+ return
252
+ }
253
+ if (!isPaused) {
254
+ call.reject("Not paused")
255
+ return
256
+ }
257
+
258
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
259
+ try {
260
+ mediaRecorder?.resume()
261
+ // Track paused duration for accurate timing
262
+ if (pauseStartTime > 0) {
263
+ pausedDurationMs += System.currentTimeMillis() - pauseStartTime
264
+ pauseStartTime = 0
265
+ }
266
+ isPaused = false
267
+
268
+ notifyListeners("recordingState", JSObject().apply {
269
+ put("isRecording", true)
270
+ put("isPaused", false)
271
+ put("duration", getRecordingDuration())
272
+ put("fileSize", outputFile?.length() ?: 0)
273
+ })
274
+ call.resolve()
275
+ } catch (e: Exception) {
276
+ Log.e(TAG, "Failed to resume recording", e)
277
+ notifyError("resume_failed", "Failed to resume: ${e.message}")
278
+ call.reject("Failed to resume recording: ${e.message}")
279
+ }
280
+ } else {
281
+ call.reject("Resume is not supported on this Android version (requires API 24+)")
282
+ }
283
+ }
284
+
285
+ @PluginMethod
286
+ fun getRecordingState(call: PluginCall) {
287
+ val duration = getRecordingDuration()
288
+ val fileSize = outputFile?.length() ?: 0
289
+
290
+ call.resolve(JSObject().apply {
291
+ put("isRecording", isRecording)
292
+ put("isPaused", isPaused)
293
+ put("duration", duration)
294
+ put("fileSize", fileSize)
295
+ })
296
+ }
297
+
298
+ @PluginMethod
299
+ override fun checkPermissions(call: PluginCall) {
300
+ val micStatus = getPermissionState("microphone")
301
+ call.resolve(JSObject().apply {
302
+ put("screenCapture", "prompt") // Always prompt for MediaProjection
303
+ put("microphone", permissionString(micStatus))
304
+ })
305
+ }
306
+
307
+ @PluginMethod
308
+ override fun requestPermissions(call: PluginCall) {
309
+ requestPermissionForAlias("microphone", call, "handlePermissionsResult")
310
+ }
311
+
312
+ // ── Permission callbacks ────────────────────────────────────────────
313
+
314
+ @PermissionCallback
315
+ private fun handleMicPermissionResult(call: PluginCall) {
316
+ val intent = mediaProjectionManager?.createScreenCaptureIntent()
317
+ if (intent != null) {
318
+ startActivityForResult(call, intent, "handleProjectionResult")
319
+ } else {
320
+ call.reject("Screen capture not available")
321
+ }
322
+ }
323
+
324
+ @PermissionCallback
325
+ private fun handlePermissionsResult(call: PluginCall) {
326
+ val micStatus = getPermissionState("microphone")
327
+ call.resolve(JSObject().apply {
328
+ put("screenCapture", "prompt")
329
+ put("microphone", permissionString(micStatus))
330
+ })
331
+ }
332
+
333
+ // ── Activity result (MediaProjection permission) ────────────────────
334
+
335
+ @ActivityCallback
336
+ private fun handleProjectionResult(call: PluginCall, result: ActivityResult) {
337
+ if (result.resultCode != Activity.RESULT_OK || result.data == null) {
338
+ call.reject("Screen capture permission denied")
339
+ return
340
+ }
341
+
342
+ mediaProjection = mediaProjectionManager?.getMediaProjection(result.resultCode, result.data!!)
343
+
344
+ if (mediaProjection == null) {
345
+ call.reject("Failed to get media projection")
346
+ return
347
+ }
348
+
349
+ // Register stop callback for cleanup
350
+ mediaProjection?.registerCallback(object : MediaProjection.Callback() {
351
+ override fun onStop() {
352
+ Log.d(TAG, "MediaProjection stopped by system")
353
+ if (isRecording) {
354
+ scope.launch { stopRecordingInternal() }
355
+ }
356
+ }
357
+ }, Handler(Looper.getMainLooper()))
358
+
359
+ when (pendingAction) {
360
+ "screenshot" -> captureScreenshotInternal(call)
361
+ "recording" -> startRecordingInternal(call)
362
+ else -> call.reject("Unknown action")
363
+ }
364
+ }
365
+
366
+ // ── Screenshot capture ──────────────────────────────────────────────
367
+
368
+ private fun captureScreenshotInternal(call: PluginCall) {
369
+ val format = call.getString("format") ?: "png"
370
+ val quality = call.getInt("quality") ?: 100
371
+ val scale = call.getFloat("scale") ?: 1f
372
+
373
+ val width = (screenWidth * scale).toInt()
374
+ val height = (screenHeight * scale).toInt()
375
+
376
+ imageReader = ImageReader.newInstance(width, height, PixelFormat.RGBA_8888, 2)
377
+
378
+ virtualDisplay = mediaProjection?.createVirtualDisplay(
379
+ "ScreenCapture",
380
+ width,
381
+ height,
382
+ screenDensity,
383
+ DisplayManager.VIRTUAL_DISPLAY_FLAG_AUTO_MIRROR,
384
+ imageReader?.surface,
385
+ null,
386
+ null
387
+ )
388
+
389
+ // Brief delay to allow the VirtualDisplay to render a frame
390
+ Handler(Looper.getMainLooper()).postDelayed({
391
+ try {
392
+ val image = imageReader?.acquireLatestImage()
393
+
394
+ if (image != null) {
395
+ val bitmap = imageToBitmap(image, width, height)
396
+ image.close()
397
+
398
+ val outputStream = ByteArrayOutputStream()
399
+ val compressFormat = when (format) {
400
+ "jpeg" -> Bitmap.CompressFormat.JPEG
401
+ "webp" -> if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
402
+ Bitmap.CompressFormat.WEBP_LOSSY
403
+ } else {
404
+ @Suppress("DEPRECATION")
405
+ Bitmap.CompressFormat.WEBP
406
+ }
407
+ else -> Bitmap.CompressFormat.PNG
408
+ }
409
+
410
+ bitmap.compress(compressFormat, quality, outputStream)
411
+ bitmap.recycle()
412
+
413
+ val base64 = Base64.encodeToString(outputStream.toByteArray(), Base64.NO_WRAP)
414
+
415
+ cleanup()
416
+
417
+ call.resolve(JSObject().apply {
418
+ put("base64", base64)
419
+ put("format", format)
420
+ put("width", width)
421
+ put("height", height)
422
+ put("timestamp", System.currentTimeMillis())
423
+ })
424
+ } else {
425
+ cleanup()
426
+ call.reject("Failed to capture screenshot")
427
+ }
428
+ } catch (e: Exception) {
429
+ Log.e(TAG, "Screenshot capture failed", e)
430
+ cleanup()
431
+ notifyError("screenshot_failed", "Screenshot failed: ${e.message}")
432
+ call.reject("Screenshot failed: ${e.message}")
433
+ }
434
+ }, 250) // 250ms delay to ensure frame is rendered
435
+ }
436
+
437
+ private fun imageToBitmap(image: Image, width: Int, height: Int): Bitmap {
438
+ val planes = image.planes
439
+ val buffer = planes[0].buffer
440
+ val pixelStride = planes[0].pixelStride
441
+ val rowStride = planes[0].rowStride
442
+ val rowPadding = rowStride - pixelStride * width
443
+
444
+ val bitmap = Bitmap.createBitmap(
445
+ width + rowPadding / pixelStride,
446
+ height,
447
+ Bitmap.Config.ARGB_8888
448
+ )
449
+ bitmap.copyPixelsFromBuffer(buffer)
450
+
451
+ return if (rowPadding > 0) {
452
+ val cropped = Bitmap.createBitmap(bitmap, 0, 0, width, height)
453
+ if (cropped !== bitmap) bitmap.recycle()
454
+ cropped
455
+ } else {
456
+ bitmap
457
+ }
458
+ }
459
+
460
+ // ── Screen recording ────────────────────────────────────────────────
461
+
462
+ private fun startRecordingInternal(call: PluginCall) {
463
+ val config = pendingRecordingOptions ?: RecordingConfig()
464
+ pendingRecordingOptions = null
465
+
466
+ val fps = config.fps
467
+ val bitrate = config.bitrate ?: estimateBitrate(screenWidth, screenHeight, fps)
468
+
469
+ val fileName = "screen_${SimpleDateFormat("yyyyMMdd_HHmmss", Locale.US).format(Date())}.mp4"
470
+ outputFile = File(context.cacheDir, fileName)
471
+
472
+ try {
473
+ mediaRecorder = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
474
+ MediaRecorder(context)
475
+ } else {
476
+ @Suppress("DEPRECATION")
477
+ MediaRecorder()
478
+ }
479
+
480
+ mediaRecorder?.apply {
481
+ // Audio source must be set before video source
482
+ if (config.captureMicrophone) {
483
+ setAudioSource(MediaRecorder.AudioSource.MIC)
484
+ }
485
+
486
+ setVideoSource(MediaRecorder.VideoSource.SURFACE)
487
+ setOutputFormat(MediaRecorder.OutputFormat.MPEG_4)
488
+ setVideoEncoder(MediaRecorder.VideoEncoder.H264)
489
+
490
+ if (config.captureMicrophone) {
491
+ setAudioEncoder(MediaRecorder.AudioEncoder.AAC)
492
+ setAudioChannels(1)
493
+ setAudioSamplingRate(44100)
494
+ setAudioEncodingBitRate(96000)
495
+ }
496
+
497
+ setVideoSize(screenWidth, screenHeight)
498
+ setVideoFrameRate(fps)
499
+ setVideoEncodingBitRate(bitrate)
500
+ setOutputFile(outputFile?.absolutePath)
501
+
502
+ if (config.maxFileSize != null && config.maxFileSize > 0) {
503
+ setMaxFileSize(config.maxFileSize)
504
+ }
505
+
506
+ // Auto-stop callback when max file size or max duration is hit
507
+ setOnInfoListener { _, what, _ ->
508
+ if (what == MediaRecorder.MEDIA_RECORDER_INFO_MAX_FILESIZE_REACHED ||
509
+ what == MediaRecorder.MEDIA_RECORDER_INFO_MAX_DURATION_REACHED
510
+ ) {
511
+ Log.d(TAG, "Recording auto-stopped (limit reached)")
512
+ scope.launch { stopRecordingInternal() }
513
+ }
514
+ }
515
+
516
+ setOnErrorListener { _, what, extra ->
517
+ Log.e(TAG, "MediaRecorder error: what=$what extra=$extra")
518
+ notifyError("recording_error", "MediaRecorder error: $what/$extra")
519
+ scope.launch { stopRecordingInternal() }
520
+ }
521
+
522
+ prepare()
523
+ }
524
+
525
+ virtualDisplay = mediaProjection?.createVirtualDisplay(
526
+ "ScreenRecording",
527
+ screenWidth,
528
+ screenHeight,
529
+ screenDensity,
530
+ DisplayManager.VIRTUAL_DISPLAY_FLAG_AUTO_MIRROR,
531
+ mediaRecorder?.surface,
532
+ null,
533
+ null
534
+ )
535
+
536
+ mediaRecorder?.start()
537
+ isRecording = true
538
+ isPaused = false
539
+ recordingStartTime = System.currentTimeMillis()
540
+ pausedDurationMs = 0
541
+ pauseStartTime = 0
542
+
543
+ // Store limits for timer-based auto-stop
544
+ maxDurationMs = config.maxDuration?.let { (it * 1000).toLong() }
545
+ maxFileSize = config.maxFileSize
546
+
547
+ startRecordingTimer()
548
+
549
+ notifyListeners("recordingState", JSObject().apply {
550
+ put("isRecording", true)
551
+ put("isPaused", false)
552
+ put("duration", 0.0)
553
+ put("fileSize", 0L)
554
+ })
555
+
556
+ call.resolve()
557
+
558
+ } catch (e: Exception) {
559
+ Log.e(TAG, "Failed to start recording", e)
560
+ mediaRecorder?.release()
561
+ mediaRecorder = null
562
+ cleanup()
563
+ notifyError("recording_start_failed", "Failed to start recording: ${e.message}")
564
+ call.reject("Failed to start recording: ${e.message}")
565
+ }
566
+ }
567
+
568
+ private fun startRecordingTimer() {
569
+ recordingTimer = Handler(Looper.getMainLooper())
570
+ recordingRunnable = object : Runnable {
571
+ override fun run() {
572
+ if (!isRecording) return
573
+
574
+ val duration = getRecordingDuration()
575
+ val fileSize = outputFile?.length() ?: 0
576
+
577
+ notifyListeners("recordingState", JSObject().apply {
578
+ put("isRecording", true)
579
+ put("isPaused", isPaused)
580
+ put("duration", duration)
581
+ put("fileSize", fileSize)
582
+ })
583
+
584
+ // Check maxDuration auto-stop
585
+ val maxDur = maxDurationMs
586
+ if (maxDur != null && !isPaused) {
587
+ val activeDuration = (duration * 1000).toLong()
588
+ if (activeDuration >= maxDur) {
589
+ Log.d(TAG, "Max duration reached, auto-stopping")
590
+ scope.launch { stopRecordingInternal() }
591
+ return
592
+ }
593
+ }
594
+
595
+ // Check maxFileSize auto-stop
596
+ val maxSize = maxFileSize
597
+ if (maxSize != null && fileSize >= maxSize) {
598
+ Log.d(TAG, "Max file size reached, auto-stopping")
599
+ scope.launch { stopRecordingInternal() }
600
+ return
601
+ }
602
+
603
+ recordingTimer?.postDelayed(this, 500)
604
+ }
605
+ }
606
+ recordingTimer?.postDelayed(recordingRunnable!!, 500)
607
+ }
608
+
609
+ private suspend fun stopRecordingInternal(): JSObject? = withContext(Dispatchers.Main) {
610
+ recordingTimer?.removeCallbacks(recordingRunnable ?: return@withContext null)
611
+ recordingTimer = null
612
+ recordingRunnable = null
613
+
614
+ val duration = getRecordingDuration()
615
+
616
+ try {
617
+ mediaRecorder?.stop()
618
+ } catch (e: Exception) {
619
+ Log.w(TAG, "MediaRecorder.stop() failed (may be empty recording)", e)
620
+ }
621
+
622
+ mediaRecorder?.release()
623
+ mediaRecorder = null
624
+ isRecording = false
625
+ isPaused = false
626
+ pausedDurationMs = 0
627
+ pauseStartTime = 0
628
+ maxDurationMs = null
629
+ maxFileSize = null
630
+
631
+ cleanup()
632
+
633
+ val file = outputFile ?: return@withContext null
634
+ val fileSize = file.length()
635
+
636
+ notifyListeners("recordingState", JSObject().apply {
637
+ put("isRecording", false)
638
+ put("isPaused", false)
639
+ put("duration", duration)
640
+ put("fileSize", fileSize)
641
+ })
642
+
643
+ JSObject().apply {
644
+ put("path", file.absolutePath)
645
+ put("duration", duration)
646
+ put("width", screenWidth)
647
+ put("height", screenHeight)
648
+ put("fileSize", fileSize)
649
+ put("mimeType", "video/mp4")
650
+ }
651
+ }
652
+
653
+ // ── Recording config parsing ────────────────────────────────────────
654
+
655
+ private data class RecordingConfig(
656
+ val quality: String? = null,
657
+ val maxDuration: Double? = null,
658
+ val maxFileSize: Long? = null,
659
+ val fps: Int = 30,
660
+ val bitrate: Int? = null,
661
+ val captureMicrophone: Boolean = false,
662
+ val captureSystemAudio: Boolean = false
663
+ )
664
+
665
+ private fun parseRecordingConfig(call: PluginCall): RecordingConfig {
666
+ val quality = call.getString("quality")
667
+ val maxDuration = call.getDouble("maxDuration")
668
+ val maxFileSize = call.getLong("maxFileSize")
669
+ val captureMicrophone = call.getBoolean("captureMicrophone") ?: false
670
+ val captureSystemAudio = call.getBoolean("captureSystemAudio") ?: false
671
+
672
+ // Apply quality presets or use explicit values
673
+ val presetFps: Int
674
+ val presetBitrate: Int?
675
+ when (quality?.lowercase()) {
676
+ "low" -> {
677
+ presetFps = 15
678
+ presetBitrate = estimateBitrate(screenWidth, screenHeight, 15) / 2
679
+ }
680
+ "medium" -> {
681
+ presetFps = 24
682
+ presetBitrate = null // use estimate
683
+ }
684
+ "high" -> {
685
+ presetFps = 30
686
+ presetBitrate = null // use estimate
687
+ }
688
+ "highest" -> {
689
+ presetFps = 60
690
+ presetBitrate = estimateBitrate(screenWidth, screenHeight, 60) * 2
691
+ }
692
+ else -> {
693
+ presetFps = 30
694
+ presetBitrate = null
695
+ }
696
+ }
697
+
698
+ val fps = call.getInt("fps") ?: presetFps
699
+ val bitrate = call.getInt("bitrate") ?: presetBitrate
700
+
701
+ return RecordingConfig(
702
+ quality = quality,
703
+ maxDuration = maxDuration,
704
+ maxFileSize = maxFileSize,
705
+ fps = fps.coerceIn(1, 60),
706
+ bitrate = bitrate,
707
+ captureMicrophone = captureMicrophone,
708
+ captureSystemAudio = captureSystemAudio
709
+ )
710
+ }
711
+
712
+ // ── Helpers ──────────────────────────────────────────────────────────
713
+
714
+ /**
715
+ * Get recording duration in seconds, accounting for paused time.
716
+ */
717
+ private fun getRecordingDuration(): Double {
718
+ if (!isRecording) return 0.0
719
+ val now = System.currentTimeMillis()
720
+ val totalElapsed = now - recordingStartTime
721
+ val currentPauseDuration = if (isPaused && pauseStartTime > 0) {
722
+ now - pauseStartTime
723
+ } else 0L
724
+ val activeDuration = totalElapsed - pausedDurationMs - currentPauseDuration
725
+ return activeDuration.toDouble() / 1000.0
726
+ }
727
+
728
+ /**
729
+ * Estimate a reasonable video bitrate based on resolution and frame rate.
730
+ * Ported from classic ScreenRecordManager.
731
+ */
732
+ private fun estimateBitrate(width: Int, height: Int, fps: Int): Int {
733
+ val pixels = width.toLong() * height.toLong()
734
+ val raw = (pixels * fps.toLong() * 2L).toInt()
735
+ return raw.coerceIn(1_000_000, 12_000_000)
736
+ }
737
+
738
+ private fun permissionString(status: com.getcapacitor.PermissionState?): String {
739
+ return when (status) {
740
+ com.getcapacitor.PermissionState.GRANTED -> "granted"
741
+ com.getcapacitor.PermissionState.DENIED -> "denied"
742
+ else -> "prompt"
743
+ }
744
+ }
745
+
746
+ private fun notifyError(code: String, message: String) {
747
+ notifyListeners("error", JSObject().apply {
748
+ put("code", code)
749
+ put("message", message)
750
+ })
751
+ }
752
+
753
+ private fun cleanup() {
754
+ virtualDisplay?.release()
755
+ virtualDisplay = null
756
+ imageReader?.close()
757
+ imageReader = null
758
+ mediaProjection?.stop()
759
+ mediaProjection = null
760
+ }
761
+
762
+ // ── Destroy ─────────────────────────────────────────────────────────
763
+
764
+ override fun handleOnDestroy() {
765
+ super.handleOnDestroy()
766
+ if (isRecording) {
767
+ try {
768
+ mediaRecorder?.stop()
769
+ } catch (_: Exception) {}
770
+ mediaRecorder?.release()
771
+ mediaRecorder = null
772
+ isRecording = false
773
+ }
774
+ cleanup()
775
+ scope.cancel()
776
+ }
777
+ }