react-native-video-trim 6.2.2 → 7.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.
Files changed (29) hide show
  1. package/README.md +34 -9
  2. package/android/src/main/java/com/videotrim/BaseVideoTrimModule.kt +146 -119
  3. package/android/src/main/java/com/videotrim/enums/ErrorCode.kt +2 -1
  4. package/android/src/main/java/com/videotrim/utils/MediaMetadataUtil.kt +6 -2
  5. package/android/src/main/java/com/videotrim/utils/StorageUtil.kt +5 -1
  6. package/android/src/main/java/com/videotrim/utils/VideoTrimmerUtil.kt +99 -26
  7. package/android/src/main/java/com/videotrim/widgets/CropOverlayView.kt +293 -0
  8. package/android/src/main/java/com/videotrim/widgets/VideoTrimmerView.kt +556 -28
  9. package/android/src/main/res/drawable/arrow_trianglehead_left_and_right_righttriangle_left_righttriangle_right.xml +19 -0
  10. package/android/src/main/res/drawable/arrow_uturn_backward.xml +15 -0
  11. package/android/src/main/res/drawable/arrow_uturn_forward.xml +15 -0
  12. package/android/src/main/res/drawable/crop.xml +15 -0
  13. package/android/src/main/res/drawable/rotate_left.xml +19 -0
  14. package/android/src/main/res/layout/video_trimmer_view.xml +115 -37
  15. package/android/src/main/res/xml/file_paths.xml +1 -1
  16. package/ios/CropOverlayView.swift +285 -0
  17. package/ios/VideoTrim.mm +2 -4
  18. package/ios/VideoTrim.swift +198 -61
  19. package/ios/VideoTrimmer.swift +2 -4
  20. package/ios/VideoTrimmerViewController.swift +478 -56
  21. package/lib/module/NativeVideoTrim.js.map +1 -1
  22. package/lib/module/index.js +1 -2
  23. package/lib/module/index.js.map +1 -1
  24. package/lib/typescript/src/NativeVideoTrim.d.ts +10 -4
  25. package/lib/typescript/src/NativeVideoTrim.d.ts.map +1 -1
  26. package/lib/typescript/src/index.d.ts.map +1 -1
  27. package/package.json +1 -1
  28. package/src/NativeVideoTrim.ts +10 -4
  29. package/src/index.tsx +1 -2
package/README.md CHANGED
@@ -14,7 +14,8 @@
14
14
  - [Advanced Features](#advanced-features)
15
15
  * [Audio Trimming](#audio-trimming)
16
16
  * [Remote Files (HTTPS)](#remote-files-https)
17
- * [Video Rotation](#video-rotation)
17
+ * [Video Transforms (Flip, Rotate, Crop)](#video-transforms-flip-rotate-crop)
18
+ * [Precise Frame Trimming](#precise-frame-trimming)
18
19
  - [Examples](#examples)
19
20
  - [Troubleshooting](#troubleshooting)
20
21
 
@@ -40,6 +41,8 @@ A powerful, easy-to-use video and audio trimming library for React Native applic
40
41
  ### ✨ Key Features
41
42
 
42
43
  - **📹 Video & Audio Support** - Trim both video and audio files
44
+ - **🔄 Flip, Rotate & Crop** - Built-in video transforms with undo/redo support
45
+ - **🎯 Precise Trimming** - Optional frame-accurate cuts via hardware-accelerated re-encoding
43
46
  - **🌐 Local & Remote Files** - Support for local storage and HTTPS URLs
44
47
  - **💾 Multiple Save Options** - Photos, Documents, or Share to other apps
45
48
  - **✅ File Validation** - Built-in validation for media files
@@ -50,7 +53,9 @@ A powerful, easy-to-use video and audio trimming library for React Native applic
50
53
 
51
54
  | Feature | Description |
52
55
  |---------|-------------|
53
- | **Trimming** | Precise video/audio trimming with visual controls |
56
+ | **Trimming** | Video/audio trimming with visual timeline controls |
57
+ | **Transforms** | Horizontal flip, 90° rotation, and freeform crop with undo/redo |
58
+ | **Precise Trimming** | Frame-accurate cuts using hardware re-encoding (opt-in) |
54
59
  | **Validation** | Check if files are valid video/audio before processing |
55
60
  | **Save Options** | Photos, Documents, Share sheet integration |
56
61
  | **File Management** | Complete file lifecycle management |
@@ -344,8 +349,7 @@ All configuration options are optional. Here are the most commonly used ones:
344
349
  |--------|------|---------|-------------|
345
350
  | `enableHapticFeedback` | `boolean` | `true` | Enable haptic feedback |
346
351
  | `closeWhenFinish` | `boolean` | `true` | Close editor when done |
347
- | `enableRotation` | `boolean` | `false` | Enable video rotation |
348
- | `rotationAngle` | `number` | `0` | Rotation angle in degrees |
352
+ | `enablePreciseTrimming` | `boolean` | `false` | Re-encode for frame-accurate cuts (slower, see [Precise Frame Trimming](#precise-frame-trimming)) |
349
353
  | `changeStatusBarColorOnOpen` | `boolean` | `false` | Change status bar color (Android only) |
350
354
  | `zoomOnWaitingDuration` | `number` | `5000` | Duration for zoom-on-waiting feature in milliseconds (default: 5000) |
351
355
 
@@ -438,18 +442,39 @@ showEditor('https://example.com/video.mp4', {
438
442
  });
439
443
  ```
440
444
 
441
- ### Video Rotation
445
+ ### Video Transforms (Flip, Rotate, Crop)
442
446
 
443
- Rotate videos during trimming using metadata (doesn't re-encode):
447
+ The editor includes built-in transform controls horizontal flip, 90° left rotation, and freeform crop — with full undo/redo support. These appear as toolbar buttons in the editor UI on both iOS and Android.
448
+
449
+ When any transform is applied, FFmpeg automatically re-encodes the video using the platform's hardware encoder (`h264_videotoolbox` on iOS, `h264_mediacodec` on Android) at the source bitrate to preserve quality. No additional configuration is needed.
450
+
451
+ ### Precise Frame Trimming
452
+
453
+ By default, trimming uses FFmpeg's stream copy (`-c copy`), which is very fast but can only cut at keyframes. The actual start/end points may drift by several seconds from what the user selected.
454
+
455
+ Enable `enablePreciseTrimming` for frame-accurate cuts:
444
456
 
445
457
  ```javascript
458
+ // Editor mode
446
459
  showEditor(videoUrl, {
447
- enableRotation: true,
448
- rotationAngle: 90, // 90, 180, 270 degrees
460
+ enablePreciseTrimming: true,
461
+ });
462
+
463
+ // Headless mode
464
+ const result = await trim(videoUrl, {
465
+ startTime: 5000,
466
+ endTime: 15000,
467
+ enablePreciseTrimming: true,
449
468
  });
450
469
  ```
451
470
 
452
- **Note:** Uses `display_rotation` metadata - playback may vary by platform/player.
471
+ | | `enablePreciseTrimming: false` (default) | `enablePreciseTrimming: true` |
472
+ |---|---|---|
473
+ | **Speed** | Very fast (stream copy) | Slower (hardware re-encode) |
474
+ | **Accuracy** | Keyframe-aligned (may drift 1-5s) | Frame-accurate |
475
+ | **Quality** | Lossless (original bitstream) | Near-lossless (matched bitrate) |
476
+
477
+ **Note:** When transforms (flip/rotate/crop) are applied, re-encoding already happens regardless of this flag, so precise trimming comes for free in that case.
453
478
 
454
479
  ### Trimming Progress & Cancellation
455
480
 
@@ -11,6 +11,8 @@ import android.content.Intent
11
11
  import android.content.pm.PackageManager
12
12
  import android.content.res.ColorStateList
13
13
  import android.graphics.Color
14
+ import android.media.MediaMetadataRetriever
15
+ import android.net.Uri
14
16
  import android.os.Build
15
17
  import android.util.Log
16
18
  import android.util.TypedValue
@@ -75,6 +77,8 @@ open class BaseVideoTrimModule internal constructor(
75
77
  get() = editorConfig?.hasKey("changeStatusBarColorOnOpen") == true && editorConfig?.getBoolean("changeStatusBarColorOnOpen") == true
76
78
 
77
79
  init {
80
+ reactApplicationContext.addLifecycleEventListener(this)
81
+
78
82
  val mActivityEventListener = object : BaseActivityEventListener() {
79
83
  override fun onActivityResult(
80
84
  activity: Activity,
@@ -137,7 +141,10 @@ open class BaseVideoTrimModule internal constructor(
137
141
 
138
142
  this.isVideoType = config.hasKey("type") && config.getString("type") == "video"
139
143
 
140
- val activity = reactApplicationContext.currentActivity
144
+ val activity = reactApplicationContext.currentActivity ?: run {
145
+ onError("Activity is not available", ErrorCode.UNKNOWN)
146
+ return
147
+ }
141
148
  if (!isInit) {
142
149
  init()
143
150
  isInit = true
@@ -150,7 +157,7 @@ open class BaseVideoTrimModule internal constructor(
150
157
  trimmerView?.initByURI(filePath.toUri())
151
158
 
152
159
  val builder = AlertDialog.Builder(
153
- activity!!, Theme_Black_NoTitleBar_Fullscreen
160
+ activity, Theme_Black_NoTitleBar_Fullscreen
154
161
  )
155
162
  builder.setCancelable(false)
156
163
  alertDialog = builder.create()
@@ -158,18 +165,17 @@ open class BaseVideoTrimModule internal constructor(
158
165
 
159
166
  // Apply safe area handling after the dialog is shown
160
167
  alertDialog?.setOnShowListener {
161
- applySafeAreaToDialog(alertDialog!!, trimmerView!!)
168
+ val dialog = alertDialog ?: return@setOnShowListener
169
+ val view = trimmerView ?: return@setOnShowListener
170
+ applySafeAreaToDialog(dialog, view)
162
171
 
163
172
  sendEvent("onShow", null)
164
173
  }
165
174
 
166
175
  // this is to ensure to release resource if dialog is dismissed in unexpected way (Eg. open control/notification center by dragging from top of screen)
167
- alertDialog!!.setOnDismissListener {
168
- // This is called in same thread as the trimmer view -> UI thread
169
- if (trimmerView != null) {
170
- trimmerView!!.onDestroy()
171
- trimmerView = null
172
- }
176
+ alertDialog?.setOnDismissListener {
177
+ trimmerView?.onDestroy()
178
+ trimmerView = null
173
179
  hideDialog(true)
174
180
  sendEvent("onHide", null)
175
181
  }
@@ -195,7 +201,7 @@ open class BaseVideoTrimModule internal constructor(
195
201
  it.addFlags(WindowManager.LayoutParams.FLAG_DRAWS_SYSTEM_BAR_BACKGROUNDS);
196
202
 
197
203
  // finally change the color
198
- it.statusBarColor = ContextCompat.getColor(reactApplicationContext.currentActivity!!,R.color.black)
204
+ it.statusBarColor = ContextCompat.getColor(reactApplicationContext, R.color.black)
199
205
  }
200
206
  }
201
207
 
@@ -251,9 +257,7 @@ open class BaseVideoTrimModule internal constructor(
251
257
 
252
258
  override fun onHostPause() {
253
259
  Log.d(TAG, "onHostPause: ")
254
- if (trimmerView != null) {
255
- trimmerView!!.onMediaPause()
256
- }
260
+ trimmerView?.onMediaPause()
257
261
  }
258
262
 
259
263
  override fun onHostDestroy() {
@@ -277,7 +281,7 @@ open class BaseVideoTrimModule internal constructor(
277
281
  return
278
282
  }
279
283
 
280
- mProgressBar!!.setProgress(percentage, true)
284
+ mProgressBar?.setProgress(percentage, true)
281
285
  }
282
286
 
283
287
 
@@ -312,10 +316,11 @@ open class BaseVideoTrimModule internal constructor(
312
316
  hideDialog(editorConfig?.getBoolean("closeWhenFinish") ?: true)
313
317
  }
314
318
  } else if (editorConfig?.getBoolean("openDocumentsOnFinish") == true) {
315
- saveFileToExternalStorage(File(outputFile!!))
319
+ val output = outputFile ?: return
320
+ saveFileToExternalStorage(File(output))
316
321
  } else if (editorConfig?.getBoolean("openShareSheetOnFinish") == true) {
317
322
  hideDialog(editorConfig?.getBoolean("closeWhenFinish") ?: true)
318
- shareFile(reactApplicationContext, File(outputFile!!))
323
+ outputFile?.let { shareFile(reactApplicationContext, File(it)) }
319
324
  } else {
320
325
  hideDialog(editorConfig?.getBoolean("closeWhenFinish") ?: true)
321
326
  }
@@ -333,13 +338,14 @@ open class BaseVideoTrimModule internal constructor(
333
338
  }
334
339
 
335
340
  override fun onCancel() {
336
- if (!editorConfig?.getBoolean("enableCancelDialog")!!) {
341
+ if (editorConfig?.getBoolean("enableCancelDialog") != true) {
337
342
  sendEvent("onCancel", null)
338
343
  hideDialog(true)
339
344
  return
340
345
  }
341
346
 
342
- val builder = AlertDialog.Builder(reactApplicationContext.currentActivity!!)
347
+ val activity = reactApplicationContext.currentActivity ?: return
348
+ val builder = AlertDialog.Builder(activity)
343
349
  builder.setMessage(editorConfig?.getString("cancelDialogMessage"))
344
350
  builder.setTitle(editorConfig?.getString("cancelDialogTitle"))
345
351
  builder.setCancelable(false)
@@ -358,12 +364,13 @@ open class BaseVideoTrimModule internal constructor(
358
364
  }
359
365
 
360
366
  override fun onSave() {
361
- if (!editorConfig?.getBoolean("enableSaveDialog")!!) {
367
+ if (editorConfig?.getBoolean("enableSaveDialog") != true) {
362
368
  startTrim()
363
369
  return
364
370
  }
365
371
 
366
- val builder = AlertDialog.Builder(reactApplicationContext.currentActivity!!)
372
+ val activity = reactApplicationContext.currentActivity ?: return
373
+ val builder = AlertDialog.Builder(activity)
367
374
  builder.setMessage(editorConfig?.getString("saveDialogMessage"))
368
375
  builder.setTitle(editorConfig?.getString("saveDialogTitle"))
369
376
  builder.setCancelable(false)
@@ -389,7 +396,7 @@ open class BaseVideoTrimModule internal constructor(
389
396
  }
390
397
 
391
398
  private fun startTrim() {
392
- val activity = reactApplicationContext.currentActivity
399
+ val activity = reactApplicationContext.currentActivity ?: return
393
400
  // Create the parent layout for the dialog
394
401
  val layout = LinearLayout(activity)
395
402
  layout.layoutParams = ViewGroup.LayoutParams(
@@ -413,12 +420,13 @@ open class BaseVideoTrimModule internal constructor(
413
420
  layout.addView(textView)
414
421
 
415
422
  // Create and add the ProgressBar
416
- mProgressBar = ProgressBar(activity, null, progressBarStyleHorizontal)
417
- mProgressBar!!.layoutParams = ViewGroup.LayoutParams(
418
- ViewGroup.LayoutParams.MATCH_PARENT,
419
- ViewGroup.LayoutParams.WRAP_CONTENT
420
- )
421
- mProgressBar!!.progressTintList = ColorStateList.valueOf("#2196F3".toColorInt())
423
+ mProgressBar = ProgressBar(activity, null, progressBarStyleHorizontal).also {
424
+ it.layoutParams = ViewGroup.LayoutParams(
425
+ ViewGroup.LayoutParams.MATCH_PARENT,
426
+ ViewGroup.LayoutParams.WRAP_CONTENT
427
+ )
428
+ it.progressTintList = ColorStateList.valueOf("#2196F3".toColorInt())
429
+ }
422
430
  layout.addView(mProgressBar)
423
431
 
424
432
  // Create button
@@ -433,10 +441,10 @@ open class BaseVideoTrimModule internal constructor(
433
441
  ?: "Cancel Trimming"
434
442
  button.setTextColor(
435
443
  ContextCompat.getColor(
436
- activity!!,
444
+ activity,
437
445
  holo_red_light
438
446
  )
439
- ) // or use your custom color
447
+ )
440
448
 
441
449
  // Apply ripple effect while keeping the button background transparent
442
450
  val outValue = TypedValue()
@@ -451,11 +459,9 @@ open class BaseVideoTrimModule internal constructor(
451
459
  builder.setTitle(editorConfig?.getString("cancelTrimmingDialogTitle"))
452
460
  builder.setCancelable(false)
453
461
  builder.setPositiveButton(editorConfig?.getString("cancelTrimmingDialogConfirmText")) { _: DialogInterface?, _: Int ->
454
- if (trimmerView != null) {
455
- trimmerView!!.onCancelTrimClicked()
456
- }
457
- if (mProgressDialog != null && mProgressDialog!!.isShowing) {
458
- mProgressDialog!!.dismiss()
462
+ trimmerView?.onCancelTrimClicked()
463
+ if (mProgressDialog?.isShowing == true) {
464
+ mProgressDialog?.dismiss()
459
465
  }
460
466
  }
461
467
  builder.setNegativeButton(
@@ -464,14 +470,12 @@ open class BaseVideoTrimModule internal constructor(
464
470
  dialog.cancel()
465
471
  }
466
472
  cancelTrimmingConfirmDialog = builder.create()
467
- cancelTrimmingConfirmDialog!!.show()
473
+ cancelTrimmingConfirmDialog?.show()
468
474
  } else {
469
- if (trimmerView != null) {
470
- trimmerView!!.onCancelTrimClicked()
471
- }
475
+ trimmerView?.onCancelTrimClicked()
472
476
 
473
- if (mProgressDialog != null && mProgressDialog!!.isShowing) {
474
- mProgressDialog!!.dismiss()
477
+ if (mProgressDialog?.isShowing == true) {
478
+ mProgressDialog?.dismiss()
475
479
  }
476
480
  }
477
481
  }
@@ -480,7 +484,7 @@ open class BaseVideoTrimModule internal constructor(
480
484
 
481
485
  // Create the AlertDialog
482
486
  val builder = AlertDialog.Builder(
483
- activity!!
487
+ activity
484
488
  )
485
489
  builder.setCancelable(false)
486
490
  builder.setView(layout)
@@ -488,36 +492,29 @@ open class BaseVideoTrimModule internal constructor(
488
492
  // Show the dialog
489
493
  mProgressDialog = builder.create()
490
494
 
491
- mProgressDialog!!.setOnShowListener {
495
+ mProgressDialog?.setOnShowListener {
492
496
  sendEvent("onStartTrimming", null)
493
- if (trimmerView != null) {
494
- trimmerView!!.onSaveClicked()
495
- }
497
+ trimmerView?.onSaveClicked()
496
498
  }
497
499
 
498
- mProgressDialog!!.show()
500
+ mProgressDialog?.show()
499
501
  }
500
502
 
501
503
  private fun hideDialog(shouldCloseEditor: Boolean) {
502
- // handle the case when the cancel dialog is still showing but the trimming is finished
503
- if (cancelTrimmingConfirmDialog != null) {
504
- if (cancelTrimmingConfirmDialog!!.isShowing) {
505
- cancelTrimmingConfirmDialog!!.dismiss()
506
- }
504
+ cancelTrimmingConfirmDialog?.let {
505
+ if (it.isShowing) it.dismiss()
507
506
  cancelTrimmingConfirmDialog = null
508
507
  }
509
508
 
510
- if (mProgressDialog != null) {
511
- if (mProgressDialog!!.isShowing) mProgressDialog!!.dismiss()
509
+ mProgressDialog?.let {
510
+ if (it.isShowing) it.dismiss()
512
511
  mProgressBar = null
513
512
  mProgressDialog = null
514
513
  }
515
514
 
516
515
  if (shouldCloseEditor) {
517
- if (alertDialog != null) {
518
- if (alertDialog!!.isShowing) {
519
- alertDialog!!.dismiss()
520
- }
516
+ alertDialog?.let {
517
+ if (it.isShowing) it.dismiss()
521
518
  alertDialog = null
522
519
  }
523
520
  }
@@ -550,7 +547,6 @@ open class BaseVideoTrimModule internal constructor(
550
547
 
551
548
  fun closeEditor() {
552
549
  hideDialog(true)
553
- sendEvent("onHide", null)
554
550
  }
555
551
 
556
552
  fun isValidFile(url: String, promise: Promise) {
@@ -587,82 +583,109 @@ open class BaseVideoTrimModule internal constructor(
587
583
  "${endTime}ms",
588
584
  )
589
585
 
590
- if (options?.getBoolean("enableRotation") == true) {
591
- cmds += arrayOf("-display_rotation", "${options.getDouble("rotationAngle")}")
586
+ val outputFile = StorageUtil.getOutputPath(reactApplicationContext, options?.getString("outputExt") ?: "mp4")
587
+
588
+ val resolvedOutputFile = outputFile ?: run {
589
+ promise.reject(Exception("Failed to create output file path"))
590
+ return
592
591
  }
593
592
 
594
- val outputFile = StorageUtil.getOutputPath(reactApplicationContext, options?.getString("outputExt") ?: "mp4")
593
+ // Headless trim: no editor UI, so no transforms (flip/rotate/crop) are possible.
594
+ // The only reason to re-encode here is enablePreciseTrimming for frame-accurate cuts.
595
+ val enablePrecise = options?.hasKey("enablePreciseTrimming") == true &&
596
+ options.getBoolean("enablePreciseTrimming")
595
597
 
596
- cmds += arrayOf(
597
- "-i",
598
- url,
599
- "-c",
600
- "copy",
601
- "-metadata",
602
- "creation_time=$formattedDateTime",
603
- outputFile!!
604
- )
598
+ if (enablePrecise) {
599
+ // Match source bitrate to preserve quality; fall back to 10 Mbps.
600
+ var bitrateStr = "10M"
601
+ try {
602
+ val retriever = MediaMetadataRetriever()
603
+ retriever.setDataSource(reactApplicationContext, Uri.parse(url))
604
+ val bitrate = retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_BITRATE)
605
+ ?.toLongOrNull() ?: 0L
606
+ if (bitrate > 0) bitrateStr = "$bitrate"
607
+ retriever.release()
608
+ } catch (_: Exception) {}
609
+
610
+ // h264_mediacodec: Android's hardware H.264 encoder.
611
+ // No -noautorotate needed — FFmpegKit on Android auto-rotates correctly.
612
+ cmds += arrayOf(
613
+ "-i", url,
614
+ "-c:v", "h264_mediacodec",
615
+ "-b:v", bitrateStr,
616
+ "-c:a", "copy",
617
+ "-metadata", "creation_time=$formattedDateTime",
618
+ resolvedOutputFile
619
+ )
620
+ } else {
621
+ // Stream copy: no re-encoding, extremely fast but only cuts at keyframes.
622
+ cmds += arrayOf(
623
+ "-i", url,
624
+ "-c", "copy",
625
+ "-metadata", "creation_time=$formattedDateTime",
626
+ resolvedOutputFile
627
+ )
628
+ }
605
629
 
606
630
  Log.d(TAG, "Command: ${cmds.joinToString(",")}")
607
631
 
608
632
  FFmpegKit.executeWithArgumentsAsync(cmds, { session ->
609
633
  val state = session.state
610
634
  val returnCode = session.returnCode
611
- when {
612
- ReturnCode.isSuccess(returnCode) -> {
613
- // SUCCESS
614
- val duration = endTime - startTime
615
- val result = Arguments.createMap()
616
-
617
- result.putString("outputPath", outputFile)
618
- result.putDouble("startTime", startTime)
619
- result.putDouble("endTime", endTime)
620
- result.putDouble("duration", duration)
621
- result.putBoolean("success", true)
622
-
623
- if (options?.getBoolean("saveToPhoto") == true && options.getString("type") == "video") {
624
- Log.d(TAG, "Android trim: saveToPhoto is true, attempting to save to gallery")
625
- try {
626
- StorageUtil.saveVideoToGallery(reactApplicationContext, outputFile)
627
- Log.d(TAG, "Edited video saved to Photo Library successfully.")
628
- if (options.getBoolean("removeAfterSavedToPhoto")) {
629
- Log.d(TAG, "Removing file after successful save to photo")
630
- StorageUtil.deleteFile(outputFile)
631
- }
635
+ UiThreadUtil.runOnUiThread {
636
+ when {
637
+ ReturnCode.isSuccess(returnCode) -> {
638
+ val duration = endTime - startTime
639
+ val result = Arguments.createMap()
640
+
641
+ result.putString("outputPath", resolvedOutputFile)
642
+ result.putDouble("startTime", startTime)
643
+ result.putDouble("endTime", endTime)
644
+ result.putDouble("duration", duration)
645
+ result.putBoolean("success", true)
646
+
647
+ if (options?.getBoolean("saveToPhoto") == true && options.getString("type") == "video") {
648
+ Log.d(TAG, "Android trim: saveToPhoto is true, attempting to save to gallery")
649
+ try {
650
+ StorageUtil.saveVideoToGallery(reactApplicationContext, resolvedOutputFile)
651
+ Log.d(TAG, "Edited video saved to Photo Library successfully.")
652
+ if (options.getBoolean("removeAfterSavedToPhoto")) {
653
+ Log.d(TAG, "Removing file after successful save to photo")
654
+ StorageUtil.deleteFile(resolvedOutputFile)
655
+ }
632
656
 
633
- promise.resolve(result)
634
- } catch (e: IOException) {
635
- e.printStackTrace()
657
+ promise.resolve(result)
658
+ } catch (e: IOException) {
659
+ e.printStackTrace()
660
+
661
+ if (options.getBoolean("removeAfterFailedToSavePhoto")) {
662
+ Log.d(TAG, "Removing file after failed save to photo")
663
+ StorageUtil.deleteFile(resolvedOutputFile)
664
+ }
636
665
 
637
- if (options.getBoolean("removeAfterFailedToSavePhoto")) {
638
- Log.d(TAG, "Removing file after failed save to photo")
639
- StorageUtil.deleteFile(outputFile)
666
+ promise.reject(
667
+ Exception("Failed to save edited video to Photo Library: " + e.localizedMessage)
668
+ )
640
669
  }
670
+ } else {
671
+ Log.d(TAG, "Android trim: saveToPhoto is false or not video type, resolving with structured result")
641
672
 
642
- promise.reject(
643
- Exception("Failed to save edited video to Photo Library: " + e.localizedMessage)
644
- )
673
+ promise.resolve(result)
645
674
  }
646
- } else {
647
- Log.d(TAG, "Android trim: saveToPhoto is false or not video type, resolving with structured result")
648
-
649
- promise.resolve(result)
650
675
  }
651
- }
652
- ReturnCode.isCancel(returnCode) -> {
653
- // CANCEL
654
- println("FFmpeg command was cancelled")
655
- promise.reject(
656
- Exception("FFmpeg command was cancelled with code $returnCode")
657
- )
658
- }
659
- else -> {
660
- // FAILURE
661
- val errorMessage = String.format("Command failed with state %s and rc %s.%s", state, returnCode, session.getFailStackTrace());
662
- Log.d(TAG, errorMessage)
663
- promise.reject(
664
- Exception(errorMessage)
665
- )
676
+ ReturnCode.isCancel(returnCode) -> {
677
+ println("FFmpeg command was cancelled")
678
+ promise.reject(
679
+ Exception("FFmpeg command was cancelled with code $returnCode")
680
+ )
681
+ }
682
+ else -> {
683
+ val errorMessage = String.format("Command failed with state %s and rc %s.%s", state, returnCode, session.getFailStackTrace())
684
+ Log.d(TAG, errorMessage)
685
+ promise.reject(
686
+ Exception(errorMessage)
687
+ )
688
+ }
666
689
  }
667
690
  }
668
691
  }, { log ->
@@ -701,6 +724,10 @@ open class BaseVideoTrimModule internal constructor(
701
724
  reactApplicationContext.currentActivity?.startActivity(Intent.createChooser(shareIntent, "Share file"))
702
725
  }
703
726
 
727
+ fun cleanup() {
728
+ reactApplicationContext.removeLifecycleEventListener(this)
729
+ }
730
+
704
731
  companion object {
705
732
  const val NAME = "VideoTrim"
706
733
  const val TAG = "VideoTrimModule"
@@ -6,5 +6,6 @@ enum class ErrorCode {
6
6
  FAIL_TO_INITIALIZE_AUDIO_PLAYER,
7
7
  FAIL_TO_LOAD_MEDIA,
8
8
  FAIL_TO_SAVE_TO_PHOTO,
9
- FAIL_TO_SAVE_TO_DOCUMENTS
9
+ FAIL_TO_SAVE_TO_DOCUMENTS,
10
+ UNKNOWN
10
11
  }
@@ -3,6 +3,7 @@ package com.videotrim.utils
3
3
  import android.media.MediaMetadataRetriever
4
4
  import android.net.Uri
5
5
  import android.util.Log
6
+ import com.facebook.react.bridge.UiThreadUtil
6
7
  import java.io.IOException
7
8
 
8
9
  object MediaMetadataUtil {
@@ -47,7 +48,7 @@ object MediaMetadataUtil {
47
48
  Thread {
48
49
  val retriever = getMediaMetadataRetriever(urlString)
49
50
  if (retriever == null) {
50
- callback.onResult(false, "unknown", -1L)
51
+ UiThreadUtil.runOnUiThread { callback.onResult(false, "unknown", -1L) }
51
52
  return@Thread
52
53
  }
53
54
 
@@ -74,7 +75,10 @@ object MediaMetadataUtil {
74
75
  } catch (e: IOException) {
75
76
  Log.e(TAG, "Error releasing retriever", e)
76
77
  }
77
- callback.onResult(isValid, fileType, if (isValid) duration else -1L)
78
+ val resultIsValid = isValid
79
+ val resultFileType = fileType
80
+ val resultDuration = if (isValid) duration else -1L
81
+ UiThreadUtil.runOnUiThread { callback.onResult(resultIsValid, resultFileType, resultDuration) }
78
82
  }.start()
79
83
  }
80
84
 
@@ -9,6 +9,7 @@ import android.os.Environment
9
9
  import android.provider.MediaStore
10
10
  import android.text.TextUtils
11
11
  import com.facebook.react.bridge.ReactApplicationContext
12
+ import iknow.android.utils.BaseUtils
12
13
  import java.io.File
13
14
  import java.io.FileInputStream
14
15
  import java.io.FileOutputStream
@@ -38,7 +39,10 @@ object StorageUtil {
38
39
 
39
40
  fun deleteFile(path: String?): Boolean {
40
41
  if (TextUtils.isEmpty(path)) return true
41
- return deleteFile(File(path!!))
42
+ val file = File(path!!).canonicalFile
43
+ val allowedDir = BaseUtils.getContext().filesDir.canonicalFile
44
+ if (!file.path.startsWith(allowedDir.path)) return false
45
+ return deleteFile(file)
42
46
  }
43
47
 
44
48
  fun deleteFile(file: File?): Boolean {